first commit
Some checks are pending
spot-quality / ci-check (push) Waiting to run
spot-quality / shellcheck (push) Waiting to run

This commit is contained in:
Théo Barnouin 2024-11-13 16:41:51 +01:00
commit 15cf412840
255 changed files with 47845 additions and 0 deletions

105
src/app/batch_loader.rs Normal file
View file

@ -0,0 +1,105 @@
use gettextrs::gettext;
use std::sync::Arc;
use crate::api::{SpotifyApiClient, SpotifyApiError};
use crate::app::models::*;
use crate::app::AppAction;
// A wrapper around the Spotify API to load batches of songs from various sources (see below)
#[derive(Clone)]
pub struct BatchLoader {
api: Arc<dyn SpotifyApiClient + Send + Sync>,
}
// The sources mentionned above
#[derive(Clone, Debug)]
pub enum SongsSource {
Playlist(String),
Album(String),
SavedTracks,
}
impl PartialEq for SongsSource {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Playlist(l), Self::Playlist(r)) => l == r,
(Self::Album(l), Self::Album(r)) => l == r,
(Self::SavedTracks, Self::SavedTracks) => true,
_ => false,
}
}
}
impl Eq for SongsSource {}
impl SongsSource {
pub fn has_spotify_uri(&self) -> bool {
matches!(self, Self::Playlist(_) | Self::Album(_))
}
pub fn spotify_uri(&self) -> Option<String> {
match self {
Self::Playlist(id) => Some(format!("spotify:playlist:{}", id)),
Self::Album(id) => Some(format!("spotify:album:{}", id)),
_ => None,
}
}
}
// How to query for a batch: specify a source, and a batch to get (offset + number of elements to get)
#[derive(Debug)]
pub struct BatchQuery {
pub source: SongsSource,
pub batch: Batch,
}
impl BatchQuery {
// Given a query, compute the next batch to get (if any)
pub fn next(&self) -> Option<Self> {
let Self { source, batch } = self;
Some(Self {
source: source.clone(),
batch: batch.next()?,
})
}
}
impl BatchLoader {
pub fn new(api: Arc<dyn SpotifyApiClient + Send + Sync>) -> Self {
Self { api }
}
// Query a batch and create an action when it's been retrieved succesfully
pub async fn query<ActionCreator>(
&self,
query: BatchQuery,
create_action: ActionCreator,
) -> Option<AppAction>
where
ActionCreator: FnOnce(SongsSource, SongBatch) -> AppAction,
{
let api = Arc::clone(&self.api);
let Batch {
offset, batch_size, ..
} = query.batch;
let result = match &query.source {
SongsSource::Playlist(id) => api.get_playlist_tracks(id, offset, batch_size).await,
SongsSource::SavedTracks => api.get_saved_tracks(offset, batch_size).await,
SongsSource::Album(id) => api.get_album_tracks(id, offset, batch_size).await,
};
match result {
Ok(batch) => Some(create_action(query.source, batch)),
// No token? Why was the batch loader called? Ah, whatever
Err(SpotifyApiError::NoToken) => None,
Err(err) => {
error!("Spotify API error: {}", err);
Some(AppAction::ShowNotification(gettext(
// translators: This notification is the default message for unhandled errors. Logs refer to console output.
"An error occured. Check logs for details!",
)))
}
}
}
}

View file

@ -0,0 +1,75 @@
using Gtk 4.0;
using Adw 1;
template $AlbumWidget : Adw.Bin {
Button cover_btn {
hexpand: false;
halign: center;
Box {
halign: center;
valign: start;
margin-top: 6;
margin-bottom: 6;
orientation: vertical;
spacing: 6;
Image cover_image {
icon-name: "media-playback-start-symbolic";
styles [
"card",
]
}
Label album_label {
label: "Album";
justify: center;
wrap: true;
wrap-mode: word;
ellipsize: end;
max-width-chars: 1;
margin-top: 6;
styles [
"title-4",
]
}
Label artist_label {
label: "Artist";
justify: center;
wrap: true;
wrap-mode: word;
ellipsize: end;
max-width-chars: 1;
styles [
"body",
]
}
Label year_label {
label: "Year";
justify: center;
wrap: true;
wrap-mode: word_char;
max-width-chars: 1;
sensitive: false;
styles [
"body",
]
}
}
styles [
"flat",
]
}
styles [
"container",
"album",
]
}

View file

@ -0,0 +1,34 @@
/* large style */
leaflet.unfolded .album .card {
min-width: 200px;
min-height: 200px;
border-radius: 6px;
}
leaflet.unfolded .album {
margin-top: 6px;
margin-bottom: 6px;
}
leaflet.unfolded .album button {
border-radius: 12px;
}
/* small style */
leaflet.folded .album .card {
min-width: 100px;
min-height: 100px;
border-radius: 6px;
margin-top: 0px;
margin-bottom: 0px;
}
leaflet.unfolded .album {
margin-top: 0px;
margin-bottom: 0px;
}
leaflet.folded .album button {
border-radius: 6px;
}

View file

@ -0,0 +1,129 @@
use crate::app::components::display_add_css_provider;
use crate::app::dispatch::Worker;
use crate::app::loader::ImageLoader;
use crate::app::models::AlbumModel;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use libadwaita::subclass::prelude::BinImpl;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/album.ui")]
pub struct AlbumWidget {
#[template_child]
pub album_label: TemplateChild<gtk::Label>,
#[template_child]
pub artist_label: TemplateChild<gtk::Label>,
#[template_child]
pub year_label: TemplateChild<gtk::Label>,
#[template_child]
pub cover_btn: TemplateChild<gtk::Button>,
#[template_child]
pub cover_image: TemplateChild<gtk::Image>,
}
#[glib::object_subclass]
impl ObjectSubclass for AlbumWidget {
const NAME: &'static str = "AlbumWidget";
type Type = super::AlbumWidget;
type ParentType = libadwaita::Bin;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for AlbumWidget {}
impl WidgetImpl for AlbumWidget {}
impl BinImpl for AlbumWidget {}
}
glib::wrapper! {
pub struct AlbumWidget(ObjectSubclass<imp::AlbumWidget>) @extends gtk::Widget, libadwaita::Bin;
}
impl Default for AlbumWidget {
fn default() -> Self {
Self::new()
}
}
impl AlbumWidget {
pub fn new() -> Self {
display_add_css_provider(resource!("/components/album.css"));
glib::Object::new()
}
pub fn for_model(album_model: &AlbumModel, worker: Worker) -> Self {
let _self = Self::new();
_self.bind(album_model, worker);
_self
}
fn set_loaded(&self) {
self.add_css_class("container--loaded");
}
fn set_image(&self, pixbuf: Option<&gdk_pixbuf::Pixbuf>) {
self.imp().cover_image.set_from_pixbuf(pixbuf);
}
fn bind(&self, album_model: &AlbumModel, worker: Worker) {
let widget = self.imp();
widget.cover_image.set_overflow(gtk::Overflow::Hidden);
if let Some(cover_art) = album_model.cover() {
let _self = self.downgrade();
worker.send_local_task(async move {
if let Some(_self) = _self.upgrade() {
let loader = ImageLoader::new();
let result = loader.load_remote(&cover_art, "jpg", 200, 200).await;
_self.set_image(result.as_ref());
_self.set_loaded();
}
});
} else {
self.set_loaded();
}
album_model
.bind_property("album", &*widget.album_label, "label")
.flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE)
.build();
album_model
.bind_property("artist", &*widget.artist_label, "label")
.flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE)
.build();
if album_model.year() > 0 {
album_model
.bind_property("year", &*widget.year_label, "label")
.flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE)
.build();
} else {
widget.year_label.set_visible(false);
}
}
pub fn connect_album_pressed<F: Fn(&Self) + 'static>(&self, f: F) {
self.imp()
.cover_btn
.connect_clicked(clone!(@weak self as _self => move |_| {
f(&_self);
}));
}
}

View file

@ -0,0 +1,3 @@
#[allow(clippy::module_inception)]
mod album;
pub use album::AlbumWidget;

View file

@ -0,0 +1,31 @@
using Gtk 4.0;
using Adw 1;
template $ArtistWidget : Box {
orientation: vertical;
Button avatar_btn {
vexpand: true;
width-request: 150;
height-request: 150;
receives-default: true;
halign: center;
valign: center;
has-frame: false;
Adw.Avatar avatar {
halign: center;
valign: center;
show-initials: true;
size: 150;
}
styles [
"circular",
]
}
Label artist {
label: "Artist Name";
}
}

View file

@ -0,0 +1,95 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use crate::app::loader::ImageLoader;
use crate::app::models::ArtistModel;
use crate::app::Worker;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/artist.ui")]
pub struct ArtistWidget {
#[template_child]
pub artist: TemplateChild<gtk::Label>,
#[template_child]
pub avatar_btn: TemplateChild<gtk::Button>,
#[template_child]
pub avatar: TemplateChild<libadwaita::Avatar>,
}
#[glib::object_subclass]
impl ObjectSubclass for ArtistWidget {
const NAME: &'static str = "ArtistWidget";
type Type = super::ArtistWidget;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for ArtistWidget {}
impl WidgetImpl for ArtistWidget {}
impl BoxImpl for ArtistWidget {}
}
glib::wrapper! {
pub struct ArtistWidget(ObjectSubclass<imp::ArtistWidget>) @extends gtk::Widget, gtk::Box;
}
impl Default for ArtistWidget {
fn default() -> Self {
Self::new()
}
}
impl ArtistWidget {
pub fn new() -> Self {
glib::Object::new()
}
pub fn for_model(model: &ArtistModel, worker: Worker) -> Self {
let _self = Self::new();
_self.bind(model, worker);
_self
}
pub fn connect_artist_pressed<F: Fn(&Self) + 'static>(&self, f: F) {
self.imp()
.avatar_btn
.connect_clicked(clone!(@weak self as _self => move |_| {
f(&_self);
}));
}
fn bind(&self, model: &ArtistModel, worker: Worker) {
let widget = self.imp();
if let Some(url) = model.image() {
let avatar = widget.avatar.downgrade();
worker.send_local_task(async move {
if let Some(avatar) = avatar.upgrade() {
let loader = ImageLoader::new();
let pixbuf = loader.load_remote(&url, "jpg", 200, 200).await;
let texture = pixbuf.as_ref().map(gdk::Texture::for_pixbuf);
avatar.set_custom_image(texture.as_ref());
}
});
}
model
.bind_property("artist", &*widget.artist, "label")
.flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE)
.build();
}
}

View file

@ -0,0 +1,63 @@
using Gtk 4.0;
template $ArtistDetailsWidget : Box {
ScrolledWindow scrolled_window {
hscrollbar-policy: never;
hexpand: true;
vexpand: true;
Box {
margin-start: 8;
margin-end: 8;
margin-top: 8;
margin-bottom: 8;
orientation: vertical;
spacing: 16;
Box {
orientation: vertical;
Label {
halign: start;
margin-start: 8;
margin-end: 8;
/* Translators: Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. */
label: _("Top tracks");
styles [
"title-4",
]
}
ListView top_tracks {
}
}
Expander {
margin-top: 8;
margin-bottom: 8;
expanded: true;
FlowBox artist_releases {
height-request: 100;
hexpand: true;
min-children-per-line: 1;
selection-mode: none;
activate-on-single-click: false;
}
[label]
Label {
/* Translators: Title of the sections that contains all releases from an artist (both singles and albums). */
label: _("Releases");
}
}
}
}
styles [
"artist",
]
}

View file

@ -0,0 +1,18 @@
listview.artist__top-tracks {
padding: 8px;
border-radius: 8px;
margin: 8px;
}
listview.artist__top-tracks row {
border-radius: 4px;
}
.artist {
transition: opacity .3s ease;
opacity: 0;
}
.artist__loaded {
opacity: 1;
}

View file

@ -0,0 +1,168 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use std::rc::Rc;
use crate::app::components::{
display_add_css_provider, AlbumWidget, Component, EventListener, Playlist,
};
use crate::app::{models::*, ListStore};
use crate::app::{AppEvent, BrowserEvent, Worker};
use super::ArtistDetailsModel;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/artist_details.ui")]
pub struct ArtistDetailsWidget {
#[template_child]
pub scrolled_window: TemplateChild<gtk::ScrolledWindow>,
#[template_child]
pub top_tracks: TemplateChild<gtk::ListView>,
#[template_child]
pub artist_releases: TemplateChild<gtk::FlowBox>,
}
#[glib::object_subclass]
impl ObjectSubclass for ArtistDetailsWidget {
const NAME: &'static str = "ArtistDetailsWidget";
type Type = super::ArtistDetailsWidget;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for ArtistDetailsWidget {}
impl WidgetImpl for ArtistDetailsWidget {}
impl BoxImpl for ArtistDetailsWidget {}
}
glib::wrapper! {
pub struct ArtistDetailsWidget(ObjectSubclass<imp::ArtistDetailsWidget>) @extends gtk::Widget, gtk::Box;
}
impl ArtistDetailsWidget {
fn new() -> Self {
display_add_css_provider(resource!("/components/artist_details.css"));
glib::Object::new()
}
fn top_tracks_widget(&self) -> &gtk::ListView {
self.imp().top_tracks.as_ref()
}
fn set_loaded(&self) {
self.add_css_class("artist__loaded");
}
fn connect_bottom_edge<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp()
.scrolled_window
.connect_edge_reached(move |_, pos| {
if let gtk::PositionType::Bottom = pos {
f()
}
});
}
fn bind_artist_releases<F>(
&self,
worker: Worker,
store: &ListStore<AlbumModel>,
on_album_pressed: F,
) where
F: Fn(String) + Clone + 'static,
{
self.imp()
.artist_releases
.bind_model(Some(store.unsafe_store()), move |item| {
let item = item.downcast_ref::<AlbumModel>().unwrap();
let child = gtk::FlowBoxChild::new();
let album = AlbumWidget::for_model(item, worker.clone());
let f = on_album_pressed.clone();
album.connect_album_pressed(clone!(@weak item => move |_| {
f(item.uri());
}));
child.set_child(Some(&album));
child.upcast::<gtk::Widget>()
});
}
}
pub struct ArtistDetails {
model: Rc<ArtistDetailsModel>,
widget: ArtistDetailsWidget,
children: Vec<Box<dyn EventListener>>,
}
impl ArtistDetails {
pub fn new(model: Rc<ArtistDetailsModel>, worker: Worker) -> Self {
model.load_artist_details(model.id.clone());
let widget = ArtistDetailsWidget::new();
widget.connect_bottom_edge(clone!(@weak model => move || {
model.load_more();
}));
if let Some(store) = model.get_list_store() {
widget.bind_artist_releases(
worker.clone(),
&store,
clone!(@weak model => move |id| {
model.open_album(id);
}),
);
}
let playlist = Box::new(Playlist::new(
widget.top_tracks_widget().clone(),
Rc::clone(&model),
worker,
));
Self {
model,
widget,
children: vec![playlist],
}
}
}
impl Component for ArtistDetails {
fn get_root_widget(&self) -> &gtk::Widget {
self.widget.upcast_ref()
}
fn get_children(&mut self) -> Option<&mut Vec<Box<dyn EventListener>>> {
Some(&mut self.children)
}
}
impl EventListener for ArtistDetails {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::BrowserEvent(BrowserEvent::ArtistDetailsUpdated(id))
if id == &self.model.id =>
{
self.widget.set_loaded();
}
_ => {}
}
self.broadcast_event(event);
}
}

View file

@ -0,0 +1,187 @@
use gio::prelude::*;
use gio::SimpleActionGroup;
use std::ops::Deref;
use std::rc::Rc;
use crate::api::SpotifyApiError;
use crate::app::components::SimpleHeaderBarModel;
use crate::app::components::{labels, PlaylistModel};
use crate::app::models::*;
use crate::app::state::SelectionContext;
use crate::app::state::{
BrowserAction, BrowserEvent, PlaybackAction, SelectionAction, SelectionState,
};
use crate::app::{ActionDispatcher, AppAction, AppEvent, AppModel, ListStore};
pub struct ArtistDetailsModel {
pub id: String,
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl ArtistDetailsModel {
pub fn new(id: String, app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
id,
app_model,
dispatcher,
}
}
pub fn get_artist_name(&self) -> Option<impl Deref<Target = String> + '_> {
self.app_model
.map_state_opt(|s| s.browser.artist_state(&self.id)?.artist.as_ref())
}
pub fn get_list_store(&self) -> Option<impl Deref<Target = ListStore<AlbumModel>> + '_> {
self.app_model
.map_state_opt(|s| Some(&s.browser.artist_state(&self.id)?.albums))
}
pub fn load_artist_details(&self, id: String) {
let api = self.app_model.get_spotify();
self.dispatcher
.call_spotify_and_dispatch(move || async move {
let artist = api.get_artist(&id).await;
match artist {
Ok(artist) => Ok(BrowserAction::SetArtistDetails(Box::new(artist)).into()),
Err(SpotifyApiError::BadStatus(400, _))
| Err(SpotifyApiError::BadStatus(404, _)) => {
Ok(BrowserAction::NavigationPop.into())
}
Err(e) => Err(e),
}
});
}
pub fn open_album(&self, id: String) {
self.dispatcher.dispatch(AppAction::ViewAlbum(id));
}
pub fn load_more(&self) -> Option<()> {
let api = self.app_model.get_spotify();
let state = self.app_model.get_state();
let next_page = &state.browser.artist_state(&self.id)?.next_page;
let id = next_page.data.clone();
let batch_size = next_page.batch_size;
let offset = next_page.next_offset?;
self.dispatcher
.call_spotify_and_dispatch(move || async move {
api.get_artist_albums(&id, offset, batch_size)
.await
.map(|albums| BrowserAction::AppendArtistReleases(id, albums).into())
});
Some(())
}
}
impl PlaylistModel for ArtistDetailsModel {
fn song_list_model(&self) -> SongListModel {
self.app_model
.get_state()
.browser
.artist_state(&self.id)
.expect("illegal attempt to read artist_state")
.top_tracks
.clone()
}
fn is_paused(&self) -> bool {
!self.app_model.get_state().playback.is_playing()
}
fn current_song_id(&self) -> Option<String> {
self.app_model.get_state().playback.current_song_id()
}
fn play_song_at(&self, _pos: usize, id: &str) {
let tracks: Vec<SongDescription> = self.song_list_model().collect();
self.dispatcher
.dispatch(PlaybackAction::LoadSongs(tracks).into());
self.dispatcher
.dispatch(PlaybackAction::Load(id.to_string()).into());
}
fn actions_for(&self, id: &str) -> Option<gio::ActionGroup> {
let song = self.song_list_model().get(id)?;
let song = song.description();
let group = SimpleActionGroup::new();
for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) {
group.add_action(&view_artist);
}
group.add_action(&song.make_album_action(self.dispatcher.box_clone(), None));
group.add_action(&song.make_link_action(None));
group.add_action(&song.make_queue_action(self.dispatcher.box_clone(), None));
Some(group.upcast())
}
fn menu_for(&self, id: &str) -> Option<gio::MenuModel> {
let song = self.song_list_model().get(id)?;
let song = song.description();
let menu = gio::Menu::new();
menu.append(Some(&*labels::VIEW_ALBUM), Some("song.view_album"));
for artist in song.artists.iter().filter(|a| self.id != a.id) {
menu.append(
Some(&labels::more_from_label(&artist.name)),
Some(&format!("song.view_artist_{}", artist.id)),
);
}
menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link"));
menu.append(Some(&*labels::ADD_TO_QUEUE), Some("song.queue"));
Some(menu.upcast())
}
fn select_song(&self, id: &str) {
let song = self.song_list_model().get(id);
if let Some(song) = song {
self.dispatcher
.dispatch(SelectionAction::Select(vec![song.into_description()]).into());
}
}
fn deselect_song(&self, id: &str) {
self.dispatcher
.dispatch(SelectionAction::Deselect(vec![id.to_string()]).into());
}
fn enable_selection(&self) -> bool {
self.dispatcher
.dispatch(AppAction::EnableSelection(SelectionContext::Default));
true
}
fn selection(&self) -> Option<Box<dyn Deref<Target = SelectionState> + '_>> {
Some(Box::new(self.app_model.map_state(|s| &s.selection)))
}
}
impl SimpleHeaderBarModel for ArtistDetailsModel {
fn title(&self) -> Option<String> {
Some(self.get_artist_name()?.clone())
}
fn title_updated(&self, event: &AppEvent) -> bool {
matches!(
event,
AppEvent::BrowserEvent(BrowserEvent::ArtistDetailsUpdated(_))
)
}
fn selection_context(&self) -> Option<SelectionContext> {
Some(SelectionContext::Default)
}
fn select_all(&self) {
let songs: Vec<SongDescription> = self.song_list_model().collect();
self.dispatcher
.dispatch(SelectionAction::Select(songs).into());
}
}

View file

@ -0,0 +1,6 @@
#[allow(clippy::module_inception)]
mod artist_details;
pub use artist_details::*;
mod artist_details_model;
pub use artist_details_model::*;

View file

@ -0,0 +1,136 @@
using Gtk 4.0;
template $AlbumHeaderWidget : Box {
valign: start;
vexpand: false;
margin-start: 6;
margin-end: 6;
margin-bottom: 6;
Overlay album_overlay {
overflow: hidden;
halign: center;
margin-top: 18;
margin-bottom: 6;
margin-start: 6;
Image album_art {
width-request: 160;
height-request: 160;
icon-name: "emblem-music-symbolic";
}
[overlay]
Button info_button {
icon-name: "preferences-system-details-symbolic";
halign: end;
valign: end;
margin-start: 6;
margin-end: 6;
margin-top: 6;
margin-bottom: 6;
tooltip-text: "Album Info";
styles [
"circular",
"osd",
]
}
styles [
"card",
]
}
Box album_info {
hexpand: true;
valign: center;
orientation: vertical;
spacing: 6;
margin-start: 18;
Label album_label {
xalign: 0;
halign: start;
label: "Album";
wrap: true;
ellipsize: end;
max-width-chars: 50;
lines: 4;
styles [
"title-1",
]
}
LinkButton artist_button {
receives-default: true;
halign: start;
valign: center;
has-frame: false;
Label artist_button_label {
hexpand: true;
vexpand: true;
label: "Artist";
ellipsize: middle;
}
styles [
"title-4",
]
}
Label year_label {
xalign: 0;
halign: start;
label: "Year";
ellipsize: end;
max-width-chars: 50;
lines: 1;
sensitive: false;
styles [
"body",
]
}
}
Box button_box {
orientation: horizontal;
valign: center;
margin-end: 6;
spacing: 8;
Button play_button {
receives-default: true;
halign: center;
valign: center;
tooltip-text: "Play";
icon-name: "media-playback-start-symbolic";
styles [
"circular",
"play__button",
]
}
Button like_button {
receives-default: true;
halign: center;
valign: center;
tooltip-text: "Add to Library";
styles [
"circular",
"like__button",
]
}
}
styles [
"album__header",
]
}

View file

@ -0,0 +1,33 @@
.album__header .title-4 label {
color: @window_fg_color;
font-weight: bold;
text-decoration: none;
}
.album__header .title-4:hover {
border-radius: 6px;
background-image: image(alpha(currentColor, 0.08));
}
clamp.details__clamp {
background-color: @view_bg_color;
box-shadow: inset 0px -1px 0px @borders;
}
headerbar.details__headerbar {
transition: background-color .3s ease;
}
headerbar.flat.details__headerbar windowtitle {
opacity: 0;
}
headerbar.details__headerbar windowtitle {
transition: opacity .3s ease;
opacity: 1;
}
.details__headerbar.flat {
background-color: @view_bg_color;
}

View file

