src/main.rs
extern crate connectr;
use connectr::SpotifyResponse;
use connectr::TStatusBar;
use connectr::MenuItem;
use connectr::NSCallback;
use connectr::ConnectDeviceList;
use connectr::PlayerState;
extern crate rubrail;
use rubrail::Touchbar;
use rubrail::TTouchbar;
use rubrail::TScrubberData;
use rubrail::ImageTemplate;
use rubrail::SpacerType;
use rubrail::SwipeState;
extern crate fruitbasket;
use fruitbasket::FruitApp;
use fruitbasket::FruitError;
extern crate timer;
extern crate chrono;
extern crate ctrlc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::RwLock;
#[macro_use]
extern crate log;
use std::ptr;
use std::thread;
use std::time::Duration;
use std::sync::mpsc::channel;
use std::sync::mpsc::{Sender, Receiver};
use std::rc::Rc;
use std::cell::RefCell;
extern crate time;
extern crate open;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;
use std::process;
// How often to refresh Spotify state (if nothing triggers a refresh earlier).
pub const REFRESH_PERIOD: i64 = 30;
enum SpotifyThreadCommand {
Update,
InvalidSettings,
ConfigActive,
ConfigInactive,
}
#[allow(dead_code)]
#[derive(PartialEq, Debug)]
enum RefreshTime {
Now, // immediately
Soon, // after ~1 sec
Later, // don't change whatever the current schedule is
Redraw, // instantly, with stale data
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
enum CallbackAction {
SelectDevice,
PlayPause,
SkipNext,
SkipPrev,
Volume,
Preset,
Redraw,
Reconfigure,
SaveTrack,
EditAlarms,
}
#[derive(Serialize, Deserialize, Debug)]
struct MenuCallbackCommand {
action: CallbackAction,
sender: u64,
data: String,
}
struct MenuItems {
device: Vec<(MenuItem, String)>,
play: MenuItem,
next: MenuItem,
prev: MenuItem,
save: MenuItem,
preset: Vec<MenuItem>,
volume: Vec<MenuItem>,
}
struct ConnectrApp {
menu: MenuItems,
// TODO: move touchbar in
}
struct TouchbarScrubberData {
action: CallbackAction,
entries: RefCell<Vec<(String,String)>>,
tx: Sender<String>,
}
impl TouchbarScrubberData {
fn new(action: CallbackAction,
tx: Sender<String>) -> Rc<TouchbarScrubberData> {
Rc::new(TouchbarScrubberData {
action: action,
entries: RefCell::new(Vec::<(String,String)>::new()),
tx: tx,
})
}
fn fill(&self, items: Vec<(String,String)>) {
let mut entries = self.entries.borrow_mut();
entries.clear();
for item in items {
entries.push(item);
}
}
}
impl TScrubberData for TouchbarScrubberData {
fn count(&self, _item: rubrail::ItemId) -> u32 {
self.entries.borrow().len() as u32
}
fn text(&self, _item: rubrail::ItemId, idx: u32) -> String {
match self.entries.borrow().get(idx as usize) {
Some(e) => e.0.to_string(),
None => String::new(),
}
}
fn width(&self, _item: rubrail::ItemId, idx: u32) -> u32 {
// 10px per character + some padding seems to work nicely for the default
// font. no idea what it's like on other machines. does the touchbar
// font change? ¯\_(ツ)_/¯
let len = match self.entries.borrow().get(idx as usize) {
Some(e) => e.0.len() as u32,
None => 1,
};
let width = len * 8 + 20;
width
}
fn touch(&self, ui_item: rubrail::ItemId, idx: u32) {
info!("scrub touch: {}", idx);
if let Some(item) = self.entries.borrow().get(idx as usize) {
let cmd = MenuCallbackCommand {
action: self.action,
sender: ui_item,
data: item.1.clone(),
};
let _ = self.tx.send(serde_json::to_string(&cmd).unwrap());
}
}
}
enum TouchbarLabelState {
TrackArtist,
Track,
Artist,
}
#[allow(dead_code)]
struct TouchbarUI {
touchbar: Touchbar,
root_bar: rubrail::BarId,
playing_label: rubrail::ItemId,
label_state: Arc<RwLock<TouchbarLabelState>>,
prev_button: rubrail::ItemId,
play_pause_button: rubrail::ItemId,
next_button: rubrail::ItemId,
preset_bar: rubrail::BarId,
preset_popover: rubrail::ItemId,
preset_data: Rc<TouchbarScrubberData>,
preset_scrubber: rubrail::ItemId,
device_bar: rubrail::BarId,
device_popover: rubrail::ItemId,
device_data: Rc<TouchbarScrubberData>,
device_scrubber: rubrail::ItemId,
volume_bar: rubrail::BarId,
volume_popover: rubrail::ItemId,
volume_slider: rubrail::ItemId,
submenu_bar: rubrail::BarId,
submenu_popover: rubrail::ItemId,
}
impl TouchbarUI {
fn init(tx: Sender<String>) -> TouchbarUI {
let mut touchbar = Touchbar::alloc("cnr");
let icon = rubrail::util::bundled_resource_path("connectr_80px_300dpi", "png");
if let Some(path) = icon {
touchbar.set_icon(&path);
}
let playing_label = touchbar.create_label("");
let label_state = Arc::new(RwLock::new(TouchbarLabelState::TrackArtist));
let cb_label_state = label_state.clone();
let tx_clone = tx.clone();
touchbar.add_item_tap_gesture(&playing_label, 2, 1, Box::new(move |s| {
let mut state = cb_label_state.write().unwrap();
*state = match *state {
TouchbarLabelState::TrackArtist => TouchbarLabelState::Track,
TouchbarLabelState::Track => TouchbarLabelState::Artist,
TouchbarLabelState::Artist => TouchbarLabelState::TrackArtist,
};
let cmd = MenuCallbackCommand {
action: CallbackAction::Redraw,
sender: *s,
data: String::new(),
};
let _ = tx_clone.send(serde_json::to_string(&cmd).unwrap());
}));
// Fade text color to green as finger swipes right across the label, and
// add a solid white border indicating the label is 'selected' after a
// long enough slide. Will be used for 'swipe to save' feature.
let tx_clone = tx.clone();
touchbar.add_item_swipe_gesture(&playing_label, Box::new(move |item,state,translation| {
let rgba = match translation {
t if t > 170. => (0.1, 1.0, 0.7, 1.0),
_ => (0.9, 0.9, 0.9, 1.0),
};
match state {
SwipeState::Cancelled | SwipeState::Failed | SwipeState::Unknown => {
unsafe { rubrail::util::set_text_color(item, 1., 1., 1., 1.); }
},
SwipeState::Ended => {
unsafe { rubrail::util::set_text_color(item, 1., 1., 1., 1.); }
match translation {
t if t > 170. => {
let cmd = MenuCallbackCommand {
action: CallbackAction::SaveTrack,
sender: 0,
data: String::new(),
};
let _ = tx_clone.send(serde_json::to_string(&cmd).unwrap());
},
_ => {},
}
}
_ => {
unsafe { rubrail::util::set_text_color(item, rgba.0, rgba.1, rgba.2, rgba.3); }
}
}
}));
let image = touchbar.create_image_from_template(ImageTemplate::RewindTemplate);
let tx_clone = tx.clone();
let prev_button = touchbar.create_button(Some(&image), None, Box::new(move |s| {
let cmd = MenuCallbackCommand {
action: CallbackAction::SkipPrev,
sender: *s,
data: String::new(),
};
let _ = tx_clone.send(serde_json::to_string(&cmd).unwrap());
}));
touchbar.update_button_width(&prev_button, 40);
let image = touchbar.create_image_from_template(ImageTemplate::PlayPauseTemplate);
let tx_clone = tx.clone();
let play_pause_button = touchbar.create_button(Some(&image), None, Box::new(move |s| {
let cmd = MenuCallbackCommand {
action: CallbackAction::PlayPause,
sender: *s,
data: String::new(),
};
let _ = tx_clone.send(serde_json::to_string(&cmd).unwrap());
}));
touchbar.update_button_width(&play_pause_button, 40);
let image = touchbar.create_image_from_template(ImageTemplate::FastForwardTemplate);
let tx_clone = tx.clone();
let next_button = touchbar.create_button(Some(&image), None, Box::new(move |s| {
let cmd = MenuCallbackCommand {
action: CallbackAction::SkipNext,
sender: *s,
data: String::new(),
};
let _ = tx_clone.send(serde_json::to_string(&cmd).unwrap());
}));
touchbar.update_button_width(&next_button, 40);
let preset_scrubber_data = TouchbarScrubberData::new(CallbackAction::Preset,
tx.clone());
let preset_scrubber = touchbar.create_text_scrubber(preset_scrubber_data.clone());
let preset_bar = touchbar.create_bar();
touchbar.add_items_to_bar(&preset_bar, vec![preset_scrubber]);
let preset_popover = touchbar.create_popover_item(
None,
Some(&format!("{}", "Presets")),
&preset_bar);
touchbar.update_button_width(&preset_popover, 200);
let device_scrubber_data = TouchbarScrubberData::new(CallbackAction::SelectDevice,
tx.clone());
let device_scrubber = touchbar.create_text_scrubber(device_scrubber_data.clone());
let device_bar = touchbar.create_bar();
touchbar.add_items_to_bar(&device_bar, vec![device_scrubber]);
let device_popover = touchbar.create_popover_item(
None,
Some(&format!("{}", "Devices")),
&device_bar);
touchbar.update_button_width(&device_popover, 200);
let tx_clone = tx.clone();
let volume_slider = touchbar.create_slider(0., 100., Some("Volume"),
false, Box::new(move |s,v| {
let cmd = MenuCallbackCommand {
action: CallbackAction::Volume,
sender: *s,
data: (v as u32).to_string(),
};
let _ = tx_clone.send(serde_json::to_string(&cmd).unwrap());
}));
let volume_bar = touchbar.create_bar();
touchbar.add_items_to_bar(&volume_bar, vec![volume_slider]);
let image = touchbar.create_image_from_template(
ImageTemplate::AudioOutputVolumeMediumTemplate);
let volume_popover = touchbar.create_popover_item(
Some(&image),
None,
&volume_bar);
touchbar.update_button_width(&volume_popover, 40);
let submenu_bar = touchbar.create_bar();
touchbar.add_items_to_bar(&submenu_bar, vec![
preset_popover,
device_popover,
]);
let image = touchbar.create_image_from_template(ImageTemplate::GoUpTemplate);
let submenu_popover = touchbar.create_popover_item(
Some(&image),
Some("More"),//None,
&submenu_bar);
// TODO: search button (SearchTemplate)
// TODO: alarm button (AlarmTemplate)
let flexible_space = touchbar.create_spacer(SpacerType::Flexible);
let small_space = touchbar.create_spacer(SpacerType::Small);
let root_bar = touchbar.create_bar();
touchbar.add_items_to_bar(&root_bar, vec![
playing_label,
prev_button,
play_pause_button,
next_button,
small_space,
volume_popover,
flexible_space,
submenu_popover,
]);
touchbar.set_bar_as_root(root_bar);
TouchbarUI {
touchbar: touchbar,
root_bar: root_bar,
playing_label: playing_label,
label_state: label_state,
prev_button: prev_button,
play_pause_button: play_pause_button,
next_button: next_button,
preset_bar: preset_bar,
preset_popover: preset_popover,
preset_data: preset_scrubber_data,
preset_scrubber: preset_scrubber,
device_bar: device_bar,
device_popover: device_popover,
device_data: device_scrubber_data,
device_scrubber: device_scrubber,
volume_bar: volume_bar,
volume_popover: volume_popover,
volume_slider: volume_slider,
submenu_bar: submenu_bar,
submenu_popover: submenu_popover,
}
}
fn update_now_playing(&mut self, track: &str, artist: &str) {
let text = match *self.label_state.read().unwrap() {
TouchbarLabelState::TrackArtist => format!("{}\n{}", track, artist),
TouchbarLabelState::Track => format!("{}", track),
TouchbarLabelState::Artist => format!("{}", artist),
};
self.touchbar.update_label(&self.playing_label, &text);
self.touchbar.update_label_width(&self.playing_label, 250)
}
fn update_volume(&mut self, volume: u32) {
self.touchbar.update_slider(&self.volume_slider, volume as f64);
}
fn update_scrubbers(&mut self) {
self.touchbar.refresh_scrubber(&self.device_scrubber);
self.touchbar.refresh_scrubber(&self.preset_scrubber);
}
fn set_selected_device(&mut self, selected: u32) {
self.touchbar.select_scrubber_item(&self.device_scrubber, selected);
}
fn update_play_button(&mut self, is_playing: bool) {
let image = match is_playing {
true => self.touchbar.create_image_from_template(ImageTemplate::PauseTemplate),
false => self.touchbar.create_image_from_template(ImageTemplate::PlayTemplate),
};
self.touchbar.update_button(&self.play_pause_button, Some(&image), None);
}
}
fn play_action_label(is_playing: bool) -> &'static str {
match is_playing {
true => "Pause",
false => "Play",
}
}
fn loading_menu<T: TStatusBar>(status: &mut T) {
status.add_label("Syncing with Spotify...");
status.add_separator();
status.add_quit("Exit");
}
fn reconfig_menu<T: TStatusBar>(status: &mut T) {
status.add_label("Invalid Configuration!");
status.add_separator();
let cb: NSCallback = Box::new(move |sender, tx| {
let cmd = MenuCallbackCommand {
action: CallbackAction::Reconfigure,
sender: sender,
data: String::new(),
};
let _ = tx.send(serde_json::to_string(&cmd).unwrap());
});
let _ = status.add_item(None, "Reconfigure Connectr", cb, false);
status.add_separator();
let cb: NSCallback = Box::new(move |_sender, _tx| {
let _ = open::that("https://github.com/mrmekon/connectr");
});
let _ = status.add_item(None, "Help!", cb, false);
status.add_separator();
status.add_quit("Exit");
}
fn fill_menu<T: TStatusBar>(app: &mut ConnectrApp,
spotify: &SpotifyThread,
status: &mut T,
touchbar: &mut TouchbarUI,
web_config_active: bool) {
if web_config_active {
// This is a leaky-abstraction way of handling the webapi thread being
// blocked, and thus unable to respond to a second 'Edit Alarms' request.
// That's problematic if you close the browser window, since you'll have
// to wait an hour for it to time out... so block all actions until it
// is answered.
let cb: NSCallback = Box::new(move |_sender, _tx| {
let _ = open::that(format!("http://127.0.0.1:{}", connectr::settings::WEB_PORT));
});
let _ = status.add_item(None, "Re-launch Config", cb, false);
status.add_separator();
status.add_quit("Exit");
return;
}
let device_list = spotify.device_list.read().unwrap();
let player_state = spotify.player_state.read().unwrap();
let presets = spotify.presets.read().unwrap();
let empty_device_list: ConnectDeviceList = Default::default();
let empty_player_state: PlayerState = Default::default();
let device_list = match device_list.as_ref() {
Some(x) => x,
None => &empty_device_list,
};
let player_state = match player_state.as_ref() {
Some(x) => x,
None => &empty_player_state,
};
let track = match player_state.item {
Some(ref item) => item.name.clone(),
_ => "unknown".to_string()
};
let artist = match player_state.item {
Some(ref item) => item.artists[0].name.clone(),
_ => "unknown".to_string()
};
let album = match player_state.item {
Some(ref item) => item.album.name.clone(),
_ => "unknown".to_string()
};
let duration_ms = match player_state.item {
Some(ref item) => item.duration_ms,
_ => 0,
};
let min = duration_ms / 1000 / 60;
let sec = (duration_ms - (min * 60 * 1000)) / 1000;
info!("Playback State:\n{}", player_state);
match player_state.item {
Some(_) => {
let play_str = format!("{}\n{}\n{}", track, artist, album);
status.set_tooltip(&play_str);
},
None => status.set_tooltip("unknown"),
}
status.add_label("Now Playing:");
status.add_separator();
match player_state.item {
Some(_) => {
status.add_label(&format!("{:<50}", track));
status.add_label(&format!("{:<50}", artist));
status.add_label(&format!("{:<50}", album));
status.add_label(&format!("{:<50}", format!("{}:{:02}", min, sec)));
},
None => {
status.add_label(&format!("{:<50}", "unknown"));
}
}
touchbar.update_now_playing(&track, &artist);
status.add_label("");
status.add_label("Actions:");
status.add_separator();
{
let play_str = play_action_label(player_state.is_playing);
let is_playing = player_state.is_playing.clone();
let cb: NSCallback = Box::new(move |sender, tx| {
let cmd = MenuCallbackCommand {
action: CallbackAction::PlayPause,
sender: sender,
data: is_playing.to_string(),
};
let _ = tx.send(serde_json::to_string(&cmd).unwrap());
});
app.menu.play = status.add_item(None, &play_str, cb, false);
touchbar.update_play_button(is_playing);
let cb: NSCallback = Box::new(move |sender, tx| {
let cmd = MenuCallbackCommand {
action: CallbackAction::SkipNext,
sender: sender,
data: String::new(),
};
let _ = tx.send(serde_json::to_string(&cmd).unwrap());
});
app.menu.next = status.add_item(None, "Next", cb, false);
let cb: NSCallback = Box::new(move |sender, tx| {
let cmd = MenuCallbackCommand {
action: CallbackAction::SkipPrev,
sender: sender,
data: String::new(),
};
let _ = tx.send(serde_json::to_string(&cmd).unwrap());
});
app.menu.prev = status.add_item(None, "Previous", cb, false);
let cb: NSCallback = Box::new(move |sender, tx| {
let cmd = MenuCallbackCommand {
action: CallbackAction::SaveTrack,
sender: sender,
data: String::new(),
};
let _ = tx.send(serde_json::to_string(&cmd).unwrap());
});
app.menu.save = status.add_item(None, "Quick-Save", cb, false);
}
status.add_label("");
status.add_label("Presets:");
status.add_separator();
{
let preset_tuples: Vec<(String,String)> = presets.iter().map(|p| {
(p.0.clone(), p.1.clone())
}).collect();
touchbar.preset_data.fill(preset_tuples);
for preset in presets.iter() {
let ref name = preset.0;
let uri = preset.1.clone();
let cb: NSCallback = Box::new(move |sender, tx| {
let cmd = MenuCallbackCommand {
action: CallbackAction::Preset,
sender: sender,
data: uri.to_owned(),
};
let _ = tx.send(serde_json::to_string(&cmd).unwrap());
});
let selected = player_state.playing_from_context(&preset.1);
let item = status.add_item(None, &name.clone(), cb, selected);
app.menu.preset.push(item);
}
}
status.add_label("");
status.add_label("Devices:");
status.add_separator();
info!("Visible Devices:");
let devices: Vec<(String,String)> = device_list.into_iter().map(|d| {
(d.name.clone(), d.id.clone().unwrap_or(String::new()))
}).collect();
touchbar.device_data.fill(devices);
if device_list.len() == 0 {
status.add_label("unavailable");
}
let selected_arr: Vec<bool> = device_list.into_iter().map(|d| {d.is_active}).collect();
if let Ok(selected) = selected_arr.binary_search(&true) {
touchbar.set_selected_device(selected as u32);
}
touchbar.update_scrubbers();
let mut cur_volume: u32 = 0;
let mut cur_volume_exact: u32 = 0;
for dev in device_list {
info!("{}", dev);
let id = match dev.id {
Some(ref id) => id.clone(),
None => "".to_string(),
};
let cb_id = id.clone();
let cb: NSCallback = Box::new(move |sender, tx| {
let cmd = MenuCallbackCommand {
action: CallbackAction::SelectDevice,
sender: sender,
data: cb_id.to_owned(),
};
let _ = tx.send(serde_json::to_string(&cmd).unwrap());
});
let item = status.add_item(None, &dev.name, cb, dev.is_active);
if dev.is_active {
cur_volume_exact = dev.volume_percent.unwrap_or(0);
cur_volume = match dev.volume_percent {
Some(v) => {
(v as f32 / 10.0).round() as u32 * 10
},
None => 100,
};
}
app.menu.device.push((item, id));
touchbar.update_volume(cur_volume_exact);
}
info!("");
status.add_label("");
status.add_separator();
let volume_menu = status.add_submenu("Volume", Box::new(move |_,_| {}));
{
let mut i = 0;
while i <= 100 {
let vol_str = format!("{}%", i);
let cb: NSCallback = Box::new(move |sender, tx| {
let cmd = MenuCallbackCommand {
action: CallbackAction::Volume,
sender: sender,
data: i.to_string(),
};
let _ = tx.send(serde_json::to_string(&cmd).unwrap());
});
let item = status.add_item(Some(volume_menu), &vol_str, cb, i == cur_volume);
app.menu.volume.push(item);
i += 10;
}
}
status.add_separator();
let cb: NSCallback = Box::new(move |sender, tx| {
let cmd = MenuCallbackCommand {
action: CallbackAction::EditAlarms,
sender: sender,
data: String::new(),
};
let _ = tx.send(serde_json::to_string(&cmd).unwrap());
});
let _ = status.add_item(None, "Edit Alarms", cb, false);
let cb: NSCallback = Box::new(move |sender, tx| {
let cmd = MenuCallbackCommand {
action: CallbackAction::Reconfigure,
sender: sender,
data: String::new(),
};
let _ = tx.send(serde_json::to_string(&cmd).unwrap());
});
let _ = status.add_item(None, "Reconfigure Connectr", cb, false);
status.add_separator();
let cb: NSCallback = Box::new(move |_sender, _tx| {
let _ = open::that("https://open.spotify.com/search/");
});
let _ = status.add_item(None, "Search Spotify", cb, false);
status.add_separator();
status.add_quit("Exit");
}
fn clear_menu<T: TStatusBar>(app: &mut ConnectrApp, status: &mut T) {
app.menu = MenuItems {
device: Vec::<(MenuItem, String)>::new(),
play: ptr::null_mut(),
next: ptr::null_mut(),
prev: ptr::null_mut(),
save: ptr::null_mut(),
preset: Vec::<MenuItem>::new(),
volume: Vec::<MenuItem>::new(),
};
status.clear_items();
}
fn handle_callback(player_state: Option<&connectr::PlayerState>,
spotify: &mut connectr::SpotifyConnectr,
cmd: &MenuCallbackCommand) -> RefreshTime {
info!("Executed action: {:?}", cmd.action);
let mut refresh = RefreshTime::Now;
match cmd.action {
CallbackAction::SelectDevice => {
require(spotify.transfer(cmd.data.clone(), true));
},
CallbackAction::PlayPause => {
if let Some(player_state) = player_state {
match player_state.is_playing {
true => {require(spotify.pause());},
false => {require(spotify.play(None));},
}
}
},
CallbackAction::Preset => {
play_uri(spotify, None, Some(&cmd.data));
}
CallbackAction::SkipNext => {
require(spotify.next());
}
CallbackAction::SkipPrev => {
require(spotify.previous());
}
CallbackAction::Volume => {
let vol = cmd.data.parse::<u32>().unwrap();
require(spotify.volume(vol));
}
CallbackAction::Redraw => {
refresh = RefreshTime::Redraw;
}
CallbackAction::SaveTrack => {
if let Some(player_state) = player_state {
if let Some(ref ctx) = player_state.context {
let playlist: Option<String>;
{
match spotify.quick_save_playlist(&ctx.uri) {
Some(u) => playlist = Some(u.to_owned()),
None => playlist = None,
}
}
if let Some(playlist) = playlist {
if let Some(ref item) = player_state.item {
let track = item.uri.to_owned();
require(spotify.save_track(track, playlist));
}
}
}
}
}
CallbackAction::Reconfigure => {}
CallbackAction::EditAlarms => {}
}
refresh
}
fn refresh_time(player_state: Option<&connectr::PlayerState>, now: i64) -> i64 {
let refresh_offset = match player_state {
Some(ref state) => {
match state.is_playing {
true => {
let duration_ms = match state.item {
Some(ref item) => item.duration_ms,
_ => 0,
};
let track_end = match state.progress_ms {
Some(prog) => {
if prog < duration_ms {
duration_ms - prog
}
else {
0
}
},
None => duration_ms,
} as i64;
// Refresh 1 second after track ends
track_end/1000 + 1
},
false => REFRESH_PERIOD,
}
}
None => REFRESH_PERIOD,
};
let refresh_offset = std::cmp::min(REFRESH_PERIOD, refresh_offset) as i64;
info!("State refresh in {} seconds.", refresh_offset);
now + refresh_offset
}
fn find_wine_path() -> Option<std::path::PathBuf> {
let search_paths = connectr::search_paths();
info!("Search paths: {:?}", search_paths);
for search_path in search_paths {
let path = std::path::PathBuf::from(search_path).join("wine");
if path.exists() && path.is_dir() {
return Some(path);
}
}
None
}
fn scrobble(spotify: &mut connectr::SpotifyConnectr,
state: Option<&connectr::PlayerState>,
played_ms: u64,
done: bool) {
if let Some(state) = flatten_player_state(state) {
let len = state.duration_ms;
let artist = state.artist.clone();
let track = state.track.clone();
let album = state.album.clone();
let devtype = state.device_type.clone();
if done {
// Scrobbling API rules:
// - Track longer than 30 seconds
// - Track played at least half its duration OR 4 minutes
if len > 30000 &&
(played_ms > 4*60000 || played_ms >= len / 2)
{
spotify.scrobble(artist, track, album, devtype);
}
}
else {
spotify.scrobbler_now_playing(artist, track, album, devtype);
}
}
}
struct FlatPlayState {
#[allow(dead_code)]
artist: String,
#[allow(dead_code)]
track: String,
#[allow(dead_code)]
album: String,
device_type: String,
uri: String,
progress_ms: u64,
duration_ms: u64,
is_playing: bool,
}
fn flatten_player_state(state: Option<&connectr::PlayerState>) -> Option<FlatPlayState> {
match state {
Some(state) => {
match state.item {
Some(ref item) => {
let artist = match item.artists.get(0) {
Some(ref a) => a.name.clone(),
None => String::new(),
};
Some(FlatPlayState {
artist: artist,
track: item.name.clone(),
album: item.album.name.clone(),
device_type: state.device.device_type.clone(),
uri: item.uri.clone(),
progress_ms: state.progress_ms.unwrap_or(0),
duration_ms: item.duration_ms,
is_playing: state.is_playing,
})
},
None => None,
}
},
None => None,
}
}
#[derive(Debug)]
enum StateChange {
Stopped(u64),
Changed(u64),
Played(u64),
Unchanged,
}
fn compare_playback_states(old: Option<&connectr::PlayerState>,
new: Option<&connectr::PlayerState>) -> StateChange {
let old_track = flatten_player_state(old);
let new_track = flatten_player_state(new);
if old_track.is_none() && new_track.is_none() {
return StateChange::Unchanged;
}
if old_track.is_some() && new_track.is_none() {
return StateChange::Stopped(0);
}
if old_track.is_none() && new_track.is_some() {
match new_track.unwrap().is_playing {
true => { return StateChange::Changed(0); },
false => { return StateChange::Stopped(0); },
}
}
let old_track = old_track.unwrap();
let new_track = new_track.unwrap();
let new_time = new_track.progress_ms;
let old_time = old_track.progress_ms;
if old_track.uri != new_track.uri {
// Assume that up to 31 seconds of the previous track played.
let played = std::cmp::min(
std::cmp::max(old_track.duration_ms as i64 - old_time as i64, 0),
31000) as u64;
if old_track.is_playing && !new_track.is_playing {
// End of context
return StateChange::Stopped(played);
}
return StateChange::Changed(played);
}
let played = std::cmp::max(new_time as i64 - old_time as i64, 0) as u64;
if (new_time == 0 && !new_track.is_playing) && (old_time > 0 && old_track.is_playing) {
// Playlist finished and reset. Normally caught above, but this
// is a special case for a 1-track playlist.
let played = std::cmp::min(
std::cmp::max(old_track.duration_ms as i64 - old_time as i64, 0),
31000) as u64;
return StateChange::Stopped(played);
}
if played == 0 {
return StateChange::Unchanged;
}
if !old_track.is_playing {
return StateChange::Changed(played);
}
StateChange::Played(played)
}
struct SpotifyThread {
#[allow(dead_code)]
handle: std::thread::JoinHandle<()>,
#[allow(dead_code)]
tx: Sender<String>,
rx: Receiver<SpotifyThreadCommand>,
device_list: Arc<RwLock<Option<connectr::ConnectDeviceList>>>,
player_state: Arc<RwLock<Option<connectr::PlayerState>>>,
presets: Arc<RwLock<Vec<(String,String)>>>,
}
fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
let (tx_in,rx_in) = channel::<String>();
let (tx_out,rx_out) = channel::<SpotifyThreadCommand>();
let device_list = Arc::new(RwLock::new(Some(Default::default())));
let player_state = Arc::new(RwLock::new(None));
let presets = Arc::new(RwLock::new(vec![]));
let thread_device_list = device_list.clone();
let thread_player_state = player_state.clone();
let thread_presets = presets.clone();
let thread = thread::spawn(move || {
let tx = tx_out;
let rx = rx_in;
let rx_cmd = rx_cmd;
let mut refresh_time_utc = 0;
let mut track_play_time_ms: u64 = 0;
// Continuously try to create a connection to Spotify web API.
// If it fails, assume that the settings file is corrupt and inform
// the main thread of it. The main thread can request that the
// settings file be re-configured.
let mut spotify: Option<connectr::SpotifyConnectr>;
loop {
spotify = connectr::SpotifyConnectr::new().build();
match spotify {
Some(_) => { break; },
None => {
let _ = tx.send(SpotifyThreadCommand::InvalidSettings);
if let Ok(s) = rx_cmd.recv_timeout(Duration::from_secs(120)) {
let cmd: MenuCallbackCommand = serde_json::from_str(&s).unwrap();
if cmd.action == CallbackAction::Reconfigure {
connectr::reconfigure(None);
}
}
},
}
}
let mut spotify = spotify.unwrap();
let device_list = thread_device_list;
let player_state = thread_player_state;
let presets = thread_presets;
info!("Created Spotify controller.");
spotify.connect();
info!("Created Spotify connection.");
spotify.set_target_device(None);
{
let mut preset_writer = presets.write().unwrap();
*preset_writer = spotify.get_presets().clone();
let _ = tx.send(SpotifyThreadCommand::Update);
}
loop {
if rx.try_recv().is_ok() {
// Main thread tells us to shutdown
break;
}
let now = time::now_utc().to_timespec().sec as i64;
spotify.await_once(false);
// Block for 200ms while waiting for UI input. This throttles the
// thread CPU usage, at the expense of slight delays for metadata
// updates. Optimizes for UI response.
if let Ok(s) = rx_cmd.recv_timeout(Duration::from_millis(200)) {
info!("Received {}", s);
let cmd: MenuCallbackCommand = serde_json::from_str(&s).unwrap();
if cmd.action == CallbackAction::EditAlarms {
let devs = device_list.read().unwrap();
let _ = tx.send(SpotifyThreadCommand::ConfigActive);
spotify.alarm_configure((*devs).as_ref());
let _ = tx.send(SpotifyThreadCommand::ConfigInactive);
}
if cmd.action == CallbackAction::Reconfigure {
info!("Reconfiguring settings.");
let _ = tx.send(SpotifyThreadCommand::ConfigActive);
connectr::reconfigure(Some(spotify.settings()));
spotify.reread_settings();
let _ = tx.send(SpotifyThreadCommand::ConfigInactive);
info!("Finished reconfiguring.");
}
let refresh_strategy = handle_callback(player_state.read().unwrap().as_ref(),
&mut spotify, &cmd);
refresh_time_utc = match refresh_strategy {
RefreshTime::Now => {
// Let the other thread run, and hope that the command
// gets through. The Spotify backend is really slow to
// show changes sometimes, even after they happen.
// TODO: change the UI before the real backend changes
// go through.
thread::sleep(Duration::from_millis(100));
now - 1
},
RefreshTime::Soon => now + 1,
_ => refresh_time_utc,
};
if refresh_strategy == RefreshTime::Redraw {
let _ = tx.send(SpotifyThreadCommand::Update);
}
}
if now > refresh_time_utc {
info!("Request update");
let dev_list = spotify.request_device_list();
{
let mut dev_writer = device_list.write().unwrap();
*dev_writer = match dev_list {
Some(_) => dev_list,
None => Some(Default::default()),
};
}
let play_state = spotify.request_player_state();
{
let mut player_writer = player_state.write().unwrap();
let cmp = compare_playback_states(player_writer.as_ref(), play_state.as_ref());
match cmp {
StateChange::Changed(time_ms) => {
track_play_time_ms += time_ms;
scrobble(&mut spotify, player_writer.as_ref(), track_play_time_ms, true);
track_play_time_ms = 0;
scrobble(&mut spotify, play_state.as_ref(), track_play_time_ms, false);
}
StateChange::Stopped(time_ms) => {
track_play_time_ms += time_ms;
scrobble(&mut spotify, player_writer.as_ref(), track_play_time_ms, true);
track_play_time_ms = 0;
},
StateChange::Played(time_ms) => {
track_play_time_ms += time_ms;
},
StateChange::Unchanged => {},
}
*player_writer = play_state;
}
refresh_time_utc = refresh_time(player_state.read().unwrap().as_ref(), now);
info!("Refreshed Spotify state.");
let _ = tx.send(SpotifyThreadCommand::Update);
}
}
});
SpotifyThread {
handle: thread,
tx: tx_in,
rx: rx_out,
device_list: device_list,
player_state: player_state,
presets: presets,
}
}
fn main() {
fruitbasket::create_logger(".connectr.log", fruitbasket::LogDir::Home, 5, 2).unwrap();
// Relaunch in a Mac app bundle if running on OS X and not already bundled.
let icon = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("icon").join("connectr.icns");
let touchbar_icon = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("connectr_80px_300dpi.png");
let clientid_script = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("clientid_prompt.sh");
let license = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("LICENSE");
let ini = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("connectr.ini.in");
let nsapp = match fruitbasket::Trampoline::new(
"Connectr", "connectr", "com.trevorbentley.connectr")
.icon("connectr.icns")
.version(env!("CARGO_PKG_VERSION"))
.plist_key("LSBackgroundOnly", "1")
// Register "connectr://" URI scheme.
.plist_raw_string("
CFBundleURLTypes = ( {
CFBundleTypeRole = \"Viewer\";
CFBundleURLName = \"Connectr URL\";
CFBundleURLSchemes = (\"connectr\");
} );\n".into())
.resource(icon.to_str().unwrap())
.resource(touchbar_icon.to_str().unwrap())
.resource(clientid_script.to_str().unwrap())
.resource(license.to_str().unwrap())
.resource(ini.to_str().unwrap())
.build(fruitbasket::InstallDir::Custom("target/".to_string())) {
Ok(app) => { app },
Err(FruitError::UnsupportedPlatform(_)) => { FruitApp::new() },
_ => {
error!("Couldn't create Mac app bundle.");
std::process::exit(1);
},
};
nsapp.set_activation_policy(fruitbasket::ActivationPolicy::Prohibited);
info!("Started Connectr");
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
match ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
}) {
Ok(_) => {},
Err(_) => { error!("Failed to register Ctrl-C handler."); }
}
let mut app = ConnectrApp {
menu: MenuItems {
device: Vec::<(MenuItem, String)>::new(),
play: ptr::null_mut(),
next: ptr::null_mut(),
prev: ptr::null_mut(),
save: ptr::null_mut(),
preset: Vec::<MenuItem>::new(),
volume: Vec::<MenuItem>::new(),
},
};
let (tx,rx) = channel::<String>();
let spotify_thread = create_spotify_thread(rx);
let mut status = connectr::StatusBar::new(tx.clone());
info!("Created status bar.");
status.register_url_handler();
loading_menu(&mut status);
let mut touchbar = TouchbarUI::init(tx);
info!("Created touchbar.");
let mut tiny: Option<process::Child> = None;
if let Some(wine_dir) = find_wine_path() {
info!("Found wine root: {:?}", wine_dir);
let wine_exe = wine_dir.join("wine");
let tiny_exe = wine_dir.join("tiny.exe");
let config_dir = wine_dir.join("config");
debug!("{:?} / {:?} / {:?} / {:?}", wine_dir, wine_exe, config_dir, tiny_exe);
tiny = Some(process::Command::new(wine_exe)
.env("WINEPREFIX", config_dir)
.current_dir(wine_dir)
.args(&[tiny_exe])
.spawn().unwrap());
}
else {
warn!("Didn't find Wine in search path.");
}
let mut web_config_active: bool = false;
let mut need_redraw: bool = false;
while running.load(Ordering::SeqCst) {
match spotify_thread.rx.recv_timeout(Duration::from_millis(100)) {
Ok(cmd) => {
match cmd {
SpotifyThreadCommand::Update => { need_redraw = true; },
SpotifyThreadCommand::InvalidSettings => {
clear_menu(&mut app, &mut status);
reconfig_menu(&mut status);
}
SpotifyThreadCommand::ConfigActive => {
web_config_active = true;
need_redraw = true;
},
SpotifyThreadCommand::ConfigInactive => {
web_config_active = false;
need_redraw = true;
},
}
},
Err(_) => {}
}
if need_redraw && status.can_redraw() {
clear_menu(&mut app, &mut status);
fill_menu(&mut app, &spotify_thread, &mut status, &mut touchbar, web_config_active);
need_redraw = false;
}
status.run(false);
}
info!("Exiting.\n");
if let Some(mut tiny_proc) = tiny {
let _ = tiny_proc.kill();
let _ = tiny_proc.wait();
}
}
fn require(response: SpotifyResponse) {
match response.code.unwrap() {
200 ..= 299 => { info!("Response: {}", response.code.unwrap()); },
_ => { warn!("Spotify action failed! ({})", response); }
}
}
fn play_uri(spotify: &mut connectr::SpotifyConnectr, device: Option<&str>, uri: Option<&str>) {
match device {
Some(dev) => { spotify.set_target_device(Some(dev.to_string())); },
None => { spotify.set_target_device(None); },
}
match uri {
Some(s) => {
let ctx = connectr::PlayContext::new()
.context_uri(s)
.offset_position(0)
.build();
require(spotify.play(Some(&ctx)));
}
None => {
info!("Transfer!");
require(spotify.play(None));
}
};
// Always set it back to None, so commands go to the currently
// playing device.
spotify.set_target_device(None);
}