diff --git a/src/app/components/mod.rs b/src/app/components/mod.rs index 537a3dc8..86d4809e 100644 --- a/src/app/components/mod.rs +++ b/src/app/components/mod.rs @@ -25,6 +25,9 @@ pub use playlist::*; mod login; pub use login::*; +mod settings; +pub use settings::*; + mod player_notifier; pub use player_notifier::PlayerNotifier; diff --git a/src/app/components/player_notifier.rs b/src/app/components/player_notifier.rs index c61ddfaf..e2e5aa35 100644 --- a/src/app/components/player_notifier.rs +++ b/src/app/components/player_notifier.rs @@ -2,7 +2,7 @@ use futures::channel::mpsc::UnboundedSender; use librespot::core::spotify_id::SpotifyId; use crate::app::components::EventListener; -use crate::app::state::{LoginAction, LoginEvent, LoginStartedEvent, PlaybackEvent}; +use crate::app::state::{LoginAction, LoginEvent, LoginStartedEvent, PlaybackEvent, SettingsEvent}; use crate::app::{AppAction, AppEvent}; use crate::player::Command; @@ -54,6 +54,9 @@ impl EventListener for PlayerNotifier { }), AppEvent::LoginEvent(LoginEvent::FreshTokenRequested) => Some(Command::RefreshToken), AppEvent::LoginEvent(LoginEvent::LogoutCompleted) => Some(Command::Logout), + AppEvent::SettingsEvent(SettingsEvent::PlayerSettingsChanged) => { + Some(Command::ReloadSettings) + } _ => None, }; diff --git a/src/app/components/settings/mod.rs b/src/app/components/settings/mod.rs new file mode 100644 index 00000000..6d479c0b --- /dev/null +++ b/src/app/components/settings/mod.rs @@ -0,0 +1,5 @@ +mod settings; +mod settings_model; + +pub use settings::*; +pub use settings_model::*; diff --git a/src/app/components/settings/settings.rs b/src/app/components/settings/settings.rs new file mode 100644 index 00000000..63eb0644 --- /dev/null +++ b/src/app/components/settings/settings.rs @@ -0,0 +1,250 @@ +use crate::app::components::EventListener; +use crate::app::AppEvent; +use crate::settings::SpotSettings; + +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; +use libadwaita::prelude::*; + +use super::SettingsModel; + +const SETTINGS: &str = "dev.alextren.Spot"; + +mod imp { + + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/dev/alextren/Spot/components/settings.ui")] + pub struct SettingsWindow { + #[template_child] + pub player_bitrate: TemplateChild, + + #[template_child] + pub alsa_device: TemplateChild, + + #[template_child] + pub alsa_device_row: TemplateChild, + + #[template_child] + pub audio_backend: TemplateChild, + + #[template_child] + pub ap_port: TemplateChild, + + #[template_child] + pub theme: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for SettingsWindow { + const NAME: &'static str = "SettingsWindow"; + type Type = super::SettingsWindow; + type ParentType = libadwaita::PreferencesWindow; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for SettingsWindow {} + impl WidgetImpl for SettingsWindow {} + impl WindowImpl for SettingsWindow {} + impl AdwWindowImpl for SettingsWindow {} + impl PreferencesWindowImpl for SettingsWindow {} +} + +glib::wrapper! { + pub struct SettingsWindow(ObjectSubclass) @extends gtk::Widget, gtk::Window, libadwaita::Window, libadwaita::PreferencesWindow; +} + +impl SettingsWindow { + pub fn new() -> Self { + let window: Self = + glib::Object::new(&[]).expect("Failed to create an instance of SettingsWindow"); + + window.bind_backend_and_device(); + window.bind_settings(); + window.connect_theme_select(); + window + } + + fn bind_backend_and_device(&self) { + let widget = imp::SettingsWindow::from_instance(self); + + let audio_backend = widget + .audio_backend + .downcast_ref::() + .unwrap(); + let alsa_device_row = widget + .alsa_device_row + .downcast_ref::() + .unwrap(); + + audio_backend + .bind_property("selected", alsa_device_row, "visible") + .transform_to(|_, value| value.get::().ok().map(|u| (u == 1).to_value())) + .build(); + + if audio_backend.selected() == 0 { + alsa_device_row.set_visible(false); + } + } + + fn bind_settings(&self) { + let widget = imp::SettingsWindow::from_instance(self); + let settings = gio::Settings::new(SETTINGS); + + let player_bitrate = widget + .player_bitrate + .downcast_ref::() + .unwrap(); + settings + .bind("player-bitrate", player_bitrate, "selected") + .mapping(|variant, _| { + variant.str().map(|s| { + match s { + "96" => 0, + "160" => 1, + "320" => 2, + _ => unreachable!(), + } + .to_value() + }) + }) + .set_mapping(|value, _| { + value.get::().ok().map(|u| { + match u { + 0 => "96", + 1 => "160", + 2 => "320", + _ => unreachable!(), + } + .to_variant() + }) + }) + .build(); + + let alsa_device = widget.alsa_device.downcast_ref::().unwrap(); + settings.bind("alsa-device", alsa_device, "text").build(); + + let audio_backend = widget + .audio_backend + .downcast_ref::() + .unwrap(); + settings + .bind("audio-backend", audio_backend, "selected") + .mapping(|variant, _| { + variant.str().map(|s| { + match s { + "pulseaudio" => 0, + "alsa" => 1, + _ => unreachable!(), + } + .to_value() + }) + }) + .set_mapping(|value, _| { + value.get::().ok().map(|u| { + match u { + 0 => "pulseaudio", + 1 => "alsa", + _ => unreachable!(), + } + .to_variant() + }) + }) + .build(); + + let ap_port = widget.ap_port.downcast_ref::().unwrap(); + settings + .bind("ap-port", ap_port, "text") + .mapping(|variant, _| variant.get::().map(|s| s.to_value())) + .set_mapping(|value, _| value.get::().ok().map(|u| u.to_variant())) + .build(); + + let theme = widget.theme.downcast_ref::().unwrap(); + settings + .bind("prefers-dark-theme", theme, "selected") + .mapping(|variant, _| { + variant + .get::() + .map(|prefer_dark| if prefer_dark { 1 } else { 0 }.to_value()) + }) + .set_mapping(|value, _| value.get::().ok().map(|u| (u == 1).to_variant())) + .build(); + } + + fn connect_theme_select(&self) { + let widget = imp::SettingsWindow::from_instance(self); + let theme = widget.theme.downcast_ref::().unwrap(); + theme.connect_selected_notify(|theme| { + let prefers_dark_theme = theme.selected() == 1; + let manager = libadwaita::StyleManager::default(); + + manager.set_color_scheme(if prefers_dark_theme { + libadwaita::ColorScheme::PreferDark + } else { + libadwaita::ColorScheme::PreferLight + }); + }); + } + + fn connect_close(&self, on_close: F) + where + F: Fn() + 'static, + { + let window = self.upcast_ref::(); + + window.connect_close_request( + clone!(@weak self as _self => @default-return gtk::Inhibit(false), move |_| { + on_close(); + gtk::Inhibit(false) + }), + ); + } +} + +pub struct Settings { + parent: gtk::Window, + settings_window: SettingsWindow, +} + +impl Settings { + pub fn new(parent: gtk::Window, model: SettingsModel) -> Self { + let settings_window = SettingsWindow::new(); + + settings_window.connect_close(move || { + let new_settings = SpotSettings::new_from_gsettings().unwrap_or_default(); + if model.settings().player_settings != new_settings.player_settings { + model.stop_player(); + } + model.set_settings(); + }); + + Self { + parent, + settings_window, + } + } + + fn window(&self) -> &libadwaita::Window { + self.settings_window.upcast_ref::() + } + + pub fn show_self(&self) { + self.window().set_transient_for(Some(&self.parent)); + self.window().set_modal(true); + self.window().show(); + } +} + +impl EventListener for Settings { + fn on_event(&mut self, _: &AppEvent) {} +} diff --git a/src/app/components/settings/settings.ui b/src/app/components/settings/settings.ui new file mode 100644 index 00000000..10487e72 --- /dev/null +++ b/src/app/components/settings/settings.ui @@ -0,0 +1,91 @@ + + + + + + diff --git a/src/app/components/settings/settings_model.rs b/src/app/components/settings/settings_model.rs new file mode 100644 index 00000000..4706497c --- /dev/null +++ b/src/app/components/settings/settings_model.rs @@ -0,0 +1,32 @@ +use crate::app::state::{PlaybackAction, SettingsAction}; +use crate::app::{ActionDispatcher, AppModel}; +use crate::settings::SpotSettings; +use std::rc::Rc; + +pub struct SettingsModel { + app_model: Rc, + dispatcher: Box, +} + +impl SettingsModel { + pub fn new(app_model: Rc, dispatcher: Box) -> Self { + Self { + app_model, + dispatcher, + } + } + + pub fn stop_player(&self) { + self.dispatcher.dispatch(PlaybackAction::Stop.into()); + } + + pub fn set_settings(&self) { + self.dispatcher + .dispatch(SettingsAction::ChangeSettings.into()); + } + + pub fn settings(&self) -> SpotSettings { + let state = self.app_model.get_state(); + state.settings.settings.clone() + } +} diff --git a/src/app/components/user_menu/user_menu.rs b/src/app/components/user_menu/user_menu.rs index 4e02a6c3..85d2b89b 100644 --- a/src/app/components/user_menu/user_menu.rs +++ b/src/app/components/user_menu/user_menu.rs @@ -4,7 +4,7 @@ use gtk::prelude::*; use std::rc::Rc; use super::UserMenuModel; -use crate::app::components::EventListener; +use crate::app::components::{EventListener, Settings}; use crate::app::{state::LoginEvent, AppEvent}; pub struct UserMenu { @@ -15,6 +15,7 @@ pub struct UserMenu { impl UserMenu { pub fn new( user_button: gtk::MenuButton, + settings: Settings, about: gtk::AboutDialog, model: UserMenuModel, ) -> Self { @@ -37,6 +38,14 @@ impl UserMenu { logout }); + action_group.add_action(&{ + let settings_action = SimpleAction::new("settings", None); + settings_action.connect_activate(clone!(@weak model => move |_, _| { + settings.show_self(); + })); + settings_action + }); + action_group.add_action(&{ let about_action = SimpleAction::new("about", None); about_action.connect_activate(clone!(@weak about => move |_, _| { @@ -53,6 +62,8 @@ impl UserMenu { fn update_menu(&self) { let menu = gio::Menu::new(); // translators: This is a menu entry. + menu.append(Some(&gettext("Preferences")), Some("menu.settings")); + // translators: This is a menu entry. menu.append(Some(&gettext("About")), Some("menu.about")); // translators: This is a menu entry. menu.append(Some(&gettext("Quit")), Some("app.quit")); diff --git a/src/app/mod.rs b/src/app/mod.rs index 656effb8..7aed738f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -189,10 +189,14 @@ impl App { app_model: Rc, dispatcher: Box, ) -> Box { + let parent: gtk::Window = builder.object("window").unwrap(); + let settings_model = SettingsModel::new(app_model.clone(), dispatcher.box_clone()); + let settings = Settings::new(parent, settings_model); + let button: gtk::MenuButton = builder.object("user").unwrap(); let about: gtk::AboutDialog = builder.object("about").unwrap(); let model = UserMenuModel::new(app_model, dispatcher); - let user_menu = UserMenu::new(button, about, model); + let user_menu = UserMenu::new(button, settings, about, model); Box::new(user_menu) } diff --git a/src/app/state/app_state.rs b/src/app/state/app_state.rs index 65f41918..cfa72e26 100644 --- a/src/app/state/app_state.rs +++ b/src/app/state/app_state.rs @@ -5,6 +5,7 @@ use crate::app::state::{ login_state::{LoginAction, LoginEvent, LoginState}, playback_state::{PlaybackAction, PlaybackEvent, PlaybackState}, selection_state::{SelectionAction, SelectionContext, SelectionEvent, SelectionState}, + settings_state::{SettingsAction, SettingsEvent, SettingsState}, ScreenName, UpdatableState, }; @@ -14,6 +15,7 @@ pub enum AppAction { BrowserAction(BrowserAction), SelectionAction(SelectionAction), LoginAction(LoginAction), + SettingsAction(SettingsAction), Start, Raise, ShowNotification(String), @@ -90,6 +92,7 @@ pub enum AppEvent { Raised, NotificationShown(String), NowPlayingShown, + SettingsEvent(SettingsEvent), } pub struct AppState { @@ -98,6 +101,7 @@ pub struct AppState { pub browser: BrowserState, pub selection: SelectionState, pub logged_user: LoginState, + pub settings: SettingsState, } impl AppState { @@ -108,6 +112,7 @@ impl AppState { browser: BrowserState::new(), selection: Default::default(), logged_user: Default::default(), + settings: Default::default(), } } @@ -200,6 +205,7 @@ impl AppState { AppAction::BrowserAction(a) => forward_action(a, &mut self.browser), AppAction::SelectionAction(a) => forward_action(a, &mut self.selection), AppAction::LoginAction(a) => forward_action(a, &mut self.logged_user), + AppAction::SettingsAction(a) => forward_action(a, &mut self.settings), _ => vec![], } } diff --git a/src/app/state/mod.rs b/src/app/state/mod.rs index 3b9c85e2..2ae01721 100644 --- a/src/app/state/mod.rs +++ b/src/app/state/mod.rs @@ -6,6 +6,7 @@ mod pagination; mod playback_state; mod screen_states; mod selection_state; +mod settings_state; use std::borrow::Cow; @@ -17,6 +18,7 @@ pub use pagination::*; pub use playback_state::*; pub use screen_states::*; pub use selection_state::*; +pub use settings_state::*; pub trait UpdatableState { type Action: Clone; diff --git a/src/app/state/settings_state.rs b/src/app/state/settings_state.rs new file mode 100644 index 00000000..648c3292 --- /dev/null +++ b/src/app/state/settings_state.rs @@ -0,0 +1,53 @@ +use crate::{ + app::state::{AppAction, AppEvent, UpdatableState}, + settings::SpotSettings, +}; + +#[derive(Clone, Debug)] +pub enum SettingsAction { + ChangeSettings, +} + +impl From for AppAction { + fn from(settings_action: SettingsAction) -> Self { + Self::SettingsAction(settings_action) + } +} + +#[derive(Clone, Debug)] +pub enum SettingsEvent { + PlayerSettingsChanged, +} + +impl From for AppEvent { + fn from(settings_event: SettingsEvent) -> Self { + Self::SettingsEvent(settings_event) + } +} + +#[derive(Default)] +pub struct SettingsState { + pub settings: SpotSettings, +} + +impl UpdatableState for SettingsState { + type Action = SettingsAction; + type Event = AppEvent; + + fn update_with(&mut self, action: std::borrow::Cow) -> Vec { + match action.into_owned() { + SettingsAction::ChangeSettings => { + let old_settings = &self.settings; + let new_settings = SpotSettings::new_from_gsettings().unwrap_or_default(); + let player_settings_changed = + new_settings.player_settings != old_settings.player_settings; + self.settings = new_settings; + if player_settings_changed { + vec![SettingsEvent::PlayerSettingsChanged.into()] + } else { + vec![] + } + } + } + } +} diff --git a/src/meson.build b/src/meson.build index f0235a60..755d8708 100644 --- a/src/meson.build +++ b/src/meson.build @@ -77,6 +77,7 @@ sources = files( './app/state/pagination.rs', './app/state/app_state.rs', './app/state/login_state.rs', +'./app/state/settings_state.rs', './app/state/browser_state.rs', './app/state/playback_state.rs', './app/state/mod.rs', @@ -131,6 +132,9 @@ sources = files( './app/components/login/login.rs', './app/components/login/mod.rs', './app/components/login/login_model.rs', +'./app/components/settings/mod.rs', +'./app/components/settings/settings_model.rs', +'./app/components/settings/settings.rs', './app/components/playlist_details/playlist_details_model.rs', './app/components/playlist_details/mod.rs', './app/components/playlist_details/playlist_details.rs', diff --git a/src/player/mod.rs b/src/player/mod.rs index f91cb6c4..8d64551f 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -24,6 +24,7 @@ pub enum Command { PlayerSeek(u32), PlayerSetVolume(f64), RefreshToken, + ReloadSettings, } struct AppPlayerDelegate { diff --git a/src/player/player.rs b/src/player/player.rs index 1093b7f6..81894130 100644 --- a/src/player/player.rs +++ b/src/player/player.rs @@ -22,6 +22,7 @@ use std::time::{Duration, SystemTime}; use super::Command; use crate::app::credentials; +use crate::settings::SpotSettings; #[derive(Debug)] pub enum SpotifyError { @@ -51,13 +52,13 @@ pub trait SpotifyPlayerDelegate { fn notify_playback_state(&self, position: u32); } -#[derive(Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum AudioBackend { PulseAudio, Alsa(String), } -#[derive(Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SpotifyPlayerSettings { pub bitrate: Bitrate, pub backend: AudioBackend, @@ -75,7 +76,7 @@ impl Default for SpotifyPlayerSettings { } pub struct SpotifyPlayer { - settings: SpotifyPlayerSettings, + settings: RefCell, player: RefCell>, mixer: RefCell>>, session: RefCell>, @@ -85,7 +86,7 @@ pub struct SpotifyPlayer { impl SpotifyPlayer { pub fn new(settings: SpotifyPlayerSettings, delegate: Rc) -> Self { Self { - settings, + settings: RefCell::new(settings), mixer: RefCell::new(None), player: RefCell::new(None), session: RefCell::new(None), @@ -144,7 +145,8 @@ impl SpotifyPlayer { } Command::PasswordLogin { username, password } => { let credentials = Credentials::with_password(username, password.clone()); - let new_session = create_session(credentials, self.settings.ap_port).await?; + let new_session = + create_session(credentials, self.settings.borrow().ap_port).await?; let (token, token_expiry_time) = get_access_token_and_expiry_time(&new_session).await?; let credentials = credentials::Credentials { @@ -169,7 +171,8 @@ impl SpotifyPlayer { auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN, auth_data: token.clone().into_bytes(), }; - let new_session = create_session(credentials, self.settings.ap_port).await?; + let new_session = + create_session(credentials, self.settings.borrow().ap_port).await?; self.delegate .token_login_successful(new_session.username(), token); @@ -178,16 +181,28 @@ impl SpotifyPlayer { player.replace(new_player); session.replace(new_session); + Ok(()) + } + Command::ReloadSettings => { + let settings = SpotSettings::new_from_gsettings().unwrap_or_default(); + self.settings.replace(settings.player_settings); + + let session = session.as_ref().ok_or(SpotifyError::PlayerNotReady)?; + let (new_player, channel) = self.create_player(session.clone()); + tokio::task::spawn_local(player_setup_delegate(channel, Rc::clone(&self.delegate))); + player.replace(new_player); + Ok(()) } } } fn create_player(&self, session: Session) -> (Player, PlayerEventChannel) { - let backend = self.settings.backend.clone(); + let settings = self.settings.borrow(); + let backend = settings.backend.clone(); let player_config = PlayerConfig { - bitrate: self.settings.bitrate, + bitrate: settings.bitrate, ..Default::default() }; info!("bitrate: {:?}", &player_config.bitrate); diff --git a/src/settings.rs b/src/settings.rs index ea3005c0..a29621e2 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -4,7 +4,7 @@ use librespot::playback::config::Bitrate; const SETTINGS: &str = "dev.alextren.Spot"; -#[derive(Clone, Default)] +#[derive(Clone, Debug, Default)] pub struct WindowGeometry { pub width: i32, pub height: i32, @@ -71,6 +71,7 @@ impl SpotifyPlayerSettings { } } +#[derive(Debug, Clone)] pub struct SpotSettings { pub prefers_dark_theme: bool, pub player_settings: SpotifyPlayerSettings, diff --git a/src/spot.gresource.xml b/src/spot.gresource.xml index a99d3655..2e9be3c1 100644 --- a/src/spot.gresource.xml +++ b/src/spot.gresource.xml @@ -5,6 +5,8 @@ app.css app/components/login/login.ui + + app/components/settings/settings.ui app/components/search/search.ui @@ -55,4 +57,4 @@ app/components/selection/icons/playlist2-symbolic.svg app/components/sidebar_listbox/icons/library-music-symbolic.svg - \ No newline at end of file +