@ -0,0 +1,166 @@
use crate::app::components::display_add_css_provider;
use gettextrs::gettext;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::{glib, CompositeTemplate};
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/album_header.ui")]
pub struct AlbumHeaderWidget {
#[template_child]
pub album_overlay: TemplateChild<gtk::Overlay>,
#[template_child]
pub album_label: TemplateChild<gtk::Label>,
#[template_child]
pub album_art: TemplateChild<gtk::Image>,
#[template_child]
pub button_box: TemplateChild<gtk::Box>,
#[template_child]
pub like_button: TemplateChild<gtk::Button>,
#[template_child]
pub play_button: TemplateChild<gtk::Button>,
#[template_child]
pub info_button: TemplateChild<gtk::Button>,
#[template_child]
pub album_info: TemplateChild<gtk::Box>,
#[template_child]
pub artist_button: TemplateChild<gtk::LinkButton>,
#[template_child]
pub artist_button_label: TemplateChild<gtk::Label>,
#[template_child]
pub year_label: TemplateChild<gtk::Label>,
}
#[glib::object_subclass]
impl ObjectSubclass for AlbumHeaderWidget {
const NAME: &'static str = "AlbumHeaderWidget";
type Type = super::AlbumHeaderWidget;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
display_add_css_provider(resource!("/components/album_header.css"));
obj.init_template();
}
}
impl ObjectImpl for AlbumHeaderWidget {}
impl WidgetImpl for AlbumHeaderWidget {}
impl BoxImpl for AlbumHeaderWidget {}
}
glib::wrapper! {
pub struct AlbumHeaderWidget(ObjectSubclass<imp::AlbumHeaderWidget>) @extends gtk::Widget, gtk::Box;
}
impl Default for AlbumHeaderWidget {
fn default() -> Self {
Self::new()
}
}
impl AlbumHeaderWidget {
pub fn new() -> Self {
glib::Object::new()
}
pub fn connect_play<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().play_button.connect_clicked(move |_| f());
}
pub fn connect_liked<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().like_button.connect_clicked(move |_| f());
}
pub fn connect_info<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().info_button.connect_clicked(move |_| f());
}
pub fn connect_artist_clicked<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().artist_button.connect_activate_link(move |_| {
f();
glib::signal::Inhibit(true)
});
}
pub fn set_liked(&self, is_liked: bool) {
self.imp().like_button.set_icon_name(if is_liked {
"starred-symbolic"
} else {
"non-starred-symbolic"
});
}
pub fn set_playing(&self, is_playing: bool) {
let playback_icon = if is_playing {
"media-playback-pause-symbolic"
} else {
"media-playback-start-symbolic"
};
let translated_tooltip = if is_playing {
gettext("Pause")
} else {
gettext("Play")
};
let tooltip_text = Some(translated_tooltip.as_str());
self.imp().play_button.set_icon_name(playback_icon);
self.imp().play_button.set_tooltip_text(tooltip_text);
}
pub fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) {
self.imp().album_art.set_from_pixbuf(Some(art));
}
pub fn set_album_and_artist_and_year(&self, album: &str, artist: &str, year: Option<u32>) {
let widget = self.imp();
widget.album_label.set_label(album);
widget.artist_button_label.set_label(artist);
match year {
Some(year) => widget.year_label.set_label(&year.to_string()),
None => widget.year_label.set_visible(false),
}
}
pub fn set_centered(&self) {
let widget = self.imp();
widget.album_label.set_halign(gtk::Align::Center);
widget.album_label.set_justify(gtk::Justification::Center);
widget.artist_button.set_halign(gtk::Align::Center);
widget.year_label.set_halign(gtk::Align::Center);
widget.button_box.set_halign(gtk::Align::Center);
widget.album_overlay.set_margin_start(0);
widget.button_box.set_margin_end(0);
widget.album_info.set_margin_start(0);
}
}

View file

@ -0,0 +1,55 @@
using Gtk 4.0;
using Adw 1;
template $AlbumDetailsWidget : Adw.Bin {
Box {
orientation: vertical;
vexpand: true;
hexpand: true;
$HeaderBarWidget headerbar {
}
$ScrollingHeaderWidget scrolling_header {
[header]
WindowHandle {
Adw.Clamp {
maximum-size: 900;
Adw.Squeezer {
switch-threshold-policy: natural;
valign: center;
homogeneous: false;
transition-type: crossfade;
$AlbumHeaderWidget header_widget {
}
$AlbumHeaderWidget header_mobile {
orientation: "vertical";
spacing: "12";
}
}
styles [
"details__clamp",
]
}
}
Adw.ClampScrollable {
maximum-size: 900;
ListView album_tracks {
styles [
"album__tracks",
]
}
}
styles [
"container",
]
}
}
}

View file

@ -0,0 +1,335 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use std::rc::Rc;
use super::album_header::AlbumHeaderWidget;
use super::release_details::ReleaseDetailsWindow;
use super::DetailsModel;
use crate::app::components::{
Component, EventListener, HeaderBarComponent, HeaderBarWidget, Playlist, ScrollingHeaderWidget,
};
use crate::app::dispatch::Worker;
use crate::app::loader::ImageLoader;
use crate::app::state::PlaybackEvent;
use crate::app::{AppEvent, BrowserEvent};
mod imp {
use libadwaita::subclass::prelude::BinImpl;
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/details.ui")]
pub struct AlbumDetailsWidget {
#[template_child]
pub scrolling_header: TemplateChild<ScrollingHeaderWidget>,
#[template_child]
pub headerbar: TemplateChild<HeaderBarWidget>,
#[template_child]
pub header_widget: TemplateChild<AlbumHeaderWidget>,
#[template_child]
pub header_mobile: TemplateChild<AlbumHeaderWidget>,
#[template_child]
pub album_tracks: TemplateChild<gtk::ListView>,
}
#[glib::object_subclass]
impl ObjectSubclass for AlbumDetailsWidget {
const NAME: &'static str = "AlbumDetailsWidget";
type Type = super::AlbumDetailsWidget;
type ParentType = libadwaita::Bin;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for AlbumDetailsWidget {
fn constructed(&self) {
self.parent_constructed();
self.header_mobile.set_centered();
self.headerbar.add_classes(&["details__headerbar"]);
}
}
impl WidgetImpl for AlbumDetailsWidget {}
impl BinImpl for AlbumDetailsWidget {}
}
glib::wrapper! {
pub struct AlbumDetailsWidget(ObjectSubclass<imp::AlbumDetailsWidget>) @extends gtk::Widget, libadwaita::Bin;
}
impl AlbumDetailsWidget {
fn new() -> Self {
glib::Object::new()
}
fn set_header_visible(&self, visible: bool) {
let widget = self.imp();
widget.headerbar.set_title_visible(true);
if visible {
widget.headerbar.add_classes(&["flat"]);
} else {
widget.headerbar.remove_classes(&["flat"]);
}
}
fn connect_header(&self) {
self.set_header_visible(false);
self.imp().scrolling_header.connect_header_visibility(
clone!(@weak self as _self => move |visible| {
_self.set_header_visible(visible);
}),
);
}
fn connect_bottom_edge<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().scrolling_header.connect_bottom_edge(f);
}
fn headerbar_widget(&self) -> &HeaderBarWidget {
self.imp().headerbar.as_ref()
}
fn album_tracks_widget(&self) -> &gtk::ListView {
self.imp().album_tracks.as_ref()
}
fn set_loaded(&self) {
self.imp()
.scrolling_header
.add_css_class("container--loaded");
}
fn connect_liked<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
self.imp().header_widget.connect_liked(f.clone());
self.imp().header_mobile.connect_liked(f);
}
fn connect_play<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
self.imp().header_widget.connect_play(f.clone());
self.imp().header_mobile.connect_play(f);
}
fn connect_info<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
self.imp().header_widget.connect_info(f.clone());
self.imp().header_mobile.connect_info(f);
}
fn set_liked(&self, is_liked: bool) {
self.imp().header_widget.set_liked(is_liked);
self.imp().header_mobile.set_liked(is_liked);
}
fn set_playing(&self, is_playing: bool) {
self.imp().header_widget.set_playing(is_playing);
self.imp().header_mobile.set_playing(is_playing);
}
fn set_album_and_artist_and_year(&self, album: &str, artist: &str, year: Option<u32>) {
self.imp()
.header_widget
.set_album_and_artist_and_year(album, artist, year);
self.imp()
.header_mobile
.set_album_and_artist_and_year(album, artist, year);
self.imp().headerbar.set_title_and_subtitle(album, artist);
}
fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) {
self.imp().header_widget.set_artwork(art);
self.imp().header_mobile.set_artwork(art);
}
fn connect_artist_clicked<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
self.imp().header_widget.connect_artist_clicked(f.clone());
self.imp().header_mobile.connect_artist_clicked(f);
}
}
pub struct Details {
model: Rc<DetailsModel>,
worker: Worker,
widget: AlbumDetailsWidget,
modal: ReleaseDetailsWindow,
children: Vec<Box<dyn EventListener>>,
}
impl Details {
pub fn new(model: Rc<DetailsModel>, worker: Worker, leaflet: &libadwaita::Leaflet) -> Self {
if model.get_album_info().is_none() {
model.load_album_info();
}
let widget = AlbumDetailsWidget::new();
let playlist = Box::new(Playlist::new(
widget.album_tracks_widget().clone(),
model.clone(),
worker.clone(),
));
let headerbar_widget = widget.headerbar_widget();
headerbar_widget.bind_to_leaflet(leaflet);
let headerbar = Box::new(HeaderBarComponent::new(
headerbar_widget.clone(),
model.to_headerbar_model(),
));
let modal = ReleaseDetailsWindow::new();
widget.connect_liked(clone!(@weak model => move || model.toggle_save_album()));
widget.connect_play(clone!(@weak model => move || model.toggle_play_album()));
widget.connect_header();
widget.connect_bottom_edge(clone!(@weak model => move || {
model.load_more();
}));
widget.connect_info(clone!(@weak modal, @weak widget => move || {
let modal = modal.upcast_ref::<libadwaita::Window>();
modal.set_modal(true);
modal.set_transient_for(
widget
.root()
.and_then(|r| r.downcast::<gtk::Window>().ok())
.as_ref(),
);
modal.set_visible(true);
}));
Self {
model,
worker,
widget,
modal,
children: vec![playlist, headerbar],
}
}
fn update_liked(&self) {
if let Some(info) = self.model.get_album_info() {
let is_liked = info.description.is_liked;
self.widget.set_liked(is_liked);
self.widget.set_liked(is_liked);
}
}
fn update_playing(&self, is_playing: bool) {
if !self.model.album_is_playing() || !self.model.is_playing() {
self.widget.set_playing(false);
return;
}
self.widget.set_playing(is_playing);
}
fn update_details(&mut self) {
if let Some(album) = self.model.get_album_info() {
let details = &album.release_details;
let album = &album.description;
self.widget.set_liked(album.is_liked);
self.widget.set_album_and_artist_and_year(
&album.title[..],
&album.artists_name(),
album.year(),
);
self.widget.connect_artist_clicked(
clone!(@weak self.model as model => move || model.view_artist()),
);
self.modal.set_details(
&album.title,
&album.artists_name(),
&details.label,
album.release_date.as_ref().unwrap(),
details.total_tracks,
&details.copyright_text,
);
if let Some(art) = album.art.clone() {
let widget = self.widget.downgrade();
self.worker.send_local_task(async move {
let pixbuf = ImageLoader::new()
.load_remote(&art[..], "jpg", 320, 320)
.await;
if let (Some(widget), Some(ref pixbuf)) = (widget.upgrade(), pixbuf) {
widget.set_artwork(pixbuf);
widget.set_loaded();
}
});
} else {
self.widget.set_loaded();
}
}
}
}
impl Component for Details {
fn get_root_widget(&self) -> &gtk::Widget {
self.widget.upcast_ref()
}
fn get_children(&mut self) -> Option<&mut Vec<Box<dyn EventListener>>> {
Some(&mut self.children)
}
}
impl EventListener for Details {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::BrowserEvent(BrowserEvent::AlbumDetailsLoaded(id))
if id == &self.model.id =>
{
self.update_details();
self.update_playing(true);
}
AppEvent::BrowserEvent(BrowserEvent::AlbumSaved(id))
| AppEvent::BrowserEvent(BrowserEvent::AlbumUnsaved(id))
if id == &self.model.id =>
{
self.update_liked();
}
AppEvent::PlaybackEvent(PlaybackEvent::PlaybackPaused) => {
self.update_playing(false);
}
AppEvent::PlaybackEvent(PlaybackEvent::PlaybackResumed) => {
self.update_playing(true);
}
_ => {}
}
self.broadcast_event(event);
}
}

View file

@ -0,0 +1,272 @@
use gio::prelude::*;
use gio::SimpleActionGroup;
use std::cell::Ref;
use std::ops::Deref;
use std::rc::Rc;
use crate::api::SpotifyApiError;
use crate::app::components::labels;
use crate::app::components::HeaderBarModel;
use crate::app::components::PlaylistModel;
use crate::app::components::SimpleHeaderBarModel;
use crate::app::components::SimpleHeaderBarModelWrapper;
use crate::app::dispatch::ActionDispatcher;
use crate::app::models::*;
use crate::app::state::SelectionContext;
use crate::app::state::{BrowserAction, PlaybackAction, SelectionAction, SelectionState};
use crate::app::{AppAction, AppEvent, AppModel, AppState, BatchQuery, SongsSource};
pub struct DetailsModel {
pub id: String,
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl DetailsModel {
pub fn new(id: String, app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
id,
app_model,
dispatcher,
}
}
fn state(&self) -> Ref<'_, AppState> {
self.app_model.get_state()
}
pub fn get_album_info(&self) -> Option<impl Deref<Target = AlbumFullDescription> + '_> {
self.app_model
.map_state_opt(|s| s.browser.details_state(&self.id)?.content.as_ref())
}
pub fn get_album_description(&self) -> Option<impl Deref<Target = AlbumDescription> + '_> {
self.app_model.map_state_opt(|s| {
Some(
&s.browser
.details_state(&self.id)?
.content
.as_ref()?
.description,
)
})
}
pub fn load_album_info(&self) {
let id = self.id.clone();
let api = self.app_model.get_spotify();
self.dispatcher
.call_spotify_and_dispatch(move || async move {
let album = api.get_album(&id).await;
match album {
Ok(album) => Ok(BrowserAction::SetAlbumDetails(Box::new(album)).into()),
Err(SpotifyApiError::BadStatus(400, _))
| Err(SpotifyApiError::BadStatus(404, _)) => {
Ok(BrowserAction::NavigationPop.into())
}
Err(e) => Err(e),
}
});
}
pub fn view_artist(&self) {
if let Some(album) = self.get_album_description() {
let artist = &album.artists.first().unwrap().id;
self.dispatcher
.dispatch(AppAction::ViewArtist(artist.to_owned()));
}
}
pub fn toggle_save_album(&self) {
if let Some(album) = self.get_album_description() {
let id = album.id.clone();
let is_liked = album.is_liked;
let api = self.app_model.get_spotify();
self.dispatcher
.call_spotify_and_dispatch(move || async move {
if !is_liked {
api.save_album(&id)
.await
.map(|album| BrowserAction::SaveAlbum(Box::new(album)).into())
} else {
api.remove_saved_album(&id)
.await
.map(|_| BrowserAction::UnsaveAlbum(id).into())
}
});
}
}
pub fn is_playing(&self) -> bool {
self.state().playback.is_playing()
}
pub fn album_is_playing(&self) -> bool {
matches!(
self.app_model.get_state().playback.current_source(),
Some(SongsSource::Album(ref id)) if id == &self.id)
}
pub fn toggle_play_album(&self) {
if let Some(album) = self.get_album_description() {
if !self.album_is_playing() {
if self.state().playback.is_shuffled() {
self.dispatcher
.dispatch(AppAction::PlaybackAction(PlaybackAction::ToggleShuffle));
}
let id_of_first_song = album.songs.songs[0].id.as_str();
self.play_song_at(0, id_of_first_song);
return;
}
if self.state().playback.is_playing() {
self.dispatcher
.dispatch(AppAction::PlaybackAction(PlaybackAction::Pause));
} else {
self.dispatcher
.dispatch(AppAction::PlaybackAction(PlaybackAction::Play));
}
}
}
pub fn load_more(&self) -> Option<()> {
let last_batch = self.song_list_model().last_batch()?;
let query = BatchQuery {
source: SongsSource::Album(self.id.clone()),
batch: last_batch,
};
let id = self.id.clone();
let next_query = query.next()?;
let loader = self.app_model.get_batch_loader();
self.dispatcher.dispatch_async(Box::pin(async move {
loader
.query(next_query, |_s, song_batch| {
BrowserAction::AppendAlbumTracks(id, Box::new(song_batch)).into()
})
.await
}));
Some(())
}
pub fn to_headerbar_model(self: &Rc<Self>) -> Rc<impl HeaderBarModel> {
Rc::new(SimpleHeaderBarModelWrapper::new(
self.clone(),
self.app_model.clone(),
self.dispatcher.box_clone(),
))
}
}
impl PlaylistModel for DetailsModel {
fn song_list_model(&self) -> SongListModel {
self.app_model
.get_state()
.browser
.details_state(&self.id)
.expect("illegal attempt to read details_state")
.songs
.clone()
}
fn is_paused(&self) -> bool {
!self.app_model.get_state().playback.is_playing()
}
fn show_song_covers(&self) -> bool {
false
}
fn select_song(&self, id: &str) {
let songs = self.song_list_model();
if let Some(song) = songs.get(id) {
self.dispatcher
.dispatch(SelectionAction::Select(vec![song.description().clone()]).into());
}
}
fn deselect_song(&self, id: &str) {
self.dispatcher
.dispatch(SelectionAction::Deselect(vec![id.to_string()]).into());
}
fn enable_selection(&self) -> bool {
self.dispatcher
.dispatch(AppAction::EnableSelection(SelectionContext::Default));
true
}
fn selection(&self) -> Option<Box<dyn Deref<Target = SelectionState> + '_>> {
Some(Box::new(self.app_model.map_state(|s| &s.selection)))
}
fn current_song_id(&self) -> Option<String> {
self.state().playback.current_song_id()
}
fn play_song_at(&self, pos: usize, id: &str) {
let source = SongsSource::Album(self.id.clone());
let batch = self.song_list_model().song_batch_for(pos);
if let Some(batch) = batch {
self.dispatcher
.dispatch(PlaybackAction::LoadPagedSongs(source, batch).into());
self.dispatcher
.dispatch(PlaybackAction::Load(id.to_string()).into());
}
}
fn actions_for(&self, id: &str) -> Option<gio::ActionGroup> {
let song = self.song_list_model().get(id)?;
let song = song.description();
let group = SimpleActionGroup::new();
for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) {
group.add_action(&view_artist);
}
group.add_action(&song.make_link_action(None));
group.add_action(&song.make_queue_action(self.dispatcher.box_clone(), None));
Some(group.upcast())
}
fn menu_for(&self, id: &str) -> Option<gio::MenuModel> {
let song = self.song_list_model().get(id)?;
let song = song.description();
let menu = gio::Menu::new();
for artist in song.artists.iter() {
menu.append(
Some(&labels::more_from_label(&artist.name)),
Some(&format!("song.view_artist_{}", artist.id)),
);
}
menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link"));
menu.append(Some(&*labels::ADD_TO_QUEUE), Some("song.queue"));
Some(menu.upcast())
}
}
impl SimpleHeaderBarModel for DetailsModel {
fn title(&self) -> Option<String> {
None
}
fn title_updated(&self, _: &AppEvent) -> bool {
false
}
fn selection_context(&self) -> Option<SelectionContext> {
Some(SelectionContext::Default)
}
fn select_all(&self) {
let songs: Vec<SongDescription> = self.song_list_model().collect();
self.dispatcher
.dispatch(SelectionAction::Select(songs).into());
}
}

View file

@ -0,0 +1,8 @@
mod album_header;
#[allow(clippy::module_inception)]
mod details;
mod details_model;
mod release_details;
pub use details::Details;
pub use details_model::DetailsModel;

View file

@ -0,0 +1,82 @@
using Gtk 4.0;
using Adw 1;
template $ReleaseDetailsWindow : Adw.Window {
modal: true;
hide-on-close: true;
default-width: 360;
Box {
orientation: vertical;
Adw.HeaderBar {
show-end-title-buttons: true;
[title]
Adw.WindowTitle album_artist {
}
styles [
"flat",
]
}
ListBox {
margin-start: 6;
margin-end: 6;
margin-top: 6;
margin-bottom: 6;
valign: start;
selection-mode: none;
show-separators: true;
overflow: hidden;
styles [
"card",
]
Adw.ActionRow {
/* Translators: This refers to a music label */
title: _("Label");
[suffix]
Label label {
label: "Label";
}
}
Adw.ActionRow {
/* Translators: This refers to a release date */
title: _("Released");
[suffix]
Label release {
label: "Released";
}
}
Adw.ActionRow {
/* Translators: This refers to a number of tracks */
title: _("Tracks");
[suffix]
Label tracks {
label: "Tracks";
}
}
Adw.ActionRow {
title: _("Copyright");
[suffix]
Label copyright {
label: "Copyright";
ellipsize: middle;
}
}
}
}
}

View file

@ -0,0 +1,92 @@
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use libadwaita::subclass::prelude::*;
use crate::app::components::labels;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/release_details.ui")]
pub struct ReleaseDetailsWindow {
#[template_child]
pub album_artist: TemplateChild<libadwaita::WindowTitle>,
#[template_child]
pub label: TemplateChild<gtk::Label>,
#[template_child]
pub release: TemplateChild<gtk::Label>,
#[template_child]
pub tracks: TemplateChild<gtk::Label>,
#[template_child]
pub copyright: TemplateChild<gtk::Label>,
}
#[glib::object_subclass]
impl ObjectSubclass for ReleaseDetailsWindow {
const NAME: &'static str = "ReleaseDetailsWindow";
type Type = super::ReleaseDetailsWindow;
type ParentType = libadwaita::Window;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for ReleaseDetailsWindow {
fn constructed(&self) {
self.parent_constructed();
}
}
impl WidgetImpl for ReleaseDetailsWindow {}
impl AdwWindowImpl for ReleaseDetailsWindow {}
impl WindowImpl for ReleaseDetailsWindow {}
}
glib::wrapper! {
pub struct ReleaseDetailsWindow(ObjectSubclass<imp::ReleaseDetailsWindow>) @extends gtk::Widget, libadwaita::Window, libadwaita::PreferencesWindow;
}
impl Default for ReleaseDetailsWindow {
fn default() -> Self {
Self::new()
}
}
impl ReleaseDetailsWindow {
pub fn new() -> Self {
glib::Object::new()
}
#[allow(clippy::too_many_arguments)]
pub fn set_details(
&self,
album: &str,
artist: &str,
label: &str,
release_date: &str,
track_count: usize,
copyright: &str,
) {
let widget = self.imp();
widget
.album_artist
.set_title(&labels::album_by_artist_label(album, artist));
widget.label.set_text(label);
widget.release.set_text(release_date);
widget.tracks.set_text(&track_count.to_string());
widget.copyright.set_text(copyright);
}
}

View file

@ -0,0 +1,100 @@
use std::ops::Deref;
use std::rc::Rc;
use glib::Cast;
use crate::app::components::{Component, EventListener};
use crate::app::models::ConnectDevice;
use crate::app::state::{Device, LoginEvent, PlaybackAction, PlaybackEvent};
use crate::app::{ActionDispatcher, AppEvent, AppModel};
use super::widget::DeviceSelectorWidget;
pub struct DeviceSelectorModel {
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl DeviceSelectorModel {
pub fn new(app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
app_model,
dispatcher,
}
}
pub fn refresh_available_devices(&self) {
let api = self.app_model.get_spotify();
self.dispatcher
.call_spotify_and_dispatch(move || async move {
api.list_available_devices()
.await
.map(|devices| PlaybackAction::SetAvailableDevices(devices).into())
});
}
pub fn get_available_devices(&self) -> impl Deref<Target = Vec<ConnectDevice>> + '_ {
self.app_model.map_state(|s| s.playback.available_devices())
}
pub fn get_current_device(&self) -> impl Deref<Target = Device> + '_ {
self.app_model.map_state(|s| s.playback.current_device())
}
pub fn set_current_device(&self, id: Option<String>) {
let devices = self.get_available_devices();
let connect_device = id
.and_then(|id| devices.iter().find(|&d| d.id == id))
.cloned();
let device = connect_device.map(Device::Connect).unwrap_or(Device::Local);
self.dispatcher
.dispatch(PlaybackAction::SwitchDevice(device).into());
}
}
pub struct DeviceSelector {
widget: DeviceSelectorWidget,
model: Rc<DeviceSelectorModel>,
}
impl DeviceSelector {
pub fn new(widget: DeviceSelectorWidget, model: DeviceSelectorModel) -> Self {
let model = Rc::new(model);
widget.connect_refresh(clone!(@weak model => move || {
model.refresh_available_devices();
}));
widget.connect_switch_device(clone!(@weak model => move |id| {
model.set_current_device(id);
}));
Self { widget, model }
}
}
impl Component for DeviceSelector {
fn get_root_widget(&self) -> &gtk::Widget {
self.widget.upcast_ref()
}
}
impl EventListener for DeviceSelector {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::LoginEvent(LoginEvent::LoginCompleted(_)) => {
self.model.refresh_available_devices();
}
AppEvent::PlaybackEvent(PlaybackEvent::AvailableDevicesChanged) => {
self.widget
.update_devices_list(&self.model.get_available_devices());
}
AppEvent::PlaybackEvent(PlaybackEvent::SwitchedDevice(_)) => {
self.widget
.set_current_device(&self.model.get_current_device());
}
_ => (),
}
}
}

View file

@ -0,0 +1,44 @@
using Gtk 4.0;
using Adw 1;
template $DeviceSelectorWidget : Button {
Adw.ButtonContent button_content {
halign: center;
hexpand: false;
icon-name: "audio-x-generic-symbolic";
label: _("This device");
}
}
menu menu {
section {
label: _("Playing on");
item {
custom: "custom_content";
}
}
section {
item {
label: _("Refresh devices");
action: "devices.refresh";
}
}
}
PopoverMenu popover {
}
Box custom_content {
orientation: vertical;
CheckButton this_device_button {
label: _("This device");
sensitive: false;
}
Box devices {
orientation: vertical;
}
}

View file

@ -0,0 +1,11 @@
use glib::StaticType;
mod component;
pub use component::*;
mod widget;
pub use widget::*;
pub fn expose_widgets() {
widget::DeviceSelectorWidget::static_type();
}

View file

@ -0,0 +1,169 @@
use crate::app::models::{ConnectDevice, ConnectDeviceKind};
use crate::app::state::Device;
use gettextrs::gettext;
use gio::{Action, SimpleAction, SimpleActionGroup};
use glib::FromVariant;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
const ACTIONS: &str = "devices";
const CONNECT_ACTION: &str = "connect";
const REFRESH_ACTION: &str = "refresh";
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/device_selector.ui")]
pub struct DeviceSelectorWidget {
#[template_child]
pub button_content: TemplateChild<libadwaita::ButtonContent>,
#[template_child]
pub popover: TemplateChild<gtk::PopoverMenu>,
#[template_child]
pub custom_content: TemplateChild<gtk::Box>,
#[template_child]
pub devices: TemplateChild<gtk::Box>,
#[template_child]
pub this_device_button: TemplateChild<gtk::CheckButton>,
#[template_child]
pub menu: TemplateChild<gio::MenuModel>,
pub action_group: SimpleActionGroup,
}
#[glib::object_subclass]
impl ObjectSubclass for DeviceSelectorWidget {
const NAME: &'static str = "DeviceSelectorWidget";
type Type = super::DeviceSelectorWidget;
type ParentType = gtk::Button;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for DeviceSelectorWidget {
fn constructed(&self) {
self.parent_constructed();
let popover: &gtk::PopoverMenu = &self.popover;
popover.set_menu_model(Some(&*self.menu));
popover.add_child(&*self.custom_content, "custom_content");
popover.set_parent(&*self.obj());
popover.set_autohide(true);
let this_device = &*self.this_device_button;
this_device.set_action_name(Some(&format!("{}.{}", ACTIONS, CONNECT_ACTION)));
this_device.set_action_target_value(Some(&Option::<String>::None.to_variant()));
self.obj()
.insert_action_group(ACTIONS, Some(&self.action_group));
self.obj()
.connect_clicked(clone!(@weak popover => move |_| {
popover.set_visible(true);
popover.present();
popover.grab_focus();
}));
}
}
impl WidgetImpl for DeviceSelectorWidget {}
impl ButtonImpl for DeviceSelectorWidget {}
}
glib::wrapper! {
pub struct DeviceSelectorWidget(ObjectSubclass<imp::DeviceSelectorWidget>) @extends gtk::Widget, gtk::Button;
}
impl DeviceSelectorWidget {
fn action(&self, name: &str) -> Option<Action> {
self.imp().action_group.lookup_action(name)
}
pub fn connect_refresh<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().action_group.add_action(&{
let refresh = SimpleAction::new(REFRESH_ACTION, None);
refresh.connect_activate(move |_, _| f());
refresh
});
}
pub fn connect_switch_device<F>(&self, f: F)
where
F: Fn(Option<String>) + 'static,
{
self.imp().action_group.add_action(&{
let connect = SimpleAction::new_stateful(
CONNECT_ACTION,
Some(Option::<String>::static_variant_type().as_ref()),
Option::<String>::None.to_variant(),
);
connect.connect_activate(move |action, device_id| {
if let Some(device_id) = device_id {
action.change_state(device_id);
f(Option::<String>::from_variant(device_id).unwrap());
}
});
connect
});
}
pub fn set_current_device(&self, device: &Device) {
if let Some(action) = self.action(CONNECT_ACTION) {
let device_id = match device {
Device::Local => None,
Device::Connect(connect) => Some(&connect.id),
};
action.change_state(&device_id.to_variant());
}
let label = match device {
Device::Local => gettext("This device"),
Device::Connect(connect) => connect.label.clone(),
};
let icon = match device {
Device::Local => "audio-x-generic-symbolic",
Device::Connect(connect) => match connect.kind {
ConnectDeviceKind::Phone => "phone-symbolic",
ConnectDeviceKind::Computer => "computer-symbolic",
ConnectDeviceKind::Speaker => "audio-speakers-symbolic",
ConnectDeviceKind::Other => "audio-x-generic-symbolic",
},
};
self.imp().button_content.set_label(&label);
self.imp().button_content.set_icon_name(icon);
}
pub fn update_devices_list(&self, devices: &[ConnectDevice]) {
let widget = self.imp();
widget.this_device_button.set_sensitive(!devices.is_empty());
while let Some(child) = widget.devices.upcast_ref::<gtk::Widget>().first_child() {
widget.devices.remove(&child);
}
for device in devices {
let check = gtk::CheckButton::builder()
.action_name(format!("{}.{}", ACTIONS, CONNECT_ACTION))
.action_target(&Some(&device.id).to_variant())
.group(&*widget.this_device_button)
.label(&device.label)
.build();
widget.devices.append(&check);
}
}
}

View file

@ -0,0 +1,296 @@
use std::rc::Rc;
use glib::Cast;
use gtk::prelude::*;
use crate::app::{
components::{Component, EventListener, ListenerComponent},
state::{SelectionContext, SelectionEvent},
ActionDispatcher, AppAction, AppEvent, AppModel, BrowserAction, BrowserEvent,
};
use super::widget::HeaderBarWidget;
pub trait HeaderBarModel {
fn title(&self) -> Option<String>;
fn title_updated(&self, event: &AppEvent) -> bool;
fn go_back(&self);
fn can_go_back(&self) -> bool;
fn selection_context(&self) -> Option<SelectionContext>;
fn can_select_all(&self) -> bool;
fn start_selection(&self);
fn select_all(&self);
fn cancel_selection(&self);
fn selected_count(&self) -> usize;
}
pub struct DefaultHeaderBarModel {
title: Option<String>,
selection_context: Option<SelectionContext>,
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl DefaultHeaderBarModel {
pub fn new(
title: Option<String>,
selection_context: Option<SelectionContext>,
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
) -> Self {
Self {
title,
selection_context,
app_model,
dispatcher,
}
}
}
impl HeaderBarModel for DefaultHeaderBarModel {
fn title(&self) -> Option<String> {
self.title.clone()
}
fn title_updated(&self, _: &AppEvent) -> bool {
false
}
fn go_back(&self) {
self.dispatcher
.dispatch(BrowserAction::NavigationPop.into())
}
fn can_go_back(&self) -> bool {
self.app_model.get_state().browser.can_pop()
}
fn selection_context(&self) -> Option<SelectionContext> {
self.selection_context.clone()
}
fn can_select_all(&self) -> bool {
false
}
fn start_selection(&self) {
if let Some(context) = self.selection_context.as_ref() {
self.dispatcher
.dispatch(AppAction::EnableSelection(context.clone()))
}
}
fn select_all(&self) {}
fn cancel_selection(&self) {
self.dispatcher.dispatch(AppAction::CancelSelection)
}
fn selected_count(&self) -> usize {
self.app_model.get_state().selection.count()
}
}
pub trait SimpleHeaderBarModel {
fn title(&self) -> Option<String>;
fn title_updated(&self, event: &AppEvent) -> bool;
fn selection_context(&self) -> Option<SelectionContext>;
fn select_all(&self);
}
pub struct SimpleHeaderBarModelWrapper<M> {
wrapped_model: Rc<M>,
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl<M> SimpleHeaderBarModelWrapper<M> {
pub fn new(
wrapped_model: Rc<M>,
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
) -> Self {
Self {
wrapped_model,
app_model,
dispatcher,
}
}
}
impl<M> HeaderBarModel for SimpleHeaderBarModelWrapper<M>
where
M: SimpleHeaderBarModel + 'static,
{
fn title(&self) -> Option<String> {
self.wrapped_model.title()
}
fn title_updated(&self, event: &AppEvent) -> bool {
self.wrapped_model.title_updated(event)
}
fn go_back(&self) {
self.dispatcher
.dispatch(BrowserAction::NavigationPop.into())
}
fn can_go_back(&self) -> bool {
self.app_model.get_state().browser.can_pop()
}
fn selection_context(&self) -> Option<SelectionContext> {
self.wrapped_model.selection_context()
}
fn can_select_all(&self) -> bool {
true
}
fn start_selection(&self) {
if let Some(context) = self.wrapped_model.selection_context() {
self.dispatcher
.dispatch(AppAction::EnableSelection(context));
}
}
fn select_all(&self) {
self.wrapped_model.select_all()
}
fn cancel_selection(&self) {
self.dispatcher.dispatch(AppAction::CancelSelection)
}
fn selected_count(&self) -> usize {
self.app_model.get_state().selection.count()
}
}
mod common {
use super::*;
pub fn update_for_event<Model>(event: &AppEvent, widget: &HeaderBarWidget, model: &Rc<Model>)
where
Model: HeaderBarModel + 'static,
{
match event {
AppEvent::SelectionEvent(SelectionEvent::SelectionModeChanged(active)) => {
widget.set_selection_active(*active);
}
AppEvent::SelectionEvent(SelectionEvent::SelectionChanged) => {
widget.set_selection_count(model.selected_count());
}
AppEvent::BrowserEvent(BrowserEvent::NavigationPushed(_))
| AppEvent::BrowserEvent(BrowserEvent::NavigationPoppedTo(_))
| AppEvent::BrowserEvent(BrowserEvent::NavigationPopped)
| AppEvent::BrowserEvent(BrowserEvent::NavigationHidden(_)) => {
model.cancel_selection();
widget.set_can_go_back(model.can_go_back());
}
event if model.title_updated(event) => {
widget.set_title(model.title().as_ref().map(|s| &s[..]));
}
_ => {}
}
}
pub fn bind_headerbar<Model>(widget: &HeaderBarWidget, model: &Rc<Model>)
where
Model: HeaderBarModel + 'static,
{
widget.connect_selection_start(clone!(@weak model => move || model.start_selection()));
widget.connect_select_all(clone!(@weak model => move || model.select_all()));
widget.connect_selection_cancel(clone!(@weak model => move || model.cancel_selection()));
widget.connect_go_back(clone!(@weak model => move || model.go_back()));
widget.set_title(model.title().as_ref().map(|s| &s[..]));
widget.set_selection_possible(model.selection_context().is_some());
widget.set_select_all_possible(model.can_select_all());
widget.set_can_go_back(model.can_go_back());
}
}
pub struct HeaderBarComponent<Model: HeaderBarModel> {
widget: HeaderBarWidget,
model: Rc<Model>,
}
impl<Model> HeaderBarComponent<Model>
where
Model: HeaderBarModel + 'static,
{
pub fn new(widget: HeaderBarWidget, model: Rc<Model>) -> Self {
common::bind_headerbar(&widget, &model);
Self { widget, model }
}
}
impl<Model> EventListener for HeaderBarComponent<Model>
where
Model: HeaderBarModel + 'static,
{
fn on_event(&mut self, event: &AppEvent) {
common::update_for_event(event, &self.widget, &self.model);
}
}
// wrapper version ("Screen")
pub struct StandardScreen<Model: HeaderBarModel> {
root: gtk::Widget,
widget: HeaderBarWidget,
model: Rc<Model>,
children: Vec<Box<dyn EventListener>>,
}
impl<Model> StandardScreen<Model>
where
Model: HeaderBarModel + 'static,
{
pub fn new(
wrapped: impl ListenerComponent + 'static,
leaflet: &libadwaita::Leaflet,
model: Rc<Model>,
) -> Self {
let widget = HeaderBarWidget::new();
common::bind_headerbar(&widget, &model);
widget.bind_to_leaflet(leaflet);
let root = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
root.append(&widget);
root.append(wrapped.get_root_widget());
Self {
root: root.upcast(),
widget,
model,
children: vec![Box::new(wrapped)],
}
}
}
impl<Model> Component for StandardScreen<Model>
where
Model: HeaderBarModel + 'static,
{
fn get_root_widget(&self) -> &gtk::Widget {
&self.root
}
fn get_children(&mut self) -> Option<&mut Vec<Box<dyn EventListener>>> {
Some(&mut self.children)
}
}
impl<Model> EventListener for StandardScreen<Model>
where
Model: HeaderBarModel + 'static,
{
fn on_event(&mut self, event: &AppEvent) {
common::update_for_event(event, &self.widget, &self.model);
self.broadcast_event(event);
}
}

View file

@ -0,0 +1,67 @@
using Gtk 4.0;
using Adw 1;
template $HeaderBarWidget : Adw.Bin {
[root]
Overlay overlay {
hexpand: true;
Adw.HeaderBar main_header {
show-end-title-buttons: true;
Button go_back {
receives-default: true;
halign: start;
valign: center;
icon-name: "go-previous-symbolic";
has-frame: false;
}
[title]
Adw.WindowTitle title {
visible: true;
title: "Spot";
}
[end]
Button start_selection {
icon-name: "object-select-symbolic";
}
}
[overlay]
Adw.HeaderBar selection_header {
show-end-title-buttons: false;
show-start-title-buttons: false;
visible: false;
styles [
"selection-mode",
]
Button cancel {
receives-default: true;
halign: start;
valign: center;
/* Translators: Button label. Exits selection mode. */
label: _("Cancel");
}
[title]
Adw.WindowTitle selection_title {
title: "";
}
[end]
Button select_all {
valign: center;
/* Translators: Button label. Selects all visible songs. */
label: _("Select all");
}
}
}
}

View file

@ -0,0 +1,11 @@
mod widget;
pub use widget::*;
mod component;
pub use component::*;
use glib::prelude::*;
pub fn expose_widgets() {
widget::HeaderBarWidget::static_type();
}

View file

@ -0,0 +1,189 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use libadwaita::subclass::prelude::BinImpl;
use crate::app::components::labels;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/headerbar.ui")]
pub struct HeaderBarWidget {
#[template_child]
pub main_header: TemplateChild<libadwaita::HeaderBar>,
#[template_child]
pub selection_header: TemplateChild<libadwaita::HeaderBar>,
#[template_child]
pub go_back: TemplateChild<gtk::Button>,
#[template_child]
pub title: TemplateChild<libadwaita::WindowTitle>,
#[template_child]
pub selection_title: TemplateChild<libadwaita::WindowTitle>,
#[template_child]
pub start_selection: TemplateChild<gtk::Button>,
#[template_child]
pub select_all: TemplateChild<gtk::Button>,
#[template_child]
pub cancel: TemplateChild<gtk::Button>,
#[template_child]
pub overlay: TemplateChild<gtk::Overlay>,
}
#[glib::object_subclass]
impl ObjectSubclass for HeaderBarWidget {
const NAME: &'static str = "HeaderBarWidget";
type Type = super::HeaderBarWidget;
type ParentType = libadwaita::Bin;
type Interfaces = (gtk::Buildable,);
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for HeaderBarWidget {}
impl BuildableImpl for HeaderBarWidget {
fn add_child(&self, builder: &gtk::Builder, child: &glib::Object, type_: Option<&str>) {
if Some("root") == type_ {
self.parent_add_child(builder, child, type_);
} else {
self.main_header
.set_title_widget(child.downcast_ref::<gtk::Widget>());
}
}
}
impl WidgetImpl for HeaderBarWidget {}
impl BinImpl for HeaderBarWidget {}
impl WindowImpl for HeaderBarWidget {}
}
glib::wrapper! {
pub struct HeaderBarWidget(ObjectSubclass<imp::HeaderBarWidget>) @extends gtk::Widget, libadwaita::Bin;
}
impl Default for HeaderBarWidget {
fn default() -> Self {
Self::new()
}
}
impl HeaderBarWidget {
pub fn new() -> Self {
glib::Object::new()
}
pub fn connect_selection_start<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().start_selection.connect_clicked(move |_| f());
}
pub fn connect_select_all<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().select_all.connect_clicked(move |_| f());
}
pub fn connect_selection_cancel<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().cancel.connect_clicked(move |_| f());
}
pub fn connect_go_back<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().go_back.connect_clicked(move |_| f());
}
pub fn bind_to_leaflet(&self, leaflet: &libadwaita::Leaflet) {
leaflet
.bind_property(
"folded",
&*self.imp().main_header,
"show-start-title-buttons",
)
.build();
leaflet.notify("folded");
}
pub fn set_can_go_back(&self, can_go_back: bool) {
self.imp().go_back.set_visible(can_go_back);
}
pub fn set_selection_possible(&self, possible: bool) {
self.imp().start_selection.set_visible(possible);
}
pub fn set_select_all_possible(&self, possible: bool) {
self.imp().select_all.set_visible(possible);
}
pub fn set_selection_active(&self, active: bool) {
if active {
self.imp()
.selection_title
.set_title(&labels::n_songs_selected_label(0));
self.imp().selection_title.set_visible(true);
self.imp().selection_header.set_visible(true);
} else {
self.imp().selection_title.set_visible(false);
self.imp().selection_header.set_visible(false);
}
}
pub fn set_selection_count(&self, count: usize) {
self.imp()
.selection_title
.set_title(&labels::n_songs_selected_label(count));
}
pub fn add_classes(&self, classes: &[&str]) {
for &class in classes {
self.imp().main_header.add_css_class(class);
}
}
pub fn remove_classes(&self, classes: &[&str]) {
for &class in classes {
self.imp().main_header.remove_css_class(class);
}
}
pub fn set_title_visible(&self, visible: bool) {
self.imp().title.set_visible(visible);
}
pub fn set_title_and_subtitle(&self, title: &str, subtitle: &str) {
self.imp().title.set_title(title);
self.imp().title.set_subtitle(subtitle);
}
pub fn set_title(&self, title: Option<&str>) {
self.imp().title.set_visible(title.is_some());
if let Some(title) = title {
self.imp().title.set_title(title);
}
}
}

View file

@ -0,0 +1,55 @@
use gettextrs::*;
lazy_static! {
// translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track.
pub static ref VIEW_ALBUM: String = gettext("View album");
// translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track.
pub static ref COPY_LINK: String = gettext("Copy link");
// translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue.
pub static ref ADD_TO_QUEUE: String = gettext("Add to queue");
// translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue.
pub static ref REMOVE_FROM_QUEUE: String = gettext("Remove from queue");
}
pub fn add_to_playlist_label(playlist: &str) -> String {
// this is just to fool xgettext, it doesn't like macros (or rust for that matter) :(
if cfg!(debug_assertions) {
// translators: This is part of a larger text that says "Add to <playlist name>". This text should be as short as possible.
gettext("Add to {}");
}
gettext!("Add to {}", playlist)
}
pub fn n_songs_selected_label(n: usize) -> String {
// this is just to fool xgettext, it doesn't like macros (or rust for that matter) :(
if cfg!(debug_assertions) {
// translators: This shows up when in selection mode. This text should be as short as possible.
ngettext("{} song selected", "{} songs selected", n as u32);
}
ngettext!("{} song selected", "{} songs selected", n as u32, n)
}
pub fn more_from_label(artist: &str) -> String {
// this is just to fool xgettext, it doesn't like macros (or rust for that matter) :(
if cfg!(debug_assertions) {
// translators: This is part of a contextual menu attached to a single track; the full text is "More from <artist>".
gettext("More from {}");
}
gettext!("More from {}", glib::markup_escape_text(artist))
}
pub fn album_by_artist_label(album: &str, artist: &str) -> String {
// this is just to fool xgettext, it doesn't like macros (or rust for that matter) :(
if cfg!(debug_assertions) {
// translators: This is part of a larger label that reads "<Album> by <Artist>"
gettext("{} by {}");
}
gettext!(
"{} by {}",
glib::markup_escape_text(album),
glib::markup_escape_text(artist)
)
}

View file

@ -0,0 +1,35 @@
using Gtk 4.0;
using Adw 1;
template $LibraryWidget : Box {
ScrolledWindow scrolled_window {
hexpand: true;
vexpand: true;
vscrollbar-policy: always;
min-content-width: 250;
Overlay overlay {
FlowBox flowbox {
margin-start: 6;
margin-end: 6;
margin-top: 6;
margin-bottom: 6;
min-children-per-line: 1;
selection-mode: none;
activate-on-single-click: false;
}
[overlay]
Adw.StatusPage status_page {
/* Translators: A title that is shown when the user has not saved any albums. */
title: _("You have no saved albums.");
/* Translators: A description of what happens when the user has saved albums. */
description: _("Your library will be shown here.");
icon-name: "emblem-music-symbolic";
visible: true;
}
}
}
}

View file

@ -0,0 +1,158 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use std::rc::Rc;
use super::LibraryModel;
use crate::app::components::utils::wrap_flowbox_item;
use crate::app::components::{AlbumWidget, Component, EventListener};
use crate::app::dispatch::Worker;
use crate::app::models::AlbumModel;
use crate::app::state::LoginEvent;
use crate::app::{AppEvent, BrowserEvent, ListStore};
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/library.ui")]
pub struct LibraryWidget {
#[template_child]
pub scrolled_window: TemplateChild<gtk::ScrolledWindow>,
#[template_child]
pub flowbox: TemplateChild<gtk::FlowBox>,
#[template_child]
pub status_page: TemplateChild<libadwaita::StatusPage>,
}
#[glib::object_subclass]
impl ObjectSubclass for LibraryWidget {
const NAME: &'static str = "LibraryWidget";
type Type = super::LibraryWidget;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for LibraryWidget {}
impl WidgetImpl for LibraryWidget {}
impl BoxImpl for LibraryWidget {}
}
glib::wrapper! {
pub struct LibraryWidget(ObjectSubclass<imp::LibraryWidget>) @extends gtk::Widget, gtk::Box;
}
impl Default for LibraryWidget {
fn default() -> Self {
Self::new()
}
}
impl LibraryWidget {
pub fn new() -> Self {
glib::Object::new()
}
fn connect_bottom_edge<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp()
.scrolled_window
.connect_edge_reached(move |_, pos| {
if let gtk::PositionType::Bottom = pos {
f()
}
});
}
fn bind_albums<F>(&self, worker: Worker, store: &ListStore<AlbumModel>, on_album_pressed: F)
where
F: Fn(String) + Clone + 'static,
{
self.imp()
.flowbox
.bind_model(Some(store.unsafe_store()), move |item| {
wrap_flowbox_item(item, |album_model| {
let f = on_album_pressed.clone();
let album = AlbumWidget::for_model(album_model, worker.clone());
album.connect_album_pressed(clone!(@weak album_model => move |_| {
f(album_model.uri());
}));
album
})
});
}
pub fn status_page(&self) -> &libadwaita::StatusPage {
&self.imp().status_page
}
}
pub struct Library {
widget: LibraryWidget,
worker: Worker,
model: Rc<LibraryModel>,
}
impl Library {
pub fn new(worker: Worker, model: LibraryModel) -> Self {
let model = Rc::new(model);
let widget = LibraryWidget::new();
widget.connect_bottom_edge(clone!(@weak model => move || {
model.load_more_albums();
}));
Self {
widget,
worker,
model,
}
}
fn bind_flowbox(&self) {
self.widget.bind_albums(
self.worker.clone(),
&self.model.get_list_store().unwrap(),
clone!(@weak self.model as model => move |id| {
model.open_album(id);
}),
);
}
}
impl EventListener for Library {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::Started => {
let _ = self.model.refresh_saved_albums();
self.bind_flowbox();
}
AppEvent::LoginEvent(LoginEvent::LoginCompleted(_)) => {
let _ = self.model.refresh_saved_albums();
}
AppEvent::BrowserEvent(BrowserEvent::LibraryUpdated) => {
self.widget
.status_page()
.set_visible(!self.model.has_albums());
}
_ => {}
}
}
}
impl Component for Library {
fn get_root_widget(&self) -> &gtk::Widget {
self.widget.as_ref()
}
}

View file

@ -0,0 +1,70 @@
use std::cell::Ref;
use std::ops::Deref;
use std::rc::Rc;
use crate::app::models::*;
use crate::app::state::HomeState;
use crate::app::{ActionDispatcher, AppAction, AppModel, BrowserAction, ListStore};
pub struct LibraryModel {
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl LibraryModel {
pub fn new(app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
app_model,
dispatcher,
}
}
fn state(&self) -> Option<Ref<'_, HomeState>> {
self.app_model.map_state_opt(|s| s.browser.home_state())
}
pub fn get_list_store(&self) -> Option<impl Deref<Target = ListStore<AlbumModel>> + '_> {
Some(Ref::map(self.state()?, |s| &s.albums))
}
pub fn refresh_saved_albums(&self) -> Option<()> {
let api = self.app_model.get_spotify();
let batch_size = self.state()?.next_albums_page.batch_size;
self.dispatcher
.call_spotify_and_dispatch(move || async move {
api.get_saved_albums(0, batch_size)
.await
.map(|albums| BrowserAction::SetLibraryContent(albums).into())
});
Some(())
}
pub fn has_albums(&self) -> bool {
self.get_list_store()
.map(|list| list.len() > 0)
.unwrap_or(false)
}
pub fn load_more_albums(&self) -> Option<()> {
let api = self.app_model.get_spotify();
let next_page = &self.state()?.next_albums_page;
let batch_size = next_page.batch_size;
let offset = next_page.next_offset?;
self.dispatcher
.call_spotify_and_dispatch(move || async move {
api.get_saved_albums(offset, batch_size)
.await
.map(|albums| BrowserAction::AppendLibraryContent(albums).into())
});
Some(())
}
pub fn open_album(&self, album_id: String) {
self.dispatcher.dispatch(AppAction::ViewAlbum(album_id));
}
}

View file

@ -0,0 +1,6 @@
#[allow(clippy::module_inception)]
mod library;
mod library_model;
pub use library::*;
pub use library_model::*;

View file

@ -0,0 +1,108 @@
using Gtk 4.0;
using Adw 1;
template $LoginWindow : Adw.Window {
default-width: 360;
default-height: 100;
Box {
hexpand: true;
margin-bottom: 24;
orientation: vertical;
Adw.HeaderBar {
[title]
Label {}
styles ["flat"]
}
WindowHandle {
Adw.Clamp {
maximum-size: 360;
tightening-threshold: 280;
Box {
hexpand: true;
orientation: vertical;
margin-start: 16;
margin-end: 16;
spacing: 24;
Image {
icon-name: "dev.alextren.Spot";
pixel-size: 128;
margin-bottom: 20;
}
Box{
hexpand: true;
orientation: vertical;
spacing: 4;
Label {
label: _("Welcome to Spot");
halign: center;
styles ["title-1"]
}
Label {
/* Translators: Login window title, must mention Premium (a premium account is required). */
label: _("Log in with your Spotify Account. A Spotify Premium subscription is required to use the app.");
wrap: true;
wrap-mode: word;
halign: center;
justify: center;
styles ["body"]
}
}
ListBox {
styles ["boxed-list"]
Adw.EntryRow username {
/* Translators: Placeholder for the username field */
title: _("Username or Email");
}
Adw.PasswordEntryRow password {
/* Translators: Placeholder for the password field */
title: _("Password");
}
}
Revealer auth_error_container {
vexpand: true;
transition-type: slide_up;
Label {
/* Translators: This error is shown when authentication fails. */
label: _("Incorrect login credentials.");
halign: center;
justify: center;
wrap: true;
wrap-mode: word;
styles ["error"]
}
}
Button login_button {
/* Translators: Log in button label */
label: _("Log in");
halign: center;
styles ["pill", "suggested-action"]
}
Button login_with_spotify_button {
/* Translators: Log in button label */
label: _("Log in with Spotify");
halign: center;
styles ["pill", "suggested-action"]
}
}
}
}
}
}

View file

@ -0,0 +1,247 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use std::rc::Rc;
use crate::app::components::EventListener;
use crate::app::credentials::Credentials;
use crate::app::state::{LoginCompletedEvent, LoginEvent};
use crate::app::AppEvent;
use super::LoginModel;
mod imp {
use libadwaita::subclass::prelude::AdwWindowImpl;
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/login.ui")]
pub struct LoginWindow {
#[template_child]
pub username: TemplateChild<libadwaita::EntryRow>,
#[template_child]
pub password: TemplateChild<libadwaita::PasswordEntryRow>,
#[template_child]
pub login_button: TemplateChild<gtk::Button>,
#[template_child]
pub login_with_spotify_button: TemplateChild<gtk::Button>,
#[template_child]
pub auth_error_container: TemplateChild<gtk::Revealer>,
}
#[glib::object_subclass]
impl ObjectSubclass for LoginWindow {
const NAME: &'static str = "LoginWindow";
type Type = super::LoginWindow;
type ParentType = libadwaita::Window;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for LoginWindow {}
impl WidgetImpl for LoginWindow {}
impl AdwWindowImpl for LoginWindow {}
impl WindowImpl for LoginWindow {}
}
glib::wrapper! {
pub struct LoginWindow(ObjectSubclass<imp::LoginWindow>) @extends gtk::Widget, libadwaita::Window;
}
impl Default for LoginWindow {
fn default() -> Self {
Self::new()
}
}
impl LoginWindow {
pub fn new() -> Self {
glib::Object::new()
}
fn connect_close<F>(&self, on_close: F)
where
F: Fn() + 'static,
{
let window = self.upcast_ref::<libadwaita::Window>();
window.connect_close_request(move |_| {
on_close();
gtk::Inhibit(false)
});
}
fn connect_submit<SubmitFn>(&self, on_submit: SubmitFn)
where
SubmitFn: Fn(&str, &str) + Clone + 'static,
{
let on_submit_clone = on_submit.clone();
let controller = gtk::EventControllerKey::new();
controller.set_propagation_phase(gtk::PropagationPhase::Capture);
controller.connect_key_pressed(
clone!(@weak self as _self => @default-return gtk::Inhibit(false), move |_, key, _, _| {
if key == gdk::Key::Return {
_self.submit(&on_submit_clone);
gtk::Inhibit(true)
} else {
gtk::Inhibit(false)
}
}),
);
self.add_controller(controller);
self.imp()
.login_button
.connect_clicked(clone!(@weak self as _self => move |_| {
_self.submit(&on_submit);
}));
}
fn connect_login_oauth_spotify<F>(&self, on_login_with_spotify_button: F)
where
F: Fn() + 'static,
{
self.imp().login_with_spotify_button.connect_clicked(
clone!(@weak self as _self => move |_| {
_self.login_with_spotify(&on_login_with_spotify_button);
}),
);
}
fn show_auth_error(&self, shown: bool) {
let error_class = "error";
let widget = self.imp();
if shown {
widget.username.add_css_class(error_class);
widget.password.add_css_class(error_class);
} else {
widget.username.remove_css_class(error_class);
widget.password.remove_css_class(error_class);
}
widget.auth_error_container.set_reveal_child(shown);
}
fn submit<SubmitFn>(&self, on_submit: &SubmitFn)
where
SubmitFn: Fn(&str, &str),
{
let widget = self.imp();
self.show_auth_error(false);
let username_text = widget.username.text();
let password_text = widget.password.text();
if username_text.is_empty() {
widget.username.grab_focus();
} else if password_text.is_empty() {
widget.password.grab_focus();
} else {
on_submit(username_text.as_str(), password_text.as_str());
}
}
fn login_with_spotify<F>(&self, on_login_with_spotify: &F)
where
F: Fn(),
{
self.show_auth_error(false);
on_login_with_spotify()
}
}
pub struct Login {
parent: gtk::Window,
login_window: LoginWindow,
model: Rc<LoginModel>,
}
impl Login {
pub fn new(parent: gtk::Window, model: LoginModel) -> Self {
let model = Rc::new(model);
let login_window = LoginWindow::new();
login_window.connect_close(clone!(@weak parent => move || {
if let Some(app) = parent.application().as_ref() {
app.quit();
}
}));
login_window.connect_submit(clone!(@weak model => move |username, password| {
model.login(username.to_string(), password.to_string());
}));
login_window.connect_login_oauth_spotify(clone!(@weak model => move || {
model.login_with_spotify();
}));
Self {
parent,
login_window,
model,
}
}
fn window(&self) -> &libadwaita::Window {
self.login_window.upcast_ref::<libadwaita::Window>()
}
fn show_self(&self) {
self.window().set_transient_for(Some(&self.parent));
self.window().set_modal(true);
self.window().set_visible(true);
}
fn hide_and_save_creds(&self, credentials: Credentials) {
self.window().set_visible(false);
self.model.save_for_autologin(credentials);
}
fn reveal_error(&self) {
self.login_window.show_auth_error(true);
}
}
impl EventListener for Login {
fn on_event(&mut self, event: &AppEvent) {
info!("received login event {:?}", event);
match event {
AppEvent::LoginEvent(LoginEvent::LoginCompleted(LoginCompletedEvent::Password(
creds,
))) => {
self.hide_and_save_creds(creds.clone());
}
AppEvent::LoginEvent(LoginEvent::LoginCompleted(LoginCompletedEvent::Token(token))) => {
self.hide_and_save_creds(token.clone());
}
AppEvent::LoginEvent(LoginEvent::LoginFailed) => {
self.model.clear_saved_credentials();
self.reveal_error();
}
AppEvent::Started => {
self.model.try_autologin();
}
AppEvent::LoginEvent(LoginEvent::LogoutCompleted | LoginEvent::LoginShown) => {
self.show_self();
}
AppEvent::LoginEvent(LoginEvent::RefreshTokenCompleted {
token,
token_expiry_time,
}) => {
self.model.save_token(token.clone(), *token_expiry_time);
}
_ => {}
}
}
}

View file

@ -0,0 +1,82 @@
use std::time::SystemTime;
use gettextrs::*;
use crate::app::credentials::Credentials;
use crate::app::state::{LoginAction, TryLoginAction};
use crate::app::{ActionDispatcher, AppAction, Worker};
pub struct LoginModel {
dispatcher: Box<dyn ActionDispatcher>,
worker: Worker,
}
impl LoginModel {
pub fn new(dispatcher: Box<dyn ActionDispatcher>, worker: Worker) -> Self {
Self { dispatcher, worker }
}
pub fn try_autologin(&self) {
self.dispatcher.dispatch_async(Box::pin(async {
let action = match Credentials::retrieve().await {
Ok(creds) => LoginAction::TryLogin(if creds.token_expired() {
TryLoginAction::Password {
username: creds.username,
password: creds.password,
}
} else {
TryLoginAction::Token {
username: creds.username,
token: creds.token,
}
}),
Err(err) => {
warn!("Could not retrieve credentials: {}", err);
LoginAction::ShowLogin
}
};
Some(action.into())
}));
}
pub fn clear_saved_credentials(&self) {
self.worker.send_task(async {
let _ = Credentials::logout().await;
});
}
pub fn save_token(&self, token: String, token_expiry_time: SystemTime) {
self.worker.send_task(async move {
if let Ok(mut credentials) = Credentials::retrieve().await {
credentials.token = token;
credentials.token_expiry_time = Some(token_expiry_time);
if let Err(err) = credentials.save().await {
warn!("Could not save credentials: {}", err);
}
}
});
}
pub fn save_for_autologin(&self, credentials: Credentials) {
self.dispatcher.dispatch_async(Box::pin(async move {
let Err(err) = credentials.save().await else {
return None;
};
warn!("Could not save credentials: {}", err);
Some(AppAction::ShowNotification(gettext(
// translators: This notification shows up right after login if the password could not be stored in the keyring (that is, GNOME's keyring aka seahorse, or any other libsecret compliant secret store).
"Could not save password. Make sure the session keyring is unlocked.",
)))
}));
}
pub fn login(&self, username: String, password: String) {
self.dispatcher
.dispatch(LoginAction::TryLogin(TryLoginAction::Password { username, password }).into())
}
pub fn login_with_spotify(&self) {
self.dispatcher
.dispatch(LoginAction::TryLogin(TryLoginAction::OAuthSpotify {}).into())
}
}

View file

@ -0,0 +1,6 @@
#[allow(clippy::module_inception)]
mod login;
mod login_model;
pub use login::*;
pub use login_model::*;

183
src/app/components/mod.rs Normal file
View file

@ -0,0 +1,183 @@
#[macro_export]
macro_rules! resource {
($resource:expr) => {
concat!("/dev/alextren/Spot", $resource)
};
}
use gettextrs::*;
use std::cell::RefCell;
use std::collections::HashSet;
use std::future::Future;
use crate::api::SpotifyApiError;
use crate::app::{state::LoginAction, ActionDispatcher, AppAction, AppEvent};
mod navigation;
pub use navigation::*;
mod playback;
pub use playback::*;
mod playlist;
pub use playlist::*;
mod login;
pub use login::*;
mod settings;
pub use settings::*;
mod player_notifier;
pub use player_notifier::PlayerNotifier;
mod library;
pub use library::*;
mod details;
pub use details::*;
mod search;
pub use search::*;
mod album;
use album::*;
mod artist;
use artist::*;
mod artist_details;
pub use artist_details::*;
mod user_details;
pub use user_details::*;
mod now_playing;
pub use now_playing::*;
mod device_selector;
pub use device_selector::*;
mod saved_tracks;
pub use saved_tracks::*;
mod user_menu;
pub use user_menu::*;
mod notification;
pub use notification::*;
mod saved_playlists;
pub use saved_playlists::*;
mod playlist_details;
pub use playlist_details::*;
mod window;
pub use window::*;
mod selection;
pub use selection::*;
mod headerbar;
pub use headerbar::*;
mod scrolling_header;
pub use scrolling_header::*;
pub mod utils;
pub mod labels;
pub mod sidebar;
// without this the builder doesn't seen to know about the custom widgets
pub fn expose_custom_widgets() {
playback::expose_widgets();
selection::expose_widgets();
headerbar::expose_widgets();
device_selector::expose_widgets();
playlist_details::expose_widgets();
scrolling_header::expose_widgets();
}
impl dyn ActionDispatcher {
fn call_spotify_and_dispatch<F, C>(&self, call: C)
where
C: 'static + Send + Clone + FnOnce() -> F,
F: Send + Future<Output = Result<AppAction, SpotifyApiError>>,
{
self.call_spotify_and_dispatch_many(move || async { call().await.map(|a| vec![a]) })
}
fn call_spotify_and_dispatch_many<F, C>(&self, call: C)
where
C: 'static + Send + Clone + FnOnce() -> F,
F: Send + Future<Output = Result<Vec<AppAction>, SpotifyApiError>>,
{
self.dispatch_many_async(Box::pin(async move {
let first_call = call.clone();
let result = first_call().await;
match result {
Ok(actions) => actions,
Err(SpotifyApiError::NoToken) => vec![],
Err(SpotifyApiError::InvalidToken) => {
let mut retried = call().await.unwrap_or_else(|_| Vec::new());
retried.insert(0, LoginAction::RefreshToken.into());
retried
}
Err(err) => {
error!("Spotify API error: {}", err);
vec![AppAction::ShowNotification(gettext(
// translators: This notification is the default message for unhandled errors. Logs refer to console output.
"An error occured. Check logs for details!",
))]
}
}
}))
}
}
thread_local!(static CSS_ADDED: RefCell<HashSet<&'static str>> = RefCell::new(HashSet::new()));
pub fn display_add_css_provider(resource: &'static str) {
CSS_ADDED.with(|set| {
if set.borrow().contains(resource) {
return;
}
set.borrow_mut().insert(resource);
let provider = gtk::CssProvider::new();
provider.load_from_resource(resource);
gtk::style_context_add_provider_for_display(
&gdk::Display::default().unwrap(),
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
});
}
pub trait EventListener {
fn on_event(&mut self, _: &AppEvent) {}
}
pub trait Component {
fn get_root_widget(&self) -> &gtk::Widget;
fn get_children(&mut self) -> Option<&mut Vec<Box<dyn EventListener>>> {
None
}
fn broadcast_event(&mut self, event: &AppEvent) {
if let Some(children) = self.get_children() {
for child in children.iter_mut() {
child.on_event(event);
}
}
}
}
pub trait ListenerComponent: Component + EventListener {}
impl<T> ListenerComponent for T where T: Component + EventListener {}

View file

@ -0,0 +1,149 @@
use std::rc::Rc;
use crate::app::components::sidebar::{Sidebar, SidebarModel};
use crate::app::components::*;
use crate::app::state::SelectionContext;
use crate::app::{ActionDispatcher, AppModel, Worker};
pub struct ScreenFactory {
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
worker: Worker,
leaflet: libadwaita::Leaflet,
}
impl ScreenFactory {
pub fn new(
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
worker: Worker,
leaflet: libadwaita::Leaflet,
) -> Self {
Self {
app_model,
dispatcher,
worker,
leaflet,
}
}
pub fn make_library(&self) -> impl ListenerComponent {
let model = LibraryModel::new(Rc::clone(&self.app_model), self.dispatcher.box_clone());
let screen_model = DefaultHeaderBarModel::new(
Some(gettext("Library")),
None,
Rc::clone(&self.app_model),
self.dispatcher.box_clone(),
);
StandardScreen::new(
Library::new(self.worker.clone(), model),
&self.leaflet,
Rc::new(screen_model),
)
}
pub fn make_sidebar(&self, listbox: gtk::ListBox) -> impl ListenerComponent {
let model = SidebarModel::new(Rc::clone(&self.app_model), self.dispatcher.box_clone());
Sidebar::new(listbox, Rc::new(model))
}
pub fn make_saved_playlists(&self) -> impl ListenerComponent {
let model =
SavedPlaylistsModel::new(Rc::clone(&self.app_model), self.dispatcher.box_clone());
let screen_model = DefaultHeaderBarModel::new(
Some(gettext("Playlists")),
None,
Rc::clone(&self.app_model),
self.dispatcher.box_clone(),
);
StandardScreen::new(
SavedPlaylists::new(self.worker.clone(), model),
&self.leaflet,
Rc::new(screen_model),
)
}
pub fn make_now_playing(&self) -> impl ListenerComponent {
let model = Rc::new(NowPlayingModel::new(
Rc::clone(&self.app_model),
self.dispatcher.box_clone(),
));
NowPlaying::new(model, self.worker.clone(), &self.leaflet)
}
pub fn make_saved_tracks(&self) -> impl ListenerComponent {
let screen_model = DefaultHeaderBarModel::new(
Some(gettext("Saved tracks")),
Some(SelectionContext::SavedTracks),
Rc::clone(&self.app_model),
self.dispatcher.box_clone(),
);
let model = Rc::new(SavedTracksModel::new(
Rc::clone(&self.app_model),
self.dispatcher.box_clone(),
));
StandardScreen::new(
SavedTracks::new(model, self.worker.clone()),
&self.leaflet,
Rc::new(screen_model),
)
}
pub fn make_album_details(&self, id: String) -> impl ListenerComponent {
let model = Rc::new(DetailsModel::new(
id,
Rc::clone(&self.app_model),
self.dispatcher.box_clone(),
));
Details::new(model, self.worker.clone(), &self.leaflet)
}
pub fn make_search_results(&self) -> impl ListenerComponent {
let model =
SearchResultsModel::new(Rc::clone(&self.app_model), self.dispatcher.box_clone());
SearchResults::new(model, self.worker.clone(), &self.leaflet)
}
pub fn make_artist_details(&self, id: String) -> impl ListenerComponent {
let model = Rc::new(ArtistDetailsModel::new(
id,
Rc::clone(&self.app_model),
self.dispatcher.box_clone(),
));
let screen_model = SimpleHeaderBarModelWrapper::new(
Rc::clone(&model),
Rc::clone(&self.app_model),
self.dispatcher.box_clone(),
);
StandardScreen::new(
ArtistDetails::new(model, self.worker.clone()),
&self.leaflet,
Rc::new(screen_model),
)
}
pub fn make_playlist_details(&self, id: String) -> impl ListenerComponent {
let model = Rc::new(PlaylistDetailsModel::new(
id,
Rc::clone(&self.app_model),
self.dispatcher.box_clone(),
));
PlaylistDetails::new(model, self.worker.clone())
}
pub fn make_user_details(&self, id: String) -> impl ListenerComponent {
let screen_model = DefaultHeaderBarModel::new(
None,
None,
Rc::clone(&self.app_model),
self.dispatcher.box_clone(),
);
let model =
UserDetailsModel::new(id, Rc::clone(&self.app_model), self.dispatcher.box_clone());
StandardScreen::new(
UserDetails::new(model, self.worker.clone()),
&self.leaflet,
Rc::new(screen_model),
)
}
}

View file

@ -0,0 +1,88 @@
use gtk::prelude::*;
use crate::app::components::sidebar::SidebarDestination;
use crate::app::components::{Component, EventListener, ScreenFactory};
use crate::app::{AppEvent, BrowserEvent};
pub struct HomePane {
stack: gtk::Stack,
components: Vec<Box<dyn EventListener>>,
}
impl HomePane {
pub fn new(listbox: gtk::ListBox, screen_factory: &ScreenFactory) -> Self {
let library = screen_factory.make_library();
let saved_playlists = screen_factory.make_saved_playlists();
let saved_tracks = screen_factory.make_saved_tracks();
let now_playing = screen_factory.make_now_playing();
let sidebar = screen_factory.make_sidebar(listbox);
let stack = gtk::Stack::new();
stack.set_transition_type(gtk::StackTransitionType::Crossfade);
let dest = SidebarDestination::Library;
stack.add_titled(
library.get_root_widget(),
Option::from(dest.id()),
&dest.title(),
);
let dest = SidebarDestination::SavedTracks;
stack.add_titled(
saved_tracks.get_root_widget(),
Option::from(dest.id()),
&dest.title(),
);
let dest = SidebarDestination::SavedPlaylists;
stack.add_titled(
saved_playlists.get_root_widget(),
Option::from(dest.id()),
&dest.title(),
);
let dest = SidebarDestination::NowPlaying;
stack.add_titled(
now_playing.get_root_widget(),
Option::from(dest.id()),
&dest.title(),
);
Self {
stack,
components: vec![
Box::new(sidebar),
Box::new(library),
Box::new(saved_playlists),
Box::new(saved_tracks),
Box::new(now_playing),
],
}
}
}
impl Component for HomePane {
fn get_root_widget(&self) -> &gtk::Widget {
self.stack.upcast_ref()
}
fn get_children(&mut self) -> Option<&mut Vec<Box<dyn EventListener>>> {
Some(&mut self.components)
}
}
impl EventListener for HomePane {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::NowPlayingShown => {
self.stack
.set_visible_child_name(SidebarDestination::NowPlaying.id());
}
AppEvent::BrowserEvent(BrowserEvent::HomeVisiblePageChanged(page)) => {
self.stack.set_visible_child_name(page);
}
_ => {}
}
self.broadcast_event(event);
}
}

View file

@ -0,0 +1,11 @@
#[allow(clippy::module_inception)]
mod navigation;
pub use navigation::*;
mod navigation_model;
pub use navigation_model::*;
mod home;
mod factory;
pub use factory::*;

View file

@ -0,0 +1,147 @@
use gtk::traits::WidgetExt;
use libadwaita::NavigationDirection;
use std::rc::Rc;
use crate::app::components::{EventListener, ListenerComponent};
use crate::app::state::ScreenName;
use crate::app::{AppEvent, BrowserEvent};
use super::{factory::ScreenFactory, home::HomePane, NavigationModel};
pub struct Navigation {
model: Rc<NavigationModel>,
leaflet: libadwaita::Leaflet,
navigation_stack: gtk::Stack,
home_listbox: gtk::ListBox,
screen_factory: ScreenFactory,
children: Vec<Box<dyn ListenerComponent>>,
}
impl Navigation {
pub fn new(
model: NavigationModel,
leaflet: libadwaita::Leaflet,
navigation_stack: gtk::Stack,
home_listbox: gtk::ListBox,
screen_factory: ScreenFactory,
) -> Self {
let model = Rc::new(model);
leaflet.connect_folded_notify(
clone!(@weak model => move |leaflet| {
let is_main = leaflet.visible_child_name().map(|s| s.as_str() == "main").unwrap_or(false);
let folded = leaflet.is_folded();
model.set_nav_hidden(folded && is_main);
})
);
leaflet.connect_visible_child_name_notify(
clone!(@weak model => move |leaflet| {
let is_main = leaflet.visible_child_name().map(|s| s.as_str() == "main").unwrap_or(false);
let folded = leaflet.is_folded();
model.set_nav_hidden(folded && is_main);
})
);
Self {
model,
leaflet,
navigation_stack,
home_listbox,
screen_factory,
children: vec![],
}
}
fn make_home(&self) -> Box<dyn ListenerComponent> {
Box::new(HomePane::new(
self.home_listbox.clone(),
&self.screen_factory,
))
}
fn show_navigation(&self) {
self.leaflet.navigate(NavigationDirection::Back);
}
fn push_screen(&mut self, name: &ScreenName) {
let component: Box<dyn ListenerComponent> = match name {
ScreenName::Home => self.make_home(),
ScreenName::AlbumDetails(id) => {
Box::new(self.screen_factory.make_album_details(id.to_owned()))
}
ScreenName::Search => Box::new(self.screen_factory.make_search_results()),
ScreenName::Artist(id) => {
Box::new(self.screen_factory.make_artist_details(id.to_owned()))
}
ScreenName::PlaylistDetails(id) => {
Box::new(self.screen_factory.make_playlist_details(id.to_owned()))
}
ScreenName::User(id) => Box::new(self.screen_factory.make_user_details(id.to_owned())),
};
let widget = component.get_root_widget().clone();
self.children.push(component);
self.leaflet.navigate(NavigationDirection::Forward);
self.navigation_stack
.add_named(&widget, Some(name.identifier().as_ref()));
self.navigation_stack
.set_visible_child_name(name.identifier().as_ref());
glib::source::idle_add_local_once(move || {
widget.grab_focus();
});
}
fn pop(&mut self) {
let children = &mut self.children;
let popped = children.pop();
let name = self.model.visible_child_name();
self.navigation_stack
.set_visible_child_name(name.identifier().as_ref());
if let Some(child) = popped {
self.navigation_stack.remove(child.get_root_widget());
}
}
fn pop_to(&mut self, screen: &ScreenName) {
self.navigation_stack
.set_visible_child_name(screen.identifier().as_ref());
let remainder = self.children.split_off(self.model.children_count());
for widget in remainder {
self.navigation_stack.remove(widget.get_root_widget());
}
}
}
impl EventListener for Navigation {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::Started => {
self.push_screen(&ScreenName::Home);
}
AppEvent::BrowserEvent(BrowserEvent::NavigationPushed(name)) => {
self.push_screen(name);
}
AppEvent::BrowserEvent(BrowserEvent::NavigationHidden(false)) => {
self.show_navigation();
}
AppEvent::BrowserEvent(BrowserEvent::NavigationPopped) => {
self.pop();
}
AppEvent::BrowserEvent(BrowserEvent::NavigationPoppedTo(name)) => {
self.pop_to(name);
}
AppEvent::BrowserEvent(BrowserEvent::HomeVisiblePageChanged(_)) => {
self.leaflet.navigate(NavigationDirection::Forward);
}
_ => {}
};
for child in self.children.iter_mut() {
child.on_event(event);
}
}
}

View file

@ -0,0 +1,31 @@
use crate::app::state::ScreenName;
use crate::app::{ActionDispatcher, AppModel, BrowserAction};
use std::ops::Deref;
use std::rc::Rc;
pub struct NavigationModel {
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl NavigationModel {
pub fn new(app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
app_model,
dispatcher,
}
}
pub fn visible_child_name(&self) -> impl Deref<Target = ScreenName> + '_ {
self.app_model.map_state(|s| s.browser.current_screen())
}
pub fn set_nav_hidden(&self, hidden: bool) {
self.dispatcher
.dispatch(BrowserAction::SetNavigationHidden(hidden).into());
}
pub fn children_count(&self) -> usize {
self.app_model.get_state().browser.count()
}
}

View file

@ -0,0 +1,47 @@
use crate::app::components::EventListener;
use crate::app::AppEvent;
use gettextrs::*;
use glib::ToVariant;
pub struct Notification {
toast_overlay: libadwaita::ToastOverlay,
}
impl Notification {
pub fn new(toast_overlay: libadwaita::ToastOverlay) -> Self {
Self { toast_overlay }
}
fn show(&self, content: &str) {
let toast = libadwaita::Toast::builder()
.title(content)
.timeout(4)
.build();
self.toast_overlay.add_toast(toast);
}
fn show_playlist_created(&self, id: &str) {
// translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist.
let message = gettext("New playlist created.");
// translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened.
let label = gettext("View");
let toast = libadwaita::Toast::builder()
.title(message)
.timeout(4)
.action_name("app.open_playlist")
.button_label(label)
.action_target(&id.to_variant())
.build();
self.toast_overlay.add_toast(toast);
}
}
impl EventListener for Notification {
fn on_event(&mut self, event: &AppEvent) {
if let AppEvent::NotificationShown(content) = event {
self.show(content)
} else if let AppEvent::PlaylistCreatedNotificationShown(id) = event {
self.show_playlist_created(id)
}
}
}

View file

@ -0,0 +1,6 @@
#[allow(clippy::module_inception)]
mod now_playing;
pub use now_playing::*;
mod now_playing_model;
pub use now_playing_model::*;

View file

@ -0,0 +1,23 @@
using Gtk 4.0;
using Adw 1;
template $NowPlayingWidget : Box {
orientation: vertical;
vexpand: true;
hexpand: true;
$HeaderBarWidget headerbar {
$DeviceSelectorWidget device_selector {}
}
ScrolledWindow scrolled_window {
vexpand: true;
Adw.ClampScrollable {
maximum-size: 900;
ListView song_list {
}
}
}
}

View file

@ -0,0 +1,151 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use std::rc::Rc;
use super::NowPlayingModel;
use crate::app::components::{
Component, DeviceSelector, DeviceSelectorWidget, EventListener, HeaderBarComponent,
HeaderBarWidget, Playlist,
};
use crate::app::state::PlaybackEvent;
use crate::app::{AppEvent, Worker};
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/now_playing.ui")]
pub struct NowPlayingWidget {
#[template_child]
pub song_list: TemplateChild<gtk::ListView>,
#[template_child]
pub headerbar: TemplateChild<HeaderBarWidget>,
#[template_child]
pub device_selector: TemplateChild<DeviceSelectorWidget>,
#[template_child]
pub scrolled_window: TemplateChild<gtk::ScrolledWindow>,
}
#[glib::object_subclass]
impl ObjectSubclass for NowPlayingWidget {
const NAME: &'static str = "NowPlayingWidget";
type Type = super::NowPlayingWidget;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for NowPlayingWidget {
fn constructed(&self) {
self.parent_constructed();
}
}
impl WidgetImpl for NowPlayingWidget {}
impl BoxImpl for NowPlayingWidget {}
}
glib::wrapper! {
pub struct NowPlayingWidget(ObjectSubclass<imp::NowPlayingWidget>) @extends gtk::Widget, gtk::Box;
}
impl NowPlayingWidget {
fn new() -> Self {
glib::Object::new()
}
fn connect_bottom_edge<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp()
.scrolled_window
.connect_edge_reached(move |_, pos| {
if let gtk::PositionType::Bottom = pos {
f()
}
});
}
fn song_list_widget(&self) -> &gtk::ListView {
self.imp().song_list.as_ref()
}
fn headerbar_widget(&self) -> &HeaderBarWidget {
self.imp().headerbar.as_ref()
}
fn device_selector_widget(&self) -> &DeviceSelectorWidget {
self.imp().device_selector.as_ref()
}
}
pub struct NowPlaying {
widget: NowPlayingWidget,
model: Rc<NowPlayingModel>,
children: Vec<Box<dyn EventListener>>,
}
impl NowPlaying {
pub fn new(model: Rc<NowPlayingModel>, worker: Worker, leaflet: &libadwaita::Leaflet) -> Self {
let widget = NowPlayingWidget::new();
widget.connect_bottom_edge(clone!(@weak model => move || {
model.load_more();
}));
let playlist = Box::new(Playlist::new(
widget.song_list_widget().clone(),
model.clone(),
worker,
));
let headerbar_widget = widget.headerbar_widget();
headerbar_widget.bind_to_leaflet(leaflet);
let headerbar = Box::new(HeaderBarComponent::new(
headerbar_widget.clone(),
model.to_headerbar_model(),
));
let device_selector = Box::new(DeviceSelector::new(
widget.device_selector_widget().clone(),
model.device_selector_model(),
));
Self {
widget,
model,
children: vec![playlist, headerbar, device_selector],
}
}
}
impl Component for NowPlaying {
fn get_root_widget(&self) -> &gtk::Widget {
self.widget.upcast_ref()
}
fn get_children(&mut self) -> Option<&mut Vec<Box<dyn EventListener>>> {
Some(&mut self.children)
}
}
impl EventListener for NowPlaying {
fn on_event(&mut self, event: &AppEvent) {
if let AppEvent::PlaybackEvent(PlaybackEvent::TrackChanged(_)) = event {
self.model.load_more();
}
self.broadcast_event(event);
}
}

View file

@ -0,0 +1,174 @@
use gio::prelude::*;
use gio::SimpleActionGroup;
use std::ops::Deref;
use std::rc::Rc;
use crate::app::components::{
labels, DeviceSelectorModel, HeaderBarModel, PlaylistModel, SimpleHeaderBarModel,
SimpleHeaderBarModelWrapper,
};
use crate::app::models::{SongDescription, SongListModel};
use crate::app::state::Device;
use crate::app::state::{
PlaybackAction, PlaybackState, SelectionAction, SelectionContext, SelectionState,
};
use crate::app::{ActionDispatcher, AppAction, AppEvent, AppModel};
pub struct NowPlayingModel {
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl NowPlayingModel {
pub fn new(app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
app_model,
dispatcher,
}
}
fn queue(&self) -> impl Deref<Target = PlaybackState> + '_ {
self.app_model.map_state(|s| &s.playback)
}
pub fn load_more(&self) -> Option<()> {
let queue = self.queue();
let loader = self.app_model.get_batch_loader();
let query = queue.next_query()?;
debug!("next_query = {:?}", &query);
self.dispatcher.dispatch_async(Box::pin(async move {
loader
.query(query, |source, song_batch| {
PlaybackAction::LoadPagedSongs(source, song_batch).into()
})
.await
}));
Some(())
}
pub fn to_headerbar_model(self: &Rc<Self>) -> Rc<impl HeaderBarModel> {
Rc::new(SimpleHeaderBarModelWrapper::new(
self.clone(),
self.app_model.clone(),
self.dispatcher.box_clone(),
))
}
pub fn device_selector_model(&self) -> DeviceSelectorModel {
DeviceSelectorModel::new(self.app_model.clone(), self.dispatcher.box_clone())
}
fn current_selection_context(&self) -> SelectionContext {
let state = self.app_model.get_state();
match state.playback.current_device() {
Device::Local => SelectionContext::Queue,
Device::Connect(_) => SelectionContext::ReadOnlyQueue,
}
}
}
impl PlaylistModel for NowPlayingModel {
fn song_list_model(&self) -> SongListModel {
self.queue().songs().clone()
}
fn is_paused(&self) -> bool {
!self.app_model.get_state().playback.is_playing()
}
fn current_song_id(&self) -> Option<String> {
self.queue().current_song_id()
}
fn play_song_at(&self, _pos: usize, id: &str) {
self.dispatcher
.dispatch(PlaybackAction::Load(id.to_string()).into());
}
fn autoscroll_to_playing(&self) -> bool {
false // too buggy for now
}
fn actions_for(&self, id: &str) -> Option<gio::ActionGroup> {
let queue = self.queue();
let song = queue.songs().get(id)?;
let song = song.description();
let group = SimpleActionGroup::new();
for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) {
group.add_action(&view_artist);
}
group.add_action(&song.make_album_action(self.dispatcher.box_clone(), None));
group.add_action(&song.make_link_action(None));
group.add_action(&song.make_dequeue_action(self.dispatcher.box_clone(), None));
Some(group.upcast())
}
fn menu_for(&self, id: &str) -> Option<gio::MenuModel> {
let queue = self.queue();
let song = queue.songs().get(id)?;
let song = song.description();
let menu = gio::Menu::new();
menu.append(Some(&*labels::VIEW_ALBUM), Some("song.view_album"));
for artist in song.artists.iter() {
menu.append(
Some(&labels::more_from_label(&artist.name)),
Some(&format!("song.view_artist_{}", artist.id)),
);
}
menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link"));
menu.append(Some(&*labels::REMOVE_FROM_QUEUE), Some("song.dequeue"));
Some(menu.upcast())
}
fn select_song(&self, id: &str) {
let queue = self.queue();
if let Some(song) = queue.songs().get(id) {
let song = song.description().clone();
self.dispatcher
.dispatch(SelectionAction::Select(vec![song]).into());
}
}
fn deselect_song(&self, id: &str) {
self.dispatcher
.dispatch(SelectionAction::Deselect(vec![id.to_string()]).into());
}
fn enable_selection(&self) -> bool {
self.dispatcher
.dispatch(AppAction::EnableSelection(self.current_selection_context()));
true
}
fn selection(&self) -> Option<Box<dyn Deref<Target = SelectionState> + '_>> {
let selection = self.app_model.map_state(|s| &s.selection);
Some(Box::new(selection))
}
}
impl SimpleHeaderBarModel for NowPlayingModel {
fn title(&self) -> Option<String> {
None
}
fn title_updated(&self, _: &AppEvent) -> bool {
false
}
fn selection_context(&self) -> Option<SelectionContext> {
Some(self.current_selection_context())
}
fn select_all(&self) {
let songs: Vec<SongDescription> = self.queue().songs().collect();
self.dispatcher
.dispatch(SelectionAction::Select(songs).into());
}
}

View file

@ -0,0 +1,162 @@
use std::ops::Deref;
use std::rc::Rc;
use crate::app::components::EventListener;
use crate::app::models::*;
use crate::app::state::{PlaybackAction, PlaybackEvent, ScreenName, SelectionEvent};
use crate::app::{
ActionDispatcher, AppAction, AppEvent, AppModel, AppState, BrowserAction, Worker,
};
use super::playback_widget::PlaybackWidget;
pub struct PlaybackModel {
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl PlaybackModel {
pub fn new(app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
app_model,
dispatcher,
}
}
fn state(&self) -> impl Deref<Target = AppState> + '_ {
self.app_model.get_state()
}
fn go_home(&self) {
self.dispatcher.dispatch(AppAction::ViewNowPlaying);
self.dispatcher
.dispatch(BrowserAction::NavigationPopTo(ScreenName::Home).into());
}
fn is_playing(&self) -> bool {
self.state().playback.is_playing()
}
fn is_shuffled(&self) -> bool {
self.state().playback.is_shuffled()
}
fn current_song(&self) -> Option<SongDescription> {
self.app_model.get_state().playback.current_song()
}
fn play_next_song(&self) {
self.dispatcher.dispatch(PlaybackAction::Next.into());
}
fn play_prev_song(&self) {
self.dispatcher.dispatch(PlaybackAction::Previous.into());
}
fn toggle_playback(&self) {
self.dispatcher.dispatch(PlaybackAction::TogglePlay.into());
}
fn toggle_shuffle(&self) {
self.dispatcher
.dispatch(PlaybackAction::ToggleShuffle.into());
}
fn toggle_repeat(&self) {
self.dispatcher
.dispatch(PlaybackAction::ToggleRepeat.into());
}
fn seek_to(&self, position: u32) {
self.dispatcher
.dispatch(PlaybackAction::Seek(position).into());
}
}
pub struct PlaybackControl {
model: Rc<PlaybackModel>,
widget: PlaybackWidget,
worker: Worker,
}
impl PlaybackControl {
pub fn new(model: PlaybackModel, widget: PlaybackWidget, worker: Worker) -> Self {
let model = Rc::new(model);
widget.connect_play_pause(clone!(@weak model => move || model.toggle_playback() ));
widget.connect_next(clone!(@weak model => move || model.play_next_song()));
widget.connect_prev(clone!(@weak model => move || model.play_prev_song()));
widget.connect_shuffle(clone!(@weak model => move || model.toggle_shuffle()));
widget.connect_repeat(clone!(@weak model => move || model.toggle_repeat()));
widget.connect_seek(clone!(@weak model => move |position| model.seek_to(position)));
widget.connect_now_playing_clicked(clone!(@weak model => move || model.go_home()));
Self {
model,
widget,
worker,
}
}
fn update_repeat(&self, mode: &RepeatMode) {
self.widget.set_repeat_mode(*mode);
}
fn update_shuffled(&self) {
self.widget.set_shuffled(self.model.is_shuffled());
}
fn update_playing(&self) {
let is_playing = self.model.is_playing();
self.widget.set_playing(is_playing);
}
fn update_current_info(&self) {
if let Some(song) = self.model.current_song() {
self.widget
.set_title_and_artist(&song.title, &song.artists_name());
self.widget.set_song_duration(Some(song.duration as f64));
if let Some(url) = song.art {
self.widget.set_artwork_from_url(url, &self.worker);
}
} else {
self.widget.reset_info();
}
}
fn sync_seek(&self, pos: u32) {
self.widget.set_seek_position(pos as f64);
}
}
impl EventListener for PlaybackControl {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::PlaybackEvent(PlaybackEvent::PlaybackPaused)
| AppEvent::PlaybackEvent(PlaybackEvent::PlaybackResumed) => {
self.update_playing();
}
AppEvent::PlaybackEvent(PlaybackEvent::RepeatModeChanged(mode)) => {
self.update_repeat(mode);
}
AppEvent::PlaybackEvent(PlaybackEvent::ShuffleChanged(_)) => {
self.update_shuffled();
}
AppEvent::PlaybackEvent(PlaybackEvent::TrackChanged(_)) => {
self.update_current_info();
}
AppEvent::PlaybackEvent(PlaybackEvent::PlaybackStopped) => {
self.update_playing();
self.update_current_info();
}
AppEvent::PlaybackEvent(PlaybackEvent::SeekSynced(pos))
| AppEvent::PlaybackEvent(PlaybackEvent::TrackSeeked(pos)) => {
self.sync_seek(*pos);
}
AppEvent::SelectionEvent(SelectionEvent::SelectionModeChanged(active)) => {
self.widget.set_seekbar_visible(!active);
}
_ => {}
}
}
}

View file

@ -0,0 +1,11 @@
mod component;
mod playback_controls;
mod playback_info;
mod playback_widget;
pub use component::*;
use glib::prelude::*;
pub fn expose_widgets() {
playback_widget::PlaybackWidget::static_type();
}

View file

@ -0,0 +1,31 @@
.seek-bar {
padding: 0;
padding-bottom: 2px;
min-height: 1px;
}
.seek-bar trough, .seek-bar highlight {
border-radius: 0;
border-left: none;
border-right: none;
min-height: 1px;
transition: min-height 100ms ease;
}
.seek-bar--active trough, .seek-bar--active highlight {
min-height: 5px;
}
.seek-bar--active:hover trough, .seek-bar--active:hover highlight {
min-height: 10px;
}
.seek-bar highlight {
border-left: none;
border-right: none;
}
.playback-button {
min-width: 40px;
min-height: 40px;
}

View file

@ -0,0 +1,57 @@
using Gtk 4.0;
template $PlaybackControlsWidget : Box {
halign: center;
hexpand: true;
spacing: 8;
homogeneous: true;
ToggleButton shuffle {
receives-default: true;
halign: center;
valign: center;
has-frame: false;
icon-name: "media-playlist-shuffle-symbolic";
tooltip-text: _("Shuffle");
}
Button prev {
receives-default: true;
halign: center;
valign: center;
has-frame: false;
icon-name: "media-skip-backward-symbolic";
tooltip-text: _("Previous");
}
Button play_pause {
receives-default: true;
halign: center;
valign: center;
icon-name: "media-playback-start-symbolic";
tooltip-text: "Play/Pause";
styles [
"circular",
"playback-button",
]
}
Button next {
receives-default: true;
halign: center;
valign: center;
has-frame: false;
icon-name: "media-skip-forward-symbolic";
tooltip-text: _("Next");
}
Button repeat {
receives-default: true;
halign: center;
valign: center;
has-frame: false;
icon-name: "media-playlist-consecutive-symbolic";
tooltip-text: _("Repeat");
}
}

View file

@ -0,0 +1,123 @@
use gettextrs::gettext;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::{glib, CompositeTemplate};
use crate::app::models::RepeatMode;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/playback_controls.ui")]
pub struct PlaybackControlsWidget {
#[template_child]
pub play_pause: TemplateChild<gtk::Button>,
#[template_child]
pub next: TemplateChild<gtk::Button>,
#[template_child]
pub prev: TemplateChild<gtk::Button>,
#[template_child]
pub shuffle: TemplateChild<gtk::ToggleButton>,
#[template_child]
pub repeat: TemplateChild<gtk::Button>,
}
#[glib::object_subclass]
impl ObjectSubclass for PlaybackControlsWidget {
const NAME: &'static str = "PlaybackControlsWidget";
type Type = super::PlaybackControlsWidget;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for PlaybackControlsWidget {}
impl WidgetImpl for PlaybackControlsWidget {}
impl BoxImpl for PlaybackControlsWidget {}
}
glib::wrapper! {
pub struct PlaybackControlsWidget(ObjectSubclass<imp::PlaybackControlsWidget>) @extends gtk::Widget, gtk::Box;
}
impl PlaybackControlsWidget {
pub fn set_playing(&self, is_playing: bool) {
let playback_icon = if is_playing {
"media-playback-pause-symbolic"
} else {
"media-playback-start-symbolic"
};
let translated_tooltip = if is_playing {
gettext("Pause")
} else {
gettext("Play")
};
let tooltip_text = Some(translated_tooltip.as_str());
let playback_control = self.imp();
playback_control.play_pause.set_icon_name(playback_icon);
playback_control.play_pause.set_tooltip_text(tooltip_text);
}
pub fn set_shuffled(&self, shuffled: bool) {
self.imp().shuffle.set_active(shuffled);
}
pub fn set_repeat_mode(&self, mode: RepeatMode) {
let repeat_mode_icon = match mode {
RepeatMode::Song => "media-playlist-repeat-song-symbolic",
RepeatMode::Playlist => "media-playlist-repeat-symbolic",
RepeatMode::None => "media-playlist-consecutive-symbolic",
};
self.imp().repeat.set_icon_name(repeat_mode_icon);
}
pub fn connect_play_pause<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().play_pause.connect_clicked(move |_| f());
}
pub fn connect_prev<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().prev.connect_clicked(move |_| f());
}
pub fn connect_next<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().next.connect_clicked(move |_| f());
}
pub fn connect_shuffle<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().shuffle.connect_clicked(move |_| f());
}
pub fn connect_repeat<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().repeat.connect_clicked(move |_| f());
}
}

View file

@ -0,0 +1,43 @@
using Gtk 4.0;
template $PlaybackInfoWidget : Button {
receives-default: true;
halign: start;
valign: center;
has-frame: false;
layout {
column-span: "1";
column: "0";
row: "0";
}
Box {
halign: center;
Image playing_image {
width-request: 40;
height-request: 40;
icon-name: "emblem-music-symbolic";
}
Label current_song_info {
visible: false;
halign: start;
hexpand: true;
margin-start: 12;
margin-end: 12;
/* Translators: Short text displayed instead of a song title when nothing plays */
label: _("No song playing");
use-markup: true;
ellipsize: middle;
lines: 1;
}
}
styles [
"body",
]
}

View file

@ -0,0 +1,74 @@
use gettextrs::gettext;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::{glib, CompositeTemplate};
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/playback_info.ui")]
pub struct PlaybackInfoWidget {
#[template_child]
pub playing_image: TemplateChild<gtk::Image>,
#[template_child]
pub current_song_info: TemplateChild<gtk::Label>,
}
#[glib::object_subclass]
impl ObjectSubclass for PlaybackInfoWidget {
const NAME: &'static str = "PlaybackInfoWidget";
type Type = super::PlaybackInfoWidget;
type ParentType = gtk::Button;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for PlaybackInfoWidget {}
impl WidgetImpl for PlaybackInfoWidget {}
impl ButtonImpl for PlaybackInfoWidget {}
}
glib::wrapper! {
pub struct PlaybackInfoWidget(ObjectSubclass<imp::PlaybackInfoWidget>) @extends gtk::Widget, gtk::Button;
}
impl PlaybackInfoWidget {
pub fn set_title_and_artist(&self, title: &str, artist: &str) {
let widget = self.imp();
let title = glib::markup_escape_text(title);
let artist = glib::markup_escape_text(artist);
let label = format!("<b>{}</b>\n{}", title.as_str(), artist.as_str());
widget.current_song_info.set_label(&label[..]);
}
pub fn reset_info(&self) {
let widget = self.imp();
widget
.current_song_info
// translators: Short text displayed instead of a song title when nothing plays
.set_label(&gettext("No song playing"));
widget
.playing_image
.set_from_icon_name(Some("emblem-music-symbolic"));
widget
.playing_image
.set_from_icon_name(Some("emblem-music-symbolic"));
}
pub fn set_info_visible(&self, visible: bool) {
self.imp().current_song_info.set_visible(visible);
}
pub fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) {
self.imp().playing_image.set_from_pixbuf(Some(art));
}
}

View file

@ -0,0 +1,101 @@
using Gtk 4.0;
using Adw 1;
template $PlaybackWidget : Box {
orientation: vertical;
Scale seek_bar {
show-fill-level: true;
restrict-to-fill-level: false;
fill-level: 0;
digits: 0;
value-pos: left;
styles [
"seek-bar",
]
}
Adw.Squeezer {
margin-top: 4;
margin-bottom: 4;
margin-start: 8;
margin-end: 8;
Grid {
hexpand: true;
column-homogeneous: true;
$PlaybackInfoWidget now_playing {
receives-default: "1";
halign: "start";
valign: "center";
has-frame: "0";
layout {
column-span: "1";
column: "0";
row: "0";
}
}
$PlaybackControlsWidget controls {
layout {
column-span: "1";
column: "1";
row: "0";
}
}
Box {
margin-top: 4;
margin-bottom: 4;
margin-start: 4;
margin-end: 4;
layout {
column-span: "1";
column: "2";
row: "0";
}
Label track_position {
sensitive: false;
label: "000";
halign: end;
hexpand: true;
styles [
"numeric",
]
}
Label track_duration {
sensitive: false;
label: " / 000";
halign: end;
styles [
"numeric",
]
}
}
}
Box {
halign: fill;
hexpand: true;
$PlaybackInfoWidget now_playing_mobile {
receives-default: "1";
halign: "start";
valign: "center";
has-frame: "0";
}
$PlaybackControlsWidget controls_mobile {
halign: "center";
}
}
}
}

View file

@ -0,0 +1,242 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::{glib, CompositeTemplate};
use crate::app::components::display_add_css_provider;
use crate::app::components::utils::{format_duration, Clock, Debouncer};
use crate::app::loader::ImageLoader;
use crate::app::models::RepeatMode;
use crate::app::Worker;
use super::playback_controls::PlaybackControlsWidget;
use super::playback_info::PlaybackInfoWidget;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/playback_widget.ui")]
pub struct PlaybackWidget {
#[template_child]
pub controls: TemplateChild<PlaybackControlsWidget>,
#[template_child]
pub controls_mobile: TemplateChild<PlaybackControlsWidget>,
#[template_child]
pub now_playing: TemplateChild<PlaybackInfoWidget>,
#[template_child]
pub now_playing_mobile: TemplateChild<PlaybackInfoWidget>,
#[template_child]
pub seek_bar: TemplateChild<gtk::Scale>,
#[template_child]
pub track_position: TemplateChild<gtk::Label>,
#[template_child]
pub track_duration: TemplateChild<gtk::Label>,
pub clock: Clock,
}
#[glib::object_subclass]
impl ObjectSubclass for PlaybackWidget {
const NAME: &'static str = "PlaybackWidget";
type Type = super::PlaybackWidget;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for PlaybackWidget {
fn constructed(&self) {
self.parent_constructed();
self.now_playing_mobile.set_info_visible(false);
self.now_playing.set_info_visible(true);
display_add_css_provider(resource!("/components/playback.css"));
}
}
impl WidgetImpl for PlaybackWidget {}
impl BoxImpl for PlaybackWidget {}
}
glib::wrapper! {
pub struct PlaybackWidget(ObjectSubclass<imp::PlaybackWidget>) @extends gtk::Widget, gtk::Box;
}
impl PlaybackWidget {
pub fn set_title_and_artist(&self, title: &str, artist: &str) {
let widget = self.imp();
widget.now_playing.set_title_and_artist(title, artist);
}
pub fn reset_info(&self) {
let widget = self.imp();
widget.now_playing.reset_info();
widget.now_playing_mobile.reset_info();
self.set_song_duration(None);
}
fn set_artwork(&self, image: &gdk_pixbuf::Pixbuf) {
let widget = self.imp();
widget.now_playing.set_artwork(image);
widget.now_playing_mobile.set_artwork(image);
}
pub fn set_artwork_from_url(&self, url: String, worker: &Worker) {
let weak_self = self.downgrade();
worker.send_local_task(async move {
let loader = ImageLoader::new();
let result = loader.load_remote(&url, "jpg", 48, 48).await;
if let (Some(ref _self), Some(ref result)) = (weak_self.upgrade(), result) {
_self.set_artwork(result);
}
});
}
pub fn set_song_duration(&self, duration: Option<f64>) {
let widget = self.imp();
let class = "seek-bar--active";
if let Some(duration) = duration {
self.add_css_class(class);
widget.seek_bar.set_range(0.0, duration);
widget.seek_bar.set_value(0.0);
widget.track_position.set_text("000");
widget
.track_duration
.set_text(&format!(" / {}", format_duration(duration)));
widget.track_position.set_visible(true);
widget.track_duration.set_visible(true);
} else {
self.remove_css_class(class);
widget.seek_bar.set_range(0.0, 0.0);
widget.track_position.set_visible(false);
widget.track_duration.set_visible(false);
}
}
pub fn set_seek_position(&self, pos: f64) {
let widget = self.imp();
widget.seek_bar.set_value(pos);
widget.track_position.set_text(&format_duration(pos));
}
pub fn increment_seek_position(&self) {
let value = self.imp().seek_bar.value() + 1_000.0;
self.set_seek_position(value);
}
pub fn connect_now_playing_clicked<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
let widget = self.imp();
let f_clone = f.clone();
widget.now_playing.connect_clicked(move |_| f_clone());
widget.now_playing_mobile.connect_clicked(move |_| f());
}
pub fn connect_seek<Seek>(&self, seek: Seek)
where
Seek: Fn(u32) + Clone + 'static,
{
let debouncer = Debouncer::new();
let widget = self.imp();
widget.seek_bar.set_increments(5_000.0, 10_000.0);
widget.seek_bar.connect_change_value(
clone!(@weak self as _self => @default-return glib::signal::Inhibit(false), move |_, _, requested| {
_self.imp()
.track_position
.set_text(&format_duration(requested));
let seek = seek.clone();
debouncer.debounce(200, move || seek(requested as u32));
glib::signal::Inhibit(false)
}),
);
}
pub fn set_playing(&self, is_playing: bool) {
let widget = self.imp();
widget.controls.set_playing(is_playing);
widget.controls_mobile.set_playing(is_playing);
if is_playing {
widget
.clock
.start(clone!(@weak self as _self => move || _self.increment_seek_position()));
} else {
widget.clock.stop();
}
}
pub fn set_repeat_mode(&self, mode: RepeatMode) {
let widget = self.imp();
widget.controls.set_repeat_mode(mode);
widget.controls_mobile.set_repeat_mode(mode);
}
pub fn set_shuffled(&self, shuffled: bool) {
let widget = self.imp();
widget.controls.set_shuffled(shuffled);
widget.controls_mobile.set_shuffled(shuffled);
}
pub fn set_seekbar_visible(&self, visible: bool) {
let widget = self.imp();
widget.seek_bar.set_visible(visible);
}
pub fn connect_play_pause<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
let widget = self.imp();
widget.controls.connect_play_pause(f.clone());
widget.controls_mobile.connect_play_pause(f);
}
pub fn connect_prev<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
let widget = self.imp();
widget.controls.connect_prev(f.clone());
widget.controls_mobile.connect_prev(f);
}
pub fn connect_next<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
let widget = self.imp();
widget.controls.connect_next(f.clone());
widget.controls_mobile.connect_next(f);
}
pub fn connect_shuffle<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
let widget = self.imp();
widget.controls.connect_shuffle(f.clone());
widget.controls_mobile.connect_shuffle(f);
}
pub fn connect_repeat<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
let widget = self.imp();
widget.controls.connect_repeat(f.clone());
widget.controls_mobile.connect_repeat(f);
}
}

View file

@ -0,0 +1,239 @@
use std::ops::Deref;
use std::rc::Rc;
use futures::channel::mpsc::UnboundedSender;
use librespot::core::spotify_id::{SpotifyId, SpotifyItemType};
use crate::app::components::EventListener;
use crate::app::state::{
Device, LoginAction, LoginEvent, LoginStartedEvent, PlaybackEvent, SettingsEvent,
};
use crate::app::{ActionDispatcher, AppAction, AppEvent, AppModel, SongsSource};
use crate::connect::ConnectCommand;
use crate::player::Command;
enum CurrentlyPlaying {
WithSource {
source: SongsSource,
offset: usize,
song: String,
},
Songs {
songs: Vec<String>,
offset: usize,
},
}
impl CurrentlyPlaying {
fn song_id(&self) -> &String {
match self {
Self::WithSource { song, .. } => song,
Self::Songs { songs, offset } => &songs[*offset],
}
}
}
pub struct PlayerNotifier {
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
command_sender: UnboundedSender<Command>,
connect_command_sender: UnboundedSender<ConnectCommand>,
}
impl PlayerNotifier {
pub fn new(
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
command_sender: UnboundedSender<Command>,
connect_command_sender: UnboundedSender<ConnectCommand>,
) -> Self {
Self {
app_model,
dispatcher,
command_sender,
connect_command_sender,
}
}
fn is_playing(&self) -> bool {
self.app_model.get_state().playback.is_playing()
}
fn currently_playing(&self) -> Option<CurrentlyPlaying> {
let state = self.app_model.get_state();
let song = state.playback.current_song_id()?;
let offset = state.playback.current_song_index()?;
let source = state.playback.current_source().cloned();
let result = match source {
Some(source) if source.has_spotify_uri() => CurrentlyPlaying::WithSource {
source,
offset,
song,
},
_ => CurrentlyPlaying::Songs {
songs: state.playback.songs().map_collect(|s| s.id),
offset,
},
};
Some(result)
}
fn device(&self) -> impl Deref<Target = Device> + '_ {
self.app_model.map_state(|s| s.playback.current_device())
}
fn notify_login(&self, event: &LoginEvent) {
info!("notify_login: {:?}", event);
let command = match event {
LoginEvent::LoginStarted(LoginStartedEvent::Password { username, password }) => {
Some(Command::PasswordLogin {
username: username.to_owned(),
password: password.to_owned(),
})
}
LoginEvent::LoginStarted(LoginStartedEvent::Token { username, token }) => {
Some(Command::TokenLogin {
username: username.to_owned(),
token: token.to_owned(),
})
}
LoginEvent::LoginStarted(LoginStartedEvent::OAuthSpotify {}) => {
Some(Command::OAuthLogin)
}
LoginEvent::FreshTokenRequested => Some(Command::RefreshToken),
LoginEvent::LogoutCompleted => Some(Command::Logout),
_ => None,
};
if let Some(command) = command {
self.send_command_to_local_player(command);
}
}
fn notify_connect_player(&self, event: &PlaybackEvent) {
let event = event.clone();
let currently_playing = self.currently_playing();
let command = match event {
PlaybackEvent::TrackChanged(_) | PlaybackEvent::SourceChanged => {
match currently_playing {
Some(CurrentlyPlaying::WithSource {
source,
offset,
song,
}) => Some(ConnectCommand::PlayerLoadInContext {
source,
offset,
song,
}),
Some(CurrentlyPlaying::Songs { songs, offset }) => {
Some(ConnectCommand::PlayerLoad { songs, offset })
}
None => None,
}
}
PlaybackEvent::TrackSeeked(position) => {
Some(ConnectCommand::PlayerSeek(position as usize))
}
PlaybackEvent::PlaybackPaused => Some(ConnectCommand::PlayerPause),
PlaybackEvent::PlaybackResumed => Some(ConnectCommand::PlayerResume),
PlaybackEvent::VolumeSet(volume) => Some(ConnectCommand::PlayerSetVolume(
(volume * 100f64).trunc() as u8,
)),
PlaybackEvent::RepeatModeChanged(mode) => Some(ConnectCommand::PlayerRepeat(mode)),
PlaybackEvent::ShuffleChanged(shuffled) => {
Some(ConnectCommand::PlayerShuffle(shuffled))
}
_ => None,
};
if let Some(command) = command {
self.send_command_to_connect_player(command);
}
}
fn notify_local_player(&self, event: &PlaybackEvent) {
let command = match event {
PlaybackEvent::PlaybackPaused => Some(Command::PlayerPause),
PlaybackEvent::PlaybackResumed => Some(Command::PlayerResume),
PlaybackEvent::PlaybackStopped => Some(Command::PlayerStop),
PlaybackEvent::VolumeSet(volume) => Some(Command::PlayerSetVolume(*volume)),
PlaybackEvent::TrackChanged(id) => {
info!("track changed: {}", id);
SpotifyId::from_base62(id).ok().map(|mut track| {
track.item_type = SpotifyItemType::Track;
Command::PlayerLoad {
track,
resume: true,
}
})
}
PlaybackEvent::SourceChanged => {
let resume = self.is_playing();
self.currently_playing()
.and_then(|c| SpotifyId::from_base62(c.song_id()).ok())
.map(|mut track| {
track.item_type = SpotifyItemType::Track;
Command::PlayerLoad { track, resume }
})
}
PlaybackEvent::TrackSeeked(position) => Some(Command::PlayerSeek(*position)),
PlaybackEvent::Preload(id) => SpotifyId::from_base62(id)
.ok()
.map(|mut track| {
track.item_type = SpotifyItemType::Track;
track
})
.map(Command::PlayerPreload),
_ => None,
};
if let Some(command) = command {
self.send_command_to_local_player(command);
}
}
fn send_command_to_connect_player(&self, command: ConnectCommand) {
self.connect_command_sender.unbounded_send(command).unwrap();
}
fn send_command_to_local_player(&self, command: Command) {
let dispatcher = &self.dispatcher;
self.command_sender
.unbounded_send(command)
.unwrap_or_else(|_| {
dispatcher.dispatch(AppAction::LoginAction(LoginAction::SetLoginFailure));
});
}
fn switch_device(&mut self, device: &Device) {
match device {
Device::Connect(device) => {
self.send_command_to_local_player(Command::PlayerStop);
self.send_command_to_connect_player(ConnectCommand::SetDevice(device.id.clone()));
self.notify_connect_player(&PlaybackEvent::SourceChanged);
}
Device::Local => {
self.send_command_to_connect_player(ConnectCommand::PlayerStop);
self.notify_local_player(&PlaybackEvent::SourceChanged);
}
}
}
}
impl EventListener for PlayerNotifier {
fn on_event(&mut self, event: &AppEvent) {
let device = self.device().clone();
match (device, event) {
(_, AppEvent::LoginEvent(event)) => self.notify_login(event),
(_, AppEvent::PlaybackEvent(PlaybackEvent::SwitchedDevice(d))) => self.switch_device(d),
(Device::Local, AppEvent::PlaybackEvent(event)) => self.notify_local_player(event),
(Device::Local, AppEvent::SettingsEvent(SettingsEvent::PlayerSettingsChanged)) => {
self.send_command_to_local_player(Command::ReloadSettings)
}
(Device::Connect(_), AppEvent::PlaybackEvent(event)) => {
self.notify_connect_player(event)
}
_ => {}
}
}
}

View file

@ -0,0 +1,8 @@
#[allow(clippy::module_inception)]
mod playlist;
pub use playlist::*;
mod song;
pub use song::*;
mod song_actions;

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="9" width="4" height="5" fill="#2E3436"/>
<rect x="6" y="12" width="4" height="2" fill="#2E3436"/>
<rect x="11" y="12" width="4" height="2" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="6" width="4" height="8" fill="#2E3436"/>
<rect x="6" y="9" width="4" height="5" fill="#2E3436"/>
<rect x="11" y="12" width="4" height="2" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="8" width="4" height="6" fill="#2E3436"/>
<rect x="6" y="5" width="4" height="9" fill="#2E3436"/>
<rect x="11" y="5" width="4" height="9" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="8" width="4" height="6" fill="#2E3436"/>
<rect x="6" y="3" width="4" height="11" fill="#2E3436"/>
<rect x="11" y="5" width="4" height="9" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="7" width="4" height="7" fill="#2E3436"/>
<rect x="6" y="6" width="4" height="8" fill="#2E3436"/>
<rect x="11" y="7" width="4" height="7" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="3" width="4" height="11" fill="#2E3436"/>
<rect x="6" y="6" width="4" height="8" fill="#2E3436"/>
<rect x="11" y="8" width="4" height="6" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="5" width="4" height="9" fill="#2E3436"/>
<rect x="6" y="6" width="4" height="8" fill="#2E3436"/>
<rect x="11" y="5" width="4" height="9" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="8" width="4" height="6" fill="#2E3436"/>
<rect x="6" y="6" width="4" height="8" fill="#2E3436"/>
<rect x="11" y="8" width="4" height="6" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="11" width="4" height="3" fill="#2E3436"/>
<rect x="6" y="8" width="4" height="6" fill="#2E3436"/>
<rect x="11" y="11" width="4" height="3" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="9" width="4" height="5" fill="#2E3436"/>
<rect x="6" y="6" width="4" height="8" fill="#2E3436"/>
<rect x="11" y="9" width="4" height="5" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="8" width="4" height="6" fill="#2E3436"/>
<rect x="6" y="5" width="4" height="9" fill="#2E3436"/>
<rect x="11" y="8" width="4" height="6" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="6" width="4" height="8" fill="#2E3436"/>
<rect x="6" y="8" width="4" height="6" fill="#2E3436"/>
<rect x="11" y="6" width="4" height="8" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="4" width="4" height="10" fill="#2E3436"/>
<rect x="6" y="9" width="4" height="5" fill="#2E3436"/>
<rect x="11" y="6" width="4" height="8" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="5" width="4" height="9" fill="#2E3436"/>
<rect x="6" y="11" width="4" height="3" fill="#2E3436"/>
<rect x="11" y="5" width="4" height="9" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="7" width="4" height="7" fill="#2E3436"/>
<rect x="6" y="9" width="4" height="5" fill="#2E3436"/>
<rect x="11" y="5" width="4" height="9" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="7" width="4" height="7" fill="#2E3436"/>
<rect x="6" y="7" width="4" height="7" fill="#2E3436"/>
<rect x="11" y="4" width="4" height="10" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="10" width="4" height="4" fill="#2E3436"/>
<rect x="6" y="5" width="4" height="9" fill="#2E3436"/>
<rect x="11" y="2" width="4" height="12" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="12" width="4" height="2" fill="#2E3436"/>
<rect x="6" y="12" width="4" height="2" fill="#2E3436"/>
<rect x="11" y="12" width="4" height="2" fill="#2E3436"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

View file

@ -0,0 +1,248 @@
use gio::prelude::*;
use gtk::prelude::*;
use std::ops::Deref;
use std::rc::Rc;
use crate::app::components::utils::{ancestor, AnimatorDefault};
use crate::app::components::{Component, EventListener, SongWidget};
use crate::app::models::{SongListModel, SongModel, SongState};
use crate::app::state::{PlaybackEvent, SelectionEvent, SelectionState};
use crate::app::{AppEvent, Worker};
pub trait PlaylistModel {
fn is_paused(&self) -> bool;
fn song_list_model(&self) -> SongListModel;
fn current_song_id(&self) -> Option<String>;
fn play_song_at(&self, pos: usize, id: &str);
fn autoscroll_to_playing(&self) -> bool {
true
}
fn show_song_covers(&self) -> bool {
true
}
fn actions_for(&self, _id: &str) -> Option<gio::ActionGroup> {
None
}
fn menu_for(&self, _id: &str) -> Option<gio::MenuModel> {
None
}
fn select_song(&self, _id: &str) {}
fn deselect_song(&self, _id: &str) {}
fn enable_selection(&self) -> bool {
false
}
fn selection(&self) -> Option<Box<dyn Deref<Target = SelectionState> + '_>> {
None
}
fn is_selection_enabled(&self) -> bool {
self.selection()
.map(|s| s.is_selection_enabled())
.unwrap_or(false)
}
fn song_state(&self, id: &str) -> SongState {
let is_playing = self.current_song_id().map(|s| s.eq(id)).unwrap_or(false);
let is_selected = self
.selection()
.map(|s| s.is_song_selected(id))
.unwrap_or(false);
SongState {
is_selected,
is_playing,
}
}
fn toggle_select(&self, id: &str) {
if let Some(selection) = self.selection() {
if selection.is_song_selected(id) {
self.deselect_song(id);
} else {
self.select_song(id);
}
}
}
}
pub struct Playlist<Model> {
animator: AnimatorDefault,
listview: gtk::ListView,
model: Rc<Model>,
}
impl<Model> Playlist<Model>
where
Model: PlaylistModel + 'static,
{
pub fn new(listview: gtk::ListView, model: Rc<Model>, worker: Worker) -> Self {
let list_model = model.song_list_model();
let selection_model = gtk::NoSelection::new(Some(list_model.clone()));
let factory = gtk::SignalListItemFactory::new();
listview.add_css_class("playlist");
listview.set_show_separators(true);
listview.set_valign(gtk::Align::Start);
listview.set_factory(Some(&factory));
listview.set_single_click_activate(true);
listview.set_model(Some(&selection_model));
Self::set_paused(&listview, model.is_paused());
Self::set_selection_active(&listview, model.is_selection_enabled());
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
item.set_child(Some(&SongWidget::new()));
});
factory.connect_bind(clone!(@weak model => move |_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
let song_model = item.item().unwrap().downcast::<SongModel>().unwrap();
song_model.set_state(model.song_state(&song_model.get_id()));
let widget = item.child().unwrap().downcast::<SongWidget>().unwrap();
widget.bind(&song_model, worker.clone(), model.show_song_covers());
let id = &song_model.get_id();
widget.set_actions(model.actions_for(id).as_ref());
widget.set_menu(model.menu_for(id).as_ref());
}));
factory.connect_unbind(|_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
let song_model = item.item().unwrap().downcast::<SongModel>().unwrap();
song_model.unbind_all();
});
listview.connect_activate(clone!(@weak list_model, @weak model => move |_, position| {
let song = list_model.index_continuous(position as usize).expect("attempt to access invalid index");
let song = song.description();
let selection_enabled = model.is_selection_enabled();
if selection_enabled {
model.toggle_select(&song.id);
} else {
model.play_song_at(position as usize, &song.id);
}
}));
let press_gesture = gtk::GestureLongPress::new();
press_gesture.set_touch_only(false);
press_gesture.set_propagation_phase(gtk::PropagationPhase::Capture);
press_gesture.connect_pressed(clone!(@weak model => move |_, _, _| {
model.enable_selection();
}));
listview.add_controller(press_gesture);
Self {
animator: AnimatorDefault::ease_in_out_animator(),
listview,
model,
}
}
fn autoscroll_to_playing(&self, index: usize) {
let len = self.model.song_list_model().partial_len() as f64;
let scrolled_window: Option<gtk::ScrolledWindow> = ancestor(&self.listview);
let adj = scrolled_window.map(|w| w.vadjustment());
if let Some(adj) = adj {
let v = adj.value();
let v2 = v + 0.9 * adj.page_size();
let pos = (index as f64) * adj.upper() / len;
debug!("estimated pos: {}", pos);
debug!("current window: {} -- {}", v, v2);
if pos < v || pos > v2 {
self.animator.animate(
20,
clone!(@weak adj => @default-return false, move |p| {
let v = adj.value();
adj.set_value(v + p * (pos - v));
true
}),
);
}
}
}
fn update_list(&self) {
let autoscroll_to_playing = self.model.autoscroll_to_playing();
let is_selection_enabled = self.model.is_selection_enabled();
self.model.song_list_model().for_each(|i, model_song| {
let state = self.model.song_state(&model_song.get_id());
model_song.set_state(state);
if state.is_playing && autoscroll_to_playing && !is_selection_enabled {
self.autoscroll_to_playing(i);
}
});
}
fn set_selection_active(listview: &gtk::ListView, active: bool) {
let class_name = "playlist--selectable";
if active {
listview.add_css_class(class_name);
} else {
listview.remove_css_class(class_name);
}
}
fn set_paused(listview: &gtk::ListView, paused: bool) {
let class_name = "playlist--paused";
if paused {
listview.add_css_class(class_name);
} else {
listview.remove_css_class(class_name);
}
}
}
impl SongModel {
fn set_state(
&self,
SongState {
is_playing,
is_selected,
}: SongState,
) {
self.set_playing(is_playing);
self.set_selected(is_selected);
}
}
impl<Model> EventListener for Playlist<Model>
where
Model: PlaylistModel + 'static,
{
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::SelectionEvent(SelectionEvent::SelectionChanged) => {
self.update_list();
}
AppEvent::PlaybackEvent(PlaybackEvent::TrackChanged(_)) => {
self.update_list();
}
AppEvent::PlaybackEvent(
PlaybackEvent::PlaybackResumed | PlaybackEvent::PlaybackPaused,
) => {
Self::set_paused(&self.listview, self.model.is_paused());
}
AppEvent::SelectionEvent(SelectionEvent::SelectionModeChanged(_)) => {
Self::set_selection_active(&self.listview, self.model.is_selection_enabled());
self.update_list();
}
_ => {}
}
}
}
impl<Model> Component for Playlist<Model> {
fn get_root_widget(&self) -> &gtk::Widget {
self.listview.upcast_ref()
}
}

View file

@ -0,0 +1,143 @@
using Gtk 4.0;
template $SongWidget : Grid {
margin-start: 6;
margin-end: 6;
margin-top: 6;
margin-bottom: 6;
column-spacing: 6;
row-spacing: 0;
Overlay {
layout {
row-span: "2";
column: "0";
row: "0";
}
Label song_index {
label: "1";
sensitive: false;
halign: center;
styles [
"song__index",
"numeric",
]
}
[overlay]
Image song_cover {
pixel-size: 30;
overflow: hidden;
halign: center;
valign: center;
styles [
"song__cover",
]
}
[overlay]
Spinner song_icon {
halign: center;
valign: center;
styles [
"song__icon",
]
}
[overlay]
CheckButton song_checkbox {
halign: center;
valign: center;
styles [
"song__checkbox",
]
}
}
Label song_title {
label: "Title";
ellipsize: middle;
max-width-chars: 50;
xalign: 0;
yalign: 1;
hexpand: true;
layout {
column-span: "2";
column: "1";
row: "0";
}
styles [
"title",
]
}
Label song_artist {
label: "Artist";
ellipsize: middle;
max-width-chars: 35;
xalign: 0;
hexpand: true;
layout {
column-span: "1";
column: "1";
row: "1";
}
styles [
"subtitle",
]
}
Label song_length {
sensitive: false;
label: "000";
justify: right;
max-width-chars: 7;
xalign: 1;
hexpand: false;
layout {
row-span: "2";
column: "3";
row: "0";
}
styles [
"numeric",
]
}
MenuButton menu_btn {
focus-on-click: false;
receives-default: true;
icon-name: "view-more-symbolic";
has-frame: false;
hexpand: false;
halign: end;
valign: center;
tooltip-text: "Menu";
layout {
row-span: "2";
column: "4";
row: "0";
}
styles [
"circular",
"flat",
]
}
styles [
"song",
]
}

View file

@ -0,0 +1,186 @@
.playlist .song__index {
transition: opacity 150ms ease;
margin: 6px 12px;
padding: 0;
opacity: 1;
min-width: 1.5em;
}
.song__cover {
border-radius: 6px;
border: 1px solid @card_shade_color;
}
.album__tracks .song__cover {
opacity: 0;
}
/* playback indicator */
.song--playing .song__icon {
opacity: 1;
animation: playing 1s linear infinite;
color: @accent_bg_color;
min-width: 16px;
-gtk-icon-source: -gtk-icontheme("playback-0-symbolic");
}
@keyframes playing {
0% {
-gtk-icon-source: -gtk-icontheme("playback-0-symbolic");
}
6% {
-gtk-icon-source: -gtk-icontheme("playback-1-symbolic");
}
12% {
-gtk-icon-source: -gtk-icontheme("playback-2-symbolic");
}
18% {
-gtk-icon-source: -gtk-icontheme("playback-3-symbolic");
}
24% {
-gtk-icon-source: -gtk-icontheme("playback-4-symbolic");
}
30% {
-gtk-icon-source: -gtk-icontheme("playback-5-symbolic");
}
36% {
-gtk-icon-source: -gtk-icontheme("playback-6-symbolic");
}
42% {
-gtk-icon-source: -gtk-icontheme("playback-7-symbolic");
}
49% {
-gtk-icon-source: -gtk-icontheme("playback-8-symbolic");
}
54% {
-gtk-icon-source: -gtk-icontheme("playback-9-symbolic");
}
60% {
-gtk-icon-source: -gtk-icontheme("playback-10-symbolic");
}
66% {
-gtk-icon-source: -gtk-icontheme("playback-11-symbolic");
}
72% {
-gtk-icon-source: -gtk-icontheme("playback-12-symbolic");
}
79% {
-gtk-icon-source: -gtk-icontheme("playback-13-symbolic");
}
85% {
-gtk-icon-source: -gtk-icontheme("playback-14-symbolic");
}
90% {
-gtk-icon-source: -gtk-icontheme("playback-15-symbolic");
}
96% {
-gtk-icon-source: -gtk-icontheme("playback-16-symbolic");
}
100% {
-gtk-icon-source: -gtk-icontheme("playback-0-symbolic");
}
}
.playlist--paused .song--playing .song__icon {
animation: none;
-gtk-icon-source: -gtk-icontheme("playback-paused-symbolic");
}
.song__icon,
.song__checkbox,
.song--playing .song__index,
.song--playing .song__cover,
.playlist--selectable .song__index,
.playlist--selectable .song__cover,
.playlist--selectable .song__icon {
transition: opacity 150ms ease;
opacity: 0;
}
.playlist--selectable .song__checkbox,
.playlist--selectable .song__checkbox check {
opacity: 1;
filter: none;
}
row:hover .song__menu--enabled, .song__menu--enabled:checked {
opacity: 1;
}
/* Song Labels */
.song--playing label.title {
font-weight: bold;
}
/* "Context Menu" */
.song__menu {
opacity: 0;
}
.song__menu--enabled {
opacity: 0.2;
}
/* Song boxed list styling */
.playlist {
background: transparent;
}
.playlist row {
background: @card_bg_color;
margin-left: 12px;
margin-right: 12px;
box-shadow: 1px 0px 3px rgba(0, 0, 0, 0.07), -1px 0px 3px rgba(0, 0, 0, 0.07);
transition: background-color 150ms ease;
}
.playlist row:hover {
background-image: image(alpha(currentColor, 0.03));
}
.playlist row:active {
background-image: image(alpha(currentColor, 0.08));
}
.playlist row:first-child {
margin-top: 12px;
border-radius: 12px 12px 0 0;
}
.playlist row:last-child {
margin-bottom: 12px;
border-bottom-color: rgba(0, 0, 0, 0);
border-radius: 0 0 12px 12px;
box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.21);
}
.playlist row:only-child {
margin-top: 12px;
margin-bottom: 12px;
border-radius: 12px 12px 12px 12px;
}

View file

@ -0,0 +1,189 @@
use crate::app::components::display_add_css_provider;
use crate::app::loader::ImageLoader;
use crate::app::models::SongModel;
use crate::app::Worker;
use gio::MenuModel;
use glib::subclass::InitializingObject;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
mod imp {
use super::*;
const SONG_CLASS: &str = "song--playing";
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/song.ui")]
pub struct SongWidget {
#[template_child]
pub song_index: TemplateChild<gtk::Label>,
#[template_child]
pub song_icon: TemplateChild<gtk::Spinner>,
#[template_child]
pub song_checkbox: TemplateChild<gtk::CheckButton>,
#[template_child]
pub song_title: TemplateChild<gtk::Label>,
#[template_child]
pub song_artist: TemplateChild<gtk::Label>,
#[template_child]
pub song_length: TemplateChild<gtk::Label>,
#[template_child]
pub menu_btn: TemplateChild<gtk::MenuButton>,
#[template_child]
pub song_cover: TemplateChild<gtk::Image>,
}
#[glib::object_subclass]
impl ObjectSubclass for SongWidget {
const NAME: &'static str = "SongWidget";
type Type = super::SongWidget;
type ParentType = gtk::Grid;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
lazy_static! {
static ref PROPERTIES: [glib::ParamSpec; 2] = [
glib::ParamSpecBoolean::builder("playing").build(),
glib::ParamSpecBoolean::builder("selected").build()
];
}
impl ObjectImpl for SongWidget {
fn properties() -> &'static [glib::ParamSpec] {
&*PROPERTIES
}
fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
match pspec.name() {
"playing" => {
let is_playing = value
.get()
.expect("type conformity checked by `Object::set_property`");
if is_playing {
self.obj().add_css_class(SONG_CLASS);
} else {
self.obj().remove_css_class(SONG_CLASS);
}
}
"selected" => {
let is_selected = value
.get()
.expect("type conformity checked by `Object::set_property`");
self.song_checkbox.set_active(is_selected);
}
_ => unimplemented!(),
}
}
fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"playing" => self.obj().has_css_class(SONG_CLASS).to_value(),
"selected" => self.song_checkbox.is_active().to_value(),
_ => unimplemented!(),
}
}
fn constructed(&self) {
self.parent_constructed();
self.song_checkbox.set_sensitive(false);
}
fn dispose(&self) {
while let Some(child) = self.obj().first_child() {
child.unparent();
}
}
}
impl WidgetImpl for SongWidget {}
impl GridImpl for SongWidget {}
}
glib::wrapper! {
pub struct SongWidget(ObjectSubclass<imp::SongWidget>) @extends gtk::Widget, gtk::Grid;
}
impl Default for SongWidget {
fn default() -> Self {
Self::new()
}
}
impl SongWidget {
pub fn new() -> Self {
display_add_css_provider(resource!("/components/song.css"));
glib::Object::new()
}
pub fn set_actions(&self, actions: Option<&gio::ActionGroup>) {
self.insert_action_group("song", actions);
}
pub fn set_menu(&self, menu: Option<&MenuModel>) {
if menu.is_some() {
let widget = self.imp();
widget.menu_btn.set_menu_model(menu);
widget.menu_btn.add_css_class("song__menu--enabled");
}
}
fn set_show_cover(&self, show_cover: bool) {
let song_class = "song--cover";
if show_cover {
self.add_css_class(song_class);
} else {
self.remove_css_class(song_class);
}
}
fn set_image(&self, pixbuf: Option<&gdk_pixbuf::Pixbuf>) {
self.imp().song_cover.set_from_pixbuf(pixbuf);
}
pub fn set_art(&self, model: &SongModel, worker: Worker) {
if let Some(url) = model.description().art.clone() {
let _self = self.downgrade();
worker.send_local_task(async move {
if let Some(_self) = _self.upgrade() {
let loader = ImageLoader::new();
let result = loader.load_remote(&url, "jpg", 100, 100).await;
_self.set_image(result.as_ref());
}
});
}
}
pub fn bind(&self, model: &SongModel, worker: Worker, show_cover: bool) {
let widget = self.imp();
model.bind_title(&*widget.song_title, "label");
model.bind_artist(&*widget.song_artist, "label");
model.bind_duration(&*widget.song_length, "label");
model.bind_playing(self, "playing");
model.bind_selected(self, "selected");
self.set_show_cover(show_cover);
if show_cover {
self.set_art(model, worker);
} else {
model.bind_index(&*widget.song_index, "label");
}
}
}

View file

@ -0,0 +1,82 @@
use gdk::prelude::*;
use gio::SimpleAction;
use crate::app::models::SongDescription;
use crate::app::state::{AppAction, PlaybackAction};
use crate::app::ActionDispatcher;
impl SongDescription {
pub fn make_queue_action(
&self,
dispatcher: Box<dyn ActionDispatcher>,
name: Option<&str>,
) -> SimpleAction {
let queue = SimpleAction::new(name.unwrap_or("queue"), None);
let song = self.clone();
queue.connect_activate(move |_, _| {
dispatcher.dispatch(PlaybackAction::Queue(vec![song.clone()]).into());
});
queue
}
pub fn make_dequeue_action(
&self,
dispatcher: Box<dyn ActionDispatcher>,
name: Option<&str>,
) -> SimpleAction {
let dequeue = SimpleAction::new(name.unwrap_or("dequeue"), None);
let track_id = self.id.clone();
dequeue.connect_activate(move |_, _| {
dispatcher.dispatch(PlaybackAction::Dequeue(track_id.clone()).into());
});
dequeue
}
pub fn make_link_action(&self, name: Option<&str>) -> SimpleAction {
let track_id = self.id.clone();
let copy_link = SimpleAction::new(name.unwrap_or("copy_link"), None);
copy_link.connect_activate(move |_, _| {
let link = format!("https://open.spotify.com/track/{track_id}");
let clipboard = gdk::Display::default().unwrap().clipboard();
clipboard
.set_content(Some(&gdk::ContentProvider::for_value(&link.to_value())))
.expect("Failed to set clipboard content");
});
copy_link
}
pub fn make_album_action(
&self,
dispatcher: Box<dyn ActionDispatcher>,
name: Option<&str>,
) -> SimpleAction {
let album_id = self.album.id.clone();
let view_album = SimpleAction::new(name.unwrap_or("view_album"), None);
view_album.connect_activate(move |_, _| {
dispatcher.dispatch(AppAction::ViewAlbum(album_id.clone()));
});
view_album
}
pub fn make_artist_actions(
&self,
dispatcher: Box<dyn ActionDispatcher>,
prefix: Option<&str>,
) -> Vec<SimpleAction> {
self.artists
.iter()
.map(|artist| {
let id = artist.id.clone();
let view_artist = SimpleAction::new(
&format!("{}_{}", prefix.unwrap_or("view_artist"), &id),
None,
);
let dispatcher = dispatcher.box_clone();
view_artist.connect_activate(move |_, _| {
dispatcher.dispatch(AppAction::ViewArtist(id.clone()));
});
view_artist
})
.collect()
}
}

View file

@ -0,0 +1,14 @@
#[allow(clippy::module_inception)]
mod playlist_details;
mod playlist_details_model;
mod playlist_header;
mod playlist_headerbar;
pub use playlist_details::*;
pub use playlist_details_model::*;
use glib::StaticType;
pub fn expose_widgets() {
playlist_headerbar::PlaylistHeaderBarWidget::static_type();
}

View file

@ -0,0 +1,52 @@
using Gtk 4.0;
using Adw 1;
template $PlaylistDetailsWidget : Adw.Bin {
Box {
orientation: vertical;
vexpand: true;
hexpand: true;
$PlaylistHeaderBarWidget headerbar {
}
$ScrollingHeaderWidget scrolling_header {
[header]
WindowHandle {
Adw.Clamp {
maximum-size: 900;
Adw.Squeezer {
valign: center;
homogeneous: false;
transition-type: crossfade;
switch-threshold-policy: natural;
$PlaylistHeaderWidget header_widget {
}
$PlaylistHeaderWidget header_mobile {
orientation: "vertical";
spacing: "12";
}
}
styles [
"playlist_details__clamp",
]
}
}
Adw.ClampScrollable {
maximum-size: 900;
ListView tracks {
}
}
styles [
"container",
]
}
}
}

View file

@ -0,0 +1,326 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use std::rc::Rc;
use super::playlist_header::PlaylistHeaderWidget;
use super::playlist_headerbar::PlaylistHeaderBarWidget;
use super::PlaylistDetailsModel;
use crate::app::components::{
Component, EventListener, Playlist, PlaylistModel, ScrollingHeaderWidget,
};
use crate::app::dispatch::Worker;
use crate::app::loader::ImageLoader;
use crate::app::state::{PlaybackEvent, SelectionEvent};
use crate::app::{AppEvent, BrowserEvent};
use libadwaita::subclass::prelude::BinImpl;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/playlist_details.ui")]
pub struct PlaylistDetailsWidget {
#[template_child]
pub headerbar: TemplateChild<PlaylistHeaderBarWidget>,
#[template_child]
pub scrolling_header: TemplateChild<ScrollingHeaderWidget>,
#[template_child]
pub header_widget: TemplateChild<PlaylistHeaderWidget>,
#[template_child]
pub header_mobile: TemplateChild<PlaylistHeaderWidget>,
#[template_child]
pub tracks: TemplateChild<gtk::ListView>,
}
#[glib::object_subclass]
impl ObjectSubclass for PlaylistDetailsWidget {
const NAME: &'static str = "PlaylistDetailsWidget";
type Type = super::PlaylistDetailsWidget;
type ParentType = libadwaita::Bin;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for PlaylistDetailsWidget {
fn constructed(&self) {
self.parent_constructed();
self.header_mobile.set_centered();
self.header_widget.set_grows_automatically();
self.header_widget
.entry()
.bind_property("text", self.header_mobile.entry(), "text")
.flags(glib::BindingFlags::BIDIRECTIONAL)
.build();
}
}
impl WidgetImpl for PlaylistDetailsWidget {}
impl BinImpl for PlaylistDetailsWidget {}
}
glib::wrapper! {
pub struct PlaylistDetailsWidget(ObjectSubclass<imp::PlaylistDetailsWidget>) @extends gtk::Widget, libadwaita::Bin;
}
impl PlaylistDetailsWidget {
fn new() -> Self {
glib::Object::new()
}
fn playlist_tracks_widget(&self) -> &gtk::ListView {
self.imp().tracks.as_ref()
}
fn connect_bottom_edge<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().scrolling_header.connect_bottom_edge(f);
}
fn set_header_visible(&self, visible: bool) {
let widget = self.imp();
widget.headerbar.set_title_visible(true);
if visible {
widget.headerbar.add_classes(&["flat"]);
} else {
widget.headerbar.remove_classes(&["flat"]);
}
}
fn connect_header(&self) {
self.set_header_visible(false);
self.imp().scrolling_header.connect_header_visibility(
clone!(@weak self as _self => move |visible| {
_self.set_header_visible(visible);
}),
);
}
fn set_loaded(&self) {
self.imp()
.scrolling_header
.add_css_class("container--loaded");
}
fn set_editing(&self, editing: bool) {
self.imp().header_widget.set_editing(editing);
self.imp().header_mobile.set_editing(editing);
self.imp().headerbar.set_editing(editing);
}
fn set_editable(&self, editing: bool) {
self.imp().headerbar.set_editable(editing);
}
fn set_info(&self, playlist: &str, owner: &str) {
self.imp().header_widget.set_info(playlist, owner);
self.imp().header_mobile.set_info(playlist, owner);
self.imp().headerbar.set_title(Some(playlist));
}
fn set_playing(&self, is_playing: bool) {
self.imp().header_widget.set_playing(is_playing);
self.imp().header_mobile.set_playing(is_playing);
}
fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) {
self.imp().header_widget.set_artwork(art);
self.imp().header_mobile.set_artwork(art);
}
fn connect_owner_clicked<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
self.imp().header_widget.connect_owner_clicked(f.clone());
self.imp().header_mobile.connect_owner_clicked(f);
}
pub fn connect_edit<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().headerbar.connect_edit(f);
}
pub fn connect_cancel<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp()
.headerbar
.connect_cancel(clone!(@weak self as _self => move || {
_self.imp().header_widget.reset_playlist_name();
_self.imp().header_mobile.reset_playlist_name();
f();
}));
}
pub fn connect_play<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
self.imp().header_widget.connect_play(f.clone());
self.imp().header_mobile.connect_play(f);
}
pub fn connect_done<F>(&self, f: F)
where
F: Fn(String) + 'static,
{
self.imp()
.headerbar
.connect_ok(clone!(@weak self as _self => move || {
let s = _self.imp().header_widget.get_edited_playlist_name();
f(s);
}));
}
pub fn connect_go_back<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().headerbar.connect_go_back(f);
}
}
pub struct PlaylistDetails {
model: Rc<PlaylistDetailsModel>,
worker: Worker,
widget: PlaylistDetailsWidget,
children: Vec<Box<dyn EventListener>>,
}
impl PlaylistDetails {
pub fn new(model: Rc<PlaylistDetailsModel>, worker: Worker) -> Self {
if model.get_playlist_info().is_none() {
model.load_playlist_info();
}
let widget = PlaylistDetailsWidget::new();
let playlist = Box::new(Playlist::new(
widget.playlist_tracks_widget().clone(),
model.clone(),
worker.clone(),
));
widget.set_editable(model.is_playlist_editable());
widget.connect_header();
widget.connect_bottom_edge(clone!(@weak model => move || {
model.load_more_tracks();
}));
widget.connect_owner_clicked(clone!(@weak model => move || model.view_owner()));
widget.connect_edit(clone!(@weak model => move || {
model.enable_selection();
}));
widget.connect_cancel(clone!(@weak model => move || model.disable_selection()));
widget.connect_done(clone!(@weak model => move |n| {
model.disable_selection();
model.update_playlist_details(n);
}));
widget.connect_play(clone!(@weak model => move || model.toggle_play_playlist()));
widget.connect_go_back(clone!(@weak model => move || model.go_back()));
Self {
model,
worker,
widget,
children: vec![playlist],
}
}
fn update_details(&self) {
if let Some(info) = self.model.get_playlist_info() {
let title = &info.title[..];
let owner = &info.owner.display_name[..];
let art_url = info.art.as_ref();
self.widget.set_info(title, owner);
if let Some(art_url) = art_url.cloned() {
let widget = self.widget.downgrade();
self.worker.send_local_task(async move {
let pixbuf = ImageLoader::new()
.load_remote(&art_url[..], "jpg", 320, 320)
.await;
if let (Some(widget), Some(ref pixbuf)) = (widget.upgrade(), pixbuf) {
widget.set_artwork(pixbuf);
widget.set_loaded();
}
});
} else {
self.widget.set_loaded();
}
}
}
fn update_playing(&self, is_playing: bool) {
if !self.model.playlist_is_playing() || !self.model.is_playing() {
self.widget.set_playing(false);
return;
}
self.widget.set_playing(is_playing);
}
fn set_editing(&self, editable: bool) {
if !self.model.is_playlist_editable() {
return;
}
self.widget.set_editing(editable);
}
}
impl Component for PlaylistDetails {
fn get_root_widget(&self) -> &gtk::Widget {
self.widget.upcast_ref()
}
fn get_children(&mut self) -> Option<&mut Vec<Box<dyn EventListener>>> {
Some(&mut self.children)
}
}
impl EventListener for PlaylistDetails {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::BrowserEvent(BrowserEvent::PlaylistDetailsLoaded(id))
if id == &self.model.id =>
{
self.update_details();
self.update_playing(true);
}
AppEvent::SelectionEvent(SelectionEvent::SelectionModeChanged(editing)) => {
self.set_editing(*editing);
}
AppEvent::PlaybackEvent(PlaybackEvent::PlaybackPaused) => {
self.update_playing(false);
}
AppEvent::PlaybackEvent(PlaybackEvent::PlaybackResumed) => {
self.update_playing(true);
}
_ => {}
}
self.broadcast_event(event);
}
}

View file

@ -0,0 +1,242 @@
use gio::prelude::*;
use gio::SimpleActionGroup;
use std::cell::Ref;
use std::ops::Deref;
use std::rc::Rc;
use crate::api::SpotifyApiError;
use crate::app::components::{labels, PlaylistModel};
use crate::app::models::*;
use crate::app::state::SelectionContext;
use crate::app::state::{BrowserAction, PlaybackAction, SelectionAction, SelectionState};
use crate::app::AppState;
use crate::app::{ActionDispatcher, AppAction, AppModel, BatchQuery, SongsSource};
pub struct PlaylistDetailsModel {
pub id: String,
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl PlaylistDetailsModel {
pub fn new(id: String, app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
id,
app_model,
dispatcher,
}
}
pub fn state(&self) -> Ref<'_, AppState> {
self.app_model.get_state()
}
pub fn is_playlist_editable(&self) -> bool {
let state = self.app_model.get_state();
state.logged_user.playlists.iter().any(|p| p.id == self.id)
}
pub fn get_playlist_info(&self) -> Option<impl Deref<Target = PlaylistDescription> + '_> {
self.app_model.map_state_opt(|s| {
s.browser
.playlist_details_state(&self.id)?
.playlist
.as_ref()
})
}
pub fn is_playing(&self) -> bool {
self.state().playback.is_playing()
}
pub fn playlist_is_playing(&self) -> bool {
matches!(
self.app_model.get_state().playback.current_source(),
Some(SongsSource::Playlist(ref id)) if id == &self.id)
}
pub fn toggle_play_playlist(&self) {
if let Some(playlist) = self.get_playlist_info() {
if !self.playlist_is_playing() {
if self.state().playback.is_shuffled() {
self.dispatcher
.dispatch(AppAction::PlaybackAction(PlaybackAction::ToggleShuffle));
}
let id_of_first_song = playlist.songs.songs[0].id.as_str();
self.play_song_at(0, id_of_first_song);
return;
}
if self.state().playback.is_playing() {
self.dispatcher
.dispatch(AppAction::PlaybackAction(PlaybackAction::Pause));
} else {
self.dispatcher
.dispatch(AppAction::PlaybackAction(PlaybackAction::Play));
}
}
}
pub fn load_playlist_info(&self) {
let api = self.app_model.get_spotify();
let id = self.id.clone();
self.dispatcher
.call_spotify_and_dispatch(move || async move {
let playlist = api.get_playlist(&id).await;
match playlist {
Ok(playlist) => {
Ok(BrowserAction::SetPlaylistDetails(Box::new(playlist)).into())
}
Err(SpotifyApiError::BadStatus(400, _))
| Err(SpotifyApiError::BadStatus(404, _)) => {
Ok(BrowserAction::NavigationPop.into())
}
Err(e) => Err(e),
}
});
}
pub fn load_more_tracks(&self) -> Option<()> {
let last_batch = self.song_list_model().last_batch()?;
let query = BatchQuery {
source: SongsSource::Playlist(self.id.clone()),
batch: last_batch,
};
let id = self.id.clone();
let next_query = query.next()?;
debug!("next_query = {:?}", &next_query);
let loader = self.app_model.get_batch_loader();
self.dispatcher.dispatch_async(Box::pin(async move {
loader
.query(next_query, |_s, song_batch| {
BrowserAction::AppendPlaylistTracks(id, Box::new(song_batch)).into()
})
.await
}));
Some(())
}
pub fn update_playlist_details(&self, title: String) {
let api = self.app_model.get_spotify();
let id = self.id.clone();
self.dispatcher
.call_spotify_and_dispatch(move || async move {
let playlist = api.update_playlist_details(&id, title.clone()).await;
match playlist {
Ok(_) => Ok(AppAction::UpdatePlaylistName(PlaylistSummary { id, title })),
Err(e) => Err(e),
}
});
}
pub fn view_owner(&self) {
if let Some(playlist) = self.get_playlist_info() {
let owner = &playlist.owner.id;
self.dispatcher
.dispatch(AppAction::ViewUser(owner.to_owned()));
}
}
pub fn disable_selection(&self) {
self.dispatcher.dispatch(AppAction::CancelSelection);
}
pub fn go_back(&self) {
self.dispatcher
.dispatch(BrowserAction::NavigationPop.into());
}
}
impl PlaylistModel for PlaylistDetailsModel {
fn song_list_model(&self) -> SongListModel {
self.state()
.browser
.playlist_details_state(&self.id)
.expect("illegal attempt to read playlist_details_state")
.songs
.clone()
}
fn is_paused(&self) -> bool {
!self.state().playback.is_playing()
}
fn current_song_id(&self) -> Option<String> {
self.state().playback.current_song_id()
}
fn play_song_at(&self, pos: usize, id: &str) {
let source = SongsSource::Playlist(self.id.clone());
let batch = self.song_list_model().song_batch_for(pos);
if let Some(batch) = batch {
self.dispatcher
.dispatch(PlaybackAction::LoadPagedSongs(source, batch).into());
self.dispatcher
.dispatch(PlaybackAction::Load(id.to_string()).into());
}
}
fn actions_for(&self, id: &str) -> Option<gio::ActionGroup> {
let song = self.song_list_model().get(id)?;
let song = song.description();
let group = SimpleActionGroup::new();
for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) {
group.add_action(&view_artist);
}
group.add_action(&song.make_album_action(self.dispatcher.box_clone(), None));
group.add_action(&song.make_link_action(None));
group.add_action(&song.make_queue_action(self.dispatcher.box_clone(), None));
Some(group.upcast())
}
fn menu_for(&self, id: &str) -> Option<gio::MenuModel> {
let song = self.song_list_model().get(id)?;
let song = song.description();
let menu = gio::Menu::new();
menu.append(Some(&*labels::VIEW_ALBUM), Some("song.view_album"));
for artist in song.artists.iter() {
menu.append(
Some(&labels::more_from_label(&artist.name)),
Some(&format!("song.view_artist_{}", artist.id)),
);
}
menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link"));
menu.append(Some(&*labels::ADD_TO_QUEUE), Some("song.queue"));
Some(menu.upcast())
}
fn select_song(&self, id: &str) {
let song = self.song_list_model().get(id);
if let Some(song) = song {
self.dispatcher
.dispatch(SelectionAction::Select(vec![song.into_description()]).into());
}
}
fn deselect_song(&self, id: &str) {
self.dispatcher
.dispatch(SelectionAction::Deselect(vec![id.to_string()]).into());
}
fn enable_selection(&self) -> bool {
self.dispatcher
.dispatch(AppAction::EnableSelection(if self.is_playlist_editable() {
SelectionContext::EditablePlaylist(self.id.clone())
} else {
SelectionContext::Playlist
}));
true
}
fn selection(&self) -> Option<Box<dyn Deref<Target = SelectionState> + '_>> {
Some(Box::new(self.app_model.map_state(|s| &s.selection)))
}
}

View file

@ -0,0 +1,84 @@
using Gtk 4.0;
template $PlaylistHeaderWidget : Box {
valign: start;
vexpand: false;
margin-start: 6;
margin-end: 6;
margin-bottom: 6;
Box playlist_image_box {
overflow: hidden;
halign: center;
margin-top: 18;
margin-start: 6;
margin-bottom: 6;
Image playlist_art {
width-request: 160;
height-request: 160;
icon-name: "emblem-music-symbolic";
}
styles [
"card",
]
}
Box playlist_info {
hexpand: true;
valign: center;
orientation: vertical;
spacing: 6;
margin-start: 18;
Entry playlist_label_entry {
hexpand: false;
halign: start;
editable: false;
can-focus: false;
placeholder-text: "Playlist Title";
styles [
"title-1",
"playlist__title-entry",
"playlist__title-entry--ro",
]
}
LinkButton author_button {
receives-default: true;
halign: start;
valign: center;
has-frame: false;
Label author_button_label {
hexpand: true;
vexpand: true;
label: "Artist";
ellipsize: middle;
}
styles [
"title-4",
]
}
}
Button play_button {
margin-end: 6;
receives-default: true;
halign: center;
valign: center;
tooltip-text: "Play";
icon-name: "media-playback-start-symbolic";
styles [
"circular",
"play__button",
]
}
styles [
"playlist__header",
]
}

View file

@ -0,0 +1,32 @@
.playlist__header .title-4 label {
color: @window_fg_color;
font-weight: bold;
text-decoration: none;
}
.playlist__header .title-4:hover {
border-radius: 6px;
background-image: image(alpha(currentColor, 0.08));
}
clamp.playlist_details__clamp {
background-color: @view_bg_color;
box-shadow: inset 0px -1px 0px @borders;
}
headerbar.playlist_details__headerbar {
transition: background-color .3s ease;
}
headerbar.flat.playlist_details__headerbar windowtitle {
opacity: 0;
}
headerbar.playlist_details__headerbar windowtitle {
transition: opacity .3s ease;
opacity: 1;
}
.playlist_details__headerbar.flat {
background-color: @view_bg_color;
}

View file

@ -0,0 +1,190 @@
use crate::app::components::display_add_css_provider;
use gettextrs::gettext;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::{glib, CompositeTemplate};
const CSS_RO_ENTRY: &str = "playlist__title-entry--ro";
mod imp {
use glib::{ParamSpec, Properties};
use std::cell::RefCell;
use super::*;
#[derive(Debug, Default, CompositeTemplate, Properties)]
#[template(resource = "/dev/alextren/Spot/components/playlist_header.ui")]
#[properties(wrapper_type = super::PlaylistHeaderWidget)]
pub struct PlaylistHeaderWidget {
#[template_child]
pub playlist_label_entry: TemplateChild<gtk::Entry>,
#[template_child]
pub playlist_image_box: TemplateChild<gtk::Box>,
#[template_child]
pub playlist_art: TemplateChild<gtk::Image>,
#[template_child]
pub playlist_info: TemplateChild<gtk::Box>,
#[template_child]
pub author_button: TemplateChild<gtk::LinkButton>,
#[template_child]
pub author_button_label: TemplateChild<gtk::Label>,
#[template_child]
pub play_button: TemplateChild<gtk::Button>,
#[property(get, set, name = "original-entry-text")]
pub original_entry_text: RefCell<String>,
}
#[glib::object_subclass]
impl ObjectSubclass for PlaylistHeaderWidget {
const NAME: &'static str = "PlaylistHeaderWidget";
type Type = super::PlaylistHeaderWidget;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
display_add_css_provider(resource!("/components/playlist_header.css"));
obj.init_template();
}
}
impl ObjectImpl for PlaylistHeaderWidget {
fn properties() -> &'static [ParamSpec] {
Self::derived_properties()
}
fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
self.derived_set_property(id, value, pspec);
}
fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value {
self.derived_property(id, pspec)
}
fn constructed(&self) {
self.parent_constructed();
}
}
impl WidgetImpl for PlaylistHeaderWidget {}
impl BoxImpl for PlaylistHeaderWidget {}
}
glib::wrapper! {
pub struct PlaylistHeaderWidget(ObjectSubclass<imp::PlaylistHeaderWidget>) @extends gtk::Widget, gtk::Box;
}
impl Default for PlaylistHeaderWidget {
fn default() -> Self {
Self::new()
}
}
impl PlaylistHeaderWidget {
pub fn new() -> Self {
glib::Object::new()
}
pub fn connect_owner_clicked<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().author_button.connect_activate_link(move |_| {
f();
glib::signal::Inhibit(true)
});
}
pub fn connect_play<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().play_button.connect_clicked(move |_| f());
}
pub fn reset_playlist_name(&self) {
self.imp()
.playlist_label_entry
.set_text(&self.original_entry_text());
}
pub fn get_edited_playlist_name(&self) -> String {
self.imp().playlist_label_entry.text().to_string()
}
pub fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) {
self.imp().playlist_art.set_from_pixbuf(Some(art));
}
pub fn set_info(&self, playlist: &str, owner: &str) {
let widget = self.imp();
self.set_original_entry_text(playlist);
widget.playlist_label_entry.set_text(playlist);
widget
.playlist_label_entry
.set_placeholder_text(Some(playlist));
widget.author_button_label.set_label(owner);
}
pub fn set_playing(&self, is_playing: bool) {
let playback_icon = if is_playing {
"media-playback-pause-symbolic"
} else {
"media-playback-start-symbolic"
};
let translated_tooltip = if is_playing {
gettext("Pause")
} else {
gettext("Play")
};
let tooltip_text = Some(translated_tooltip.as_str());
self.imp().play_button.set_icon_name(playback_icon);
self.imp().play_button.set_tooltip_text(tooltip_text);
}
pub fn set_centered(&self) {
let widget = self.imp();
widget.playlist_info.set_halign(gtk::Align::Center);
widget.play_button.set_margin_end(0);
widget.playlist_info.set_margin_start(0);
widget.playlist_image_box.set_margin_start(0);
widget.playlist_label_entry.set_xalign(0.5);
widget.author_button.set_halign(gtk::Align::Center);
}
pub fn set_editing(&self, editing: bool) {
let widget = self.imp();
widget.playlist_label_entry.set_can_focus(editing);
widget.playlist_label_entry.set_editable(editing);
if editing {
widget.playlist_label_entry.remove_css_class(CSS_RO_ENTRY);
} else {
widget.playlist_label_entry.add_css_class(CSS_RO_ENTRY);
}
}
pub fn entry(&self) -> &gtk::Entry {
self.imp().playlist_label_entry.as_ref()
}
pub fn set_grows_automatically(&self) {
let entry: &gtk::Entry = &self.imp().playlist_label_entry;
entry
.bind_property("text", entry, "width-chars")
.transform_to(|_, text: &str| Some(text.len() as i32))
.flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE)
.build();
}
}

View file

@ -0,0 +1,77 @@
using Gtk 4.0;
using Adw 1;
template $PlaylistHeaderBarWidget : Adw.Bin {
[root]
Overlay overlay {
hexpand: true;
Adw.HeaderBar main_header {
show-end-title-buttons: true;
Button go_back {
receives-default: true;
halign: start;
valign: center;
icon-name: "go-previous-symbolic";
has-frame: false;
}
[title]
Adw.WindowTitle title {
visible: false;
title: "Spot";
}
[end]
Button edit {
icon-name: "document-edit-symbolic";
}
styles [
"playlist_details__headerbar",
]
}
[overlay]
Adw.HeaderBar edition_header {
show-end-title-buttons: false;
show-start-title-buttons: false;
visible: false;
styles [
"selection-mode",
]
Button cancel {
receives-default: true;
halign: start;
valign: center;
/* Translators: Exit playlist edition */
label: _("Cancel");
}
[title]
Separator {
styles [
"spacer",
]
}
[end]
Button ok {
valign: center;
/* Translators: Finish playlist edition */
label: _("Done");
styles [
"suggested-action",
]
}
}
}
}

View file

@ -0,0 +1,164 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use libadwaita::subclass::prelude::BinImpl;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/playlist_headerbar.ui")]
pub struct PlaylistHeaderBarWidget {
#[template_child]
pub main_header: TemplateChild<libadwaita::HeaderBar>,
#[template_child]
pub edition_header: TemplateChild<libadwaita::HeaderBar>,
#[template_child]
pub go_back: TemplateChild<gtk::Button>,
#[template_child]
pub title: TemplateChild<libadwaita::WindowTitle>,
#[template_child]
pub edit: TemplateChild<gtk::Button>,
#[template_child]
pub ok: TemplateChild<gtk::Button>,
#[template_child]
pub cancel: TemplateChild<gtk::Button>,
#[template_child]
pub overlay: TemplateChild<gtk::Overlay>,
}
#[glib::object_subclass]
impl ObjectSubclass for PlaylistHeaderBarWidget {
const NAME: &'static str = "PlaylistHeaderBarWidget";
type Type = super::PlaylistHeaderBarWidget;
type ParentType = libadwaita::Bin;
type Interfaces = (gtk::Buildable,);
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for PlaylistHeaderBarWidget {}
impl BuildableImpl for PlaylistHeaderBarWidget {
fn add_child(&self, builder: &gtk::Builder, child: &glib::Object, type_: Option<&str>) {
if Some("root") == type_ {
self.parent_add_child(builder, child, type_);
} else {
self.main_header
.set_title_widget(child.downcast_ref::<gtk::Widget>());
}
}
}
impl WidgetImpl for PlaylistHeaderBarWidget {}
impl BinImpl for PlaylistHeaderBarWidget {}
impl WindowImpl for PlaylistHeaderBarWidget {}
}
glib::wrapper! {
pub struct PlaylistHeaderBarWidget(ObjectSubclass<imp::PlaylistHeaderBarWidget>) @extends gtk::Widget, libadwaita::Bin;
}
impl Default for PlaylistHeaderBarWidget {
fn default() -> Self {
Self::new()
}
}
impl PlaylistHeaderBarWidget {
pub fn new() -> Self {
glib::Object::new()
}
pub fn connect_edit<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().edit.connect_clicked(move |_| f());
}
pub fn connect_ok<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().ok.connect_clicked(move |_| f());
}
pub fn connect_cancel<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().cancel.connect_clicked(move |_| f());
}
pub fn connect_go_back<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().go_back.connect_clicked(move |_| f());
}
pub fn bind_to_leaflet(&self, leaflet: &libadwaita::Leaflet) {
leaflet
.bind_property(
"folded",
&*self.imp().main_header,
"show-start-title-buttons",
)
.build();
leaflet.notify("folded");
}
pub fn set_can_go_back(&self, can_go_back: bool) {
self.imp().go_back.set_visible(can_go_back);
}
pub fn set_editable(&self, editable: bool) {
self.imp().edit.set_visible(editable);
}
pub fn set_editing(&self, editing: bool) {
if editing {
self.imp().edition_header.set_visible(true);
} else {
self.imp().edition_header.set_visible(false);
}
}
pub fn add_classes(&self, classes: &[&str]) {
for &class in classes {
self.add_css_class(class);
}
}
pub fn remove_classes(&self, classes: &[&str]) {
for &class in classes {
self.remove_css_class(class);
}
}
pub fn set_title_visible(&self, visible: bool) {
self.imp().title.set_visible(visible);
}
pub fn set_title(&self, title: Option<&str>) {
self.imp().title.set_visible(title.is_some());
if let Some(title) = title {
self.imp().title.set_title(title);
}
}
}

View file

@ -0,0 +1,6 @@
#[allow(clippy::module_inception)]
mod saved_playlists;
mod saved_playlists_model;
pub use saved_playlists::*;
pub use saved_playlists_model::*;

View file

@ -0,0 +1,36 @@
using Gtk 4.0;
using Adw 1;
template $SavedPlaylistsWidget : Box {
ScrolledWindow scrolled_window {
hexpand: true;
vexpand: true;
vscrollbar-policy: always;
min-content-width: 250;
Overlay overlay {
FlowBox flowbox {
margin-start: 8;
margin-end: 8;
margin-top: 8;
margin-bottom: 8;
min-children-per-line: 1;
selection-mode: none;
activate-on-single-click: false;
}
[overlay]
Adw.StatusPage status_page {
/* Translators: A title that is shown when the user has not saved any playlists. */
title: _("You have no saved playlists.");
/* Translators: A description of what happens when the user has saved playlists. */
description: _("Your playlists will be shown here.");
icon-name: "emblem-music-symbolic";
visible: true;
}
}
}
}

View file

@ -0,0 +1,160 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use std::rc::Rc;
use super::SavedPlaylistsModel;
use crate::app::components::{AlbumWidget, Component, EventListener};
use crate::app::dispatch::Worker;
use crate::app::models::AlbumModel;
use crate::app::state::LoginEvent;
use crate::app::{AppEvent, BrowserEvent, ListStore};
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/saved_playlists.ui")]
pub struct SavedPlaylistsWidget {
#[template_child]
pub scrolled_window: TemplateChild<gtk::ScrolledWindow>,
#[template_child]
pub flowbox: TemplateChild<gtk::FlowBox>,
#[template_child]
pub status_page: TemplateChild<libadwaita::StatusPage>,
}
#[glib::object_subclass]
impl ObjectSubclass for SavedPlaylistsWidget {
const NAME: &'static str = "SavedPlaylistsWidget";
type Type = super::SavedPlaylistsWidget;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for SavedPlaylistsWidget {}
impl WidgetImpl for SavedPlaylistsWidget {}
impl BoxImpl for SavedPlaylistsWidget {}
}
glib::wrapper! {
pub struct SavedPlaylistsWidget(ObjectSubclass<imp::SavedPlaylistsWidget>) @extends gtk::Widget, gtk::Box;
}
impl Default for SavedPlaylistsWidget {
fn default() -> Self {
Self::new()
}
}
impl SavedPlaylistsWidget {
pub fn new() -> Self {
glib::Object::new()
}
fn connect_bottom_edge<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp()
.scrolled_window
.connect_edge_reached(move |_, pos| {
if let gtk::PositionType::Bottom = pos {
f()
}
});
}
fn bind_albums<F>(&self, worker: Worker, store: &ListStore<AlbumModel>, on_album_pressed: F)
where
F: Fn(String) + Clone + 'static,
{
self.imp()
.flowbox
.bind_model(Some(store.unsafe_store()), move |item| {
let album_model = item.downcast_ref::<AlbumModel>().unwrap();
let child = gtk::FlowBoxChild::new();
let album = AlbumWidget::for_model(album_model, worker.clone());
let f = on_album_pressed.clone();
album.connect_album_pressed(clone!(@weak album_model => move |_| {
f(album_model.uri());
}));
child.set_child(Some(&album));
child.upcast::<gtk::Widget>()
});
}
pub fn get_status_page(&self) -> &libadwaita::StatusPage {
&self.imp().status_page
}
}
pub struct SavedPlaylists {
widget: SavedPlaylistsWidget,
worker: Worker,
model: Rc<SavedPlaylistsModel>,
}
impl SavedPlaylists {
pub fn new(worker: Worker, model: SavedPlaylistsModel) -> Self {
let model = Rc::new(model);
let widget = SavedPlaylistsWidget::new();
widget.connect_bottom_edge(clone!(@weak model => move || {
model.load_more_playlists();
}));
Self {
widget,
worker,
model,
}
}
fn bind_flowbox(&self) {
self.widget.bind_albums(
self.worker.clone(),
&self.model.get_list_store().unwrap(),
clone!(@weak self.model as model => move |id| {
model.open_playlist(id);
}),
);
}
}
impl EventListener for SavedPlaylists {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::Started => {
let _ = self.model.refresh_saved_playlists();
self.bind_flowbox();
}
AppEvent::LoginEvent(LoginEvent::LoginCompleted(_)) => {
let _ = self.model.refresh_saved_playlists();
}
AppEvent::BrowserEvent(BrowserEvent::SavedPlaylistsUpdated) => {
self.widget
.get_status_page()
.set_visible(!self.model.has_playlists());
}
_ => {}
}
}
}
impl Component for SavedPlaylists {
fn get_root_widget(&self) -> &gtk::Widget {
self.widget.as_ref()
}
}

View file

@ -0,0 +1,70 @@
use std::cell::Ref;
use std::ops::Deref;
use std::rc::Rc;
use crate::app::models::*;
use crate::app::state::HomeState;
use crate::app::{ActionDispatcher, AppAction, AppModel, BrowserAction, ListStore};
pub struct SavedPlaylistsModel {
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl SavedPlaylistsModel {
pub fn new(app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
app_model,
dispatcher,
}
}
fn state(&self) -> Option<Ref<'_, HomeState>> {
self.app_model.map_state_opt(|s| s.browser.home_state())
}
pub fn get_list_store(&self) -> Option<impl Deref<Target = ListStore<AlbumModel>> + '_> {
Some(Ref::map(self.state()?, |s| &s.playlists))
}
pub fn refresh_saved_playlists(&self) -> Option<()> {
let api = self.app_model.get_spotify();
let batch_size = self.state()?.next_playlists_page.batch_size;
self.dispatcher
.call_spotify_and_dispatch(move || async move {
api.get_saved_playlists(0, batch_size)
.await
.map(|playlists| BrowserAction::SetPlaylistsContent(playlists).into())
});
Some(())
}
pub fn has_playlists(&self) -> bool {
self.get_list_store()
.map(|list| list.len() > 0)
.unwrap_or(false)
}
pub fn load_more_playlists(&self) -> Option<()> {
let api = self.app_model.get_spotify();
let next_page = &self.state()?.next_playlists_page;
let batch_size = next_page.batch_size;
let offset = next_page.next_offset?;
self.dispatcher
.call_spotify_and_dispatch(move || async move {
api.get_saved_playlists(offset, batch_size)
.await
.map(|playlists| BrowserAction::AppendPlaylistsContent(playlists).into())
});
Some(())
}
pub fn open_playlist(&self, id: String) {
self.dispatcher.dispatch(AppAction::ViewPlaylist(id));
}
}

View file

@ -0,0 +1,6 @@
#[allow(clippy::module_inception)]
mod saved_tracks;
pub use saved_tracks::*;
mod saved_tracks_model;
pub use saved_tracks_model::*;

View file

@ -0,0 +1,15 @@
using Gtk 4.0;
using Adw 1;
template $SavedTracksWidget : Adw.Bin {
ScrolledWindow scrolled_window {
vexpand: true;
Adw.ClampScrollable {
maximum-size: 900;
ListView song_list {
}
}
}
}

View file

@ -0,0 +1,117 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use std::rc::Rc;
use super::SavedTracksModel;
use crate::app::components::{Component, EventListener, Playlist};
use crate::app::state::LoginEvent;
use crate::app::{AppEvent, Worker};
use libadwaita::subclass::prelude::BinImpl;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/saved_tracks.ui")]
pub struct SavedTracksWidget {
#[template_child]
pub song_list: TemplateChild<gtk::ListView>,
#[template_child]
pub scrolled_window: TemplateChild<gtk::ScrolledWindow>,
}
#[glib::object_subclass]
impl ObjectSubclass for SavedTracksWidget {
const NAME: &'static str = "SavedTracksWidget";
type Type = super::SavedTracksWidget;
type ParentType = libadwaita::Bin;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for SavedTracksWidget {}
impl WidgetImpl for SavedTracksWidget {}
impl BinImpl for SavedTracksWidget {}
}
glib::wrapper! {
pub struct SavedTracksWidget(ObjectSubclass<imp::SavedTracksWidget>) @extends gtk::Widget, libadwaita::Bin;
}
impl SavedTracksWidget {
fn new() -> Self {
glib::Object::new()
}
fn connect_bottom_edge<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp()
.scrolled_window
.connect_edge_reached(move |_, pos| {
if let gtk::PositionType::Bottom = pos {
f()
}
});
}
fn song_list_widget(&self) -> &gtk::ListView {
self.imp().song_list.as_ref()
}
}
pub struct SavedTracks {
widget: SavedTracksWidget,
model: Rc<SavedTracksModel>,
children: Vec<Box<dyn EventListener>>,
}
impl SavedTracks {
pub fn new(model: Rc<SavedTracksModel>, worker: Worker) -> Self {
let widget = SavedTracksWidget::new();
widget.connect_bottom_edge(clone!(@weak model => move || {
model.load_more();
}));
let playlist = Playlist::new(widget.song_list_widget().clone(), model.clone(), worker);
Self {
widget,
model,
children: vec![Box::new(playlist)],
}
}
}
impl Component for SavedTracks {
fn get_root_widget(&self) -> &gtk::Widget {
self.widget.upcast_ref()
}
fn get_children(&mut self) -> Option<&mut Vec<Box<dyn EventListener>>> {
Some(&mut self.children)
}
}
impl EventListener for SavedTracks {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::Started | AppEvent::LoginEvent(LoginEvent::LoginCompleted(_)) => {
self.model.load_initial();
}
_ => {}
}
self.broadcast_event(event);
}
}

View file

@ -0,0 +1,147 @@
use gio::prelude::*;
use gio::SimpleActionGroup;
use std::ops::Deref;
use std::rc::Rc;
use crate::app::components::{labels, PlaylistModel};
use crate::app::models::*;
use crate::app::state::SelectionContext;
use crate::app::state::{PlaybackAction, SelectionAction, SelectionState};
use crate::app::{ActionDispatcher, AppAction, AppModel, BatchQuery, BrowserAction, SongsSource};
pub struct SavedTracksModel {
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl SavedTracksModel {
pub fn new(app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
app_model,
dispatcher,
}
}
pub fn load_initial(&self) {
let loader = self.app_model.get_batch_loader();
let query = BatchQuery {
source: SongsSource::SavedTracks,
batch: Batch::first_of_size(50),
};
self.dispatcher.dispatch_async(Box::pin(async move {
loader
.query(query, |_s, song_batch| {
BrowserAction::SetSavedTracks(Box::new(song_batch)).into()
})
.await
}));
}
pub fn load_more(&self) -> Option<()> {
let loader = self.app_model.get_batch_loader();
let last_batch = self.song_list_model().last_batch()?.next()?;
let query = BatchQuery {
source: SongsSource::SavedTracks,
batch: last_batch,
};
self.dispatcher.dispatch_async(Box::pin(async move {
loader
.query(query, |_s, song_batch| {
BrowserAction::AppendSavedTracks(Box::new(song_batch)).into()
})
.await
}));
Some(())
}
}
impl PlaylistModel for SavedTracksModel {
fn song_list_model(&self) -> SongListModel {
self.app_model
.get_state()
.browser
.home_state()
.expect("illegal attempt to read home_state")
.saved_tracks
.clone()
}
fn is_paused(&self) -> bool {
!self.app_model.get_state().playback.is_playing()
}
fn current_song_id(&self) -> Option<String> {
self.app_model.get_state().playback.current_song_id()
}
fn play_song_at(&self, pos: usize, id: &str) {
let source = SongsSource::SavedTracks;
let batch = self.song_list_model().song_batch_for(pos);
if let Some(batch) = batch {
self.dispatcher
.dispatch(PlaybackAction::LoadPagedSongs(source, batch).into());
self.dispatcher
.dispatch(PlaybackAction::Load(id.to_string()).into());
}
}
fn autoscroll_to_playing(&self) -> bool {
true
}
fn actions_for(&self, id: &str) -> Option<gio::ActionGroup> {
let song = self.song_list_model().get(id)?;
let song = song.description();
let group = SimpleActionGroup::new();
for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) {
group.add_action(&view_artist);
}
group.add_action(&song.make_album_action(self.dispatcher.box_clone(), None));
group.add_action(&song.make_link_action(None));
Some(group.upcast())
}
fn menu_for(&self, id: &str) -> Option<gio::MenuModel> {
let song = self.song_list_model().get(id)?;
let song = song.description();
let menu = gio::Menu::new();
menu.append(Some(&*labels::VIEW_ALBUM), Some("song.view_album"));
for artist in song.artists.iter() {
menu.append(
Some(&labels::more_from_label(&artist.name)),
Some(&format!("song.view_artist_{}", artist.id)),
);
}
menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link"));
Some(menu.upcast())
}
fn select_song(&self, id: &str) {
let song = self.song_list_model().get(id);
if let Some(song) = song {
self.dispatcher
.dispatch(SelectionAction::Select(vec![song.description().clone()]).into());
}
}
fn deselect_song(&self, id: &str) {
self.dispatcher
.dispatch(SelectionAction::Deselect(vec![id.to_string()]).into());
}
fn enable_selection(&self) -> bool {
self.dispatcher
.dispatch(AppAction::EnableSelection(SelectionContext::SavedTracks));
true
}
fn selection(&self) -> Option<Box<dyn Deref<Target = SelectionState> + '_>> {
let selection = self.app_model.map_state(|s| &s.selection);
Some(Box::new(selection))
}
}

Some files were not shown because too many files have changed in this diff Show more