first commit
105
src/app/batch_loader.rs
Normal 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!",
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
75
src/app/components/album/album.blp
Normal 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",
|
||||
]
|
||||
}
|
34
src/app/components/album/album.css
Normal 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;
|
||||
}
|
129
src/app/components/album/album.rs
Normal 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);
|
||||
}));
|
||||
}
|
||||
}
|
3
src/app/components/album/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
#[allow(clippy::module_inception)]
|
||||
mod album;
|
||||
pub use album::AlbumWidget;
|
31
src/app/components/artist/artist.blp
Normal 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";
|
||||
}
|
||||
}
|
95
src/app/components/artist/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
63
src/app/components/artist_details/artist_details.blp
Normal 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",
|
||||
]
|
||||
}
|
18
src/app/components/artist_details/artist_details.css
Normal 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;
|
||||
}
|
168
src/app/components/artist_details/artist_details.rs
Normal 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) -> >k::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) -> >k::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);
|
||||
}
|
||||
}
|
187
src/app/components/artist_details/artist_details_model.rs
Normal 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());
|
||||
}
|
||||
}
|
6
src/app/components/artist_details/mod.rs
Normal 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::*;
|
136
src/app/components/details/album_header.blp
Normal 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",
|
||||
]
|
||||
}
|
33
src/app/components/details/album_header.css
Normal 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;
|
||||
}
|
166
src/app/components/details/album_header.rs
Normal 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);
|
||||
}
|
||||
}
|
55
src/app/components/details/details.blp
Normal 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",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
335
src/app/components/details/details.rs
Normal 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) -> >k::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) -> >k::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);
|
||||
}
|
||||
}
|
272
src/app/components/details/details_model.rs
Normal 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());
|
||||
}
|
||||
}
|
8
src/app/components/details/mod.rs
Normal 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;
|
82
src/app/components/details/release_details.blp
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
92
src/app/components/details/release_details.rs
Normal 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);
|
||||
}
|
||||
}
|
100
src/app/components/device_selector/component.rs
Normal 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) -> >k::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());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
44
src/app/components/device_selector/device_selector.blp
Normal 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;
|
||||
}
|
||||
}
|
11
src/app/components/device_selector/mod.rs
Normal 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();
|
||||
}
|
169
src/app/components/device_selector/widget.rs
Normal 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: >k::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);
|
||||
}
|
||||
}
|
||||
}
|
296
src/app/components/headerbar/component.rs
Normal 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) -> >k::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);
|
||||
}
|
||||
}
|
67
src/app/components/headerbar/headerbar.blp
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
src/app/components/headerbar/mod.rs
Normal 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();
|
||||
}
|
189
src/app/components/headerbar/widget.rs
Normal 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: >k::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);
|
||||
}
|
||||
}
|
||||
}
|
55
src/app/components/labels.rs
Normal 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)
|
||||
)
|
||||
}
|
35
src/app/components/library/library.blp
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
158
src/app/components/library/library.rs
Normal 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) -> >k::Widget {
|
||||
self.widget.as_ref()
|
||||
}
|
||||
}
|
70
src/app/components/library/library_model.rs
Normal 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));
|
||||
}
|
||||
}
|
6
src/app/components/library/mod.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
#[allow(clippy::module_inception)]
|
||||
mod library;
|
||||
mod library_model;
|
||||
|
||||
pub use library::*;
|
||||
pub use library_model::*;
|
108
src/app/components/login/login.blp
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
247
src/app/components/login/login.rs
Normal 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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
82
src/app/components/login/login_model.rs
Normal 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())
|
||||
}
|
||||
}
|
6
src/app/components/login/mod.rs
Normal 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
|
@ -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) -> >k::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 {}
|
149
src/app/components/navigation/factory.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
88
src/app/components/navigation/home.rs
Normal 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) -> >k::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);
|
||||
}
|
||||
}
|
11
src/app/components/navigation/mod.rs
Normal 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::*;
|
147
src/app/components/navigation/navigation.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
31
src/app/components/navigation/navigation_model.rs
Normal 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()
|
||||
}
|
||||
}
|
47
src/app/components/notification/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
6
src/app/components/now_playing/mod.rs
Normal 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::*;
|
23
src/app/components/now_playing/now_playing.blp
Normal 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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
151
src/app/components/now_playing/now_playing.rs
Normal 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) -> >k::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) -> >k::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);
|
||||
}
|
||||
}
|
174
src/app/components/now_playing/now_playing_model.rs
Normal 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());
|
||||
}
|
||||
}
|
162
src/app/components/playback/component.rs
Normal 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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
11
src/app/components/playback/mod.rs
Normal 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();
|
||||
}
|
31
src/app/components/playback/playback.css
Normal 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;
|
||||
}
|
57
src/app/components/playback/playback_controls.blp
Normal 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");
|
||||
}
|
||||
}
|
123
src/app/components/playback/playback_controls.rs
Normal 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());
|
||||
}
|
||||
}
|
43
src/app/components/playback/playback_info.blp
Normal 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",
|
||||
]
|
||||
}
|
74
src/app/components/playback/playback_info.rs
Normal 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));
|
||||
}
|
||||
}
|
101
src/app/components/playback/playback_widget.blp
Normal 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: "0∶00";
|
||||
halign: end;
|
||||
hexpand: true;
|
||||
|
||||
styles [
|
||||
"numeric",
|
||||
]
|
||||
}
|
||||
|
||||
Label track_duration {
|
||||
sensitive: false;
|
||||
label: " / 0∶00";
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
242
src/app/components/playback/playback_widget.rs
Normal 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("0∶00");
|
||||
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);
|
||||
}
|
||||
}
|
239
src/app/components/player_notifier.rs
Normal 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)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
8
src/app/components/playlist/mod.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
#[allow(clippy::module_inception)]
|
||||
mod playlist;
|
||||
pub use playlist::*;
|
||||
|
||||
mod song;
|
||||
pub use song::*;
|
||||
|
||||
mod song_actions;
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
248
src/app/components/playlist/playlist.rs
Normal 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: >k::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: >k::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) -> >k::Widget {
|
||||
self.listview.upcast_ref()
|
||||
}
|
||||
}
|
143
src/app/components/playlist/song.blp
Normal 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: "0∶00";
|
||||
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",
|
||||
]
|
||||
}
|
186
src/app/components/playlist/song.css
Normal 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;
|
||||
}
|
189
src/app/components/playlist/song.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
82
src/app/components/playlist/song_actions.rs
Normal 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()
|
||||
}
|
||||
}
|
14
src/app/components/playlist_details/mod.rs
Normal 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();
|
||||
}
|
52
src/app/components/playlist_details/playlist_details.blp
Normal 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",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
326
src/app/components/playlist_details/playlist_details.rs
Normal 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) -> >k::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) -> >k::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);
|
||||
}
|
||||
}
|
242
src/app/components/playlist_details/playlist_details_model.rs
Normal 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)))
|
||||
}
|
||||
}
|
84
src/app/components/playlist_details/playlist_header.blp
Normal 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",
|
||||
]
|
||||
}
|
32
src/app/components/playlist_details/playlist_header.css
Normal 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;
|
||||
}
|
190
src/app/components/playlist_details/playlist_header.rs
Normal 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) -> >k::Entry {
|
||||
self.imp().playlist_label_entry.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_grows_automatically(&self) {
|
||||
let entry: >k::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();
|
||||
}
|
||||
}
|
77
src/app/components/playlist_details/playlist_headerbar.blp
Normal 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",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
164
src/app/components/playlist_details/playlist_headerbar.rs
Normal 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: >k::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);
|
||||
}
|
||||
}
|
||||
}
|
6
src/app/components/saved_playlists/mod.rs
Normal 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::*;
|
36
src/app/components/saved_playlists/saved_playlists.blp
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
160
src/app/components/saved_playlists/saved_playlists.rs
Normal 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) -> >k::Widget {
|
||||
self.widget.as_ref()
|
||||
}
|
||||
}
|
70
src/app/components/saved_playlists/saved_playlists_model.rs
Normal 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));
|
||||
}
|
||||
}
|
6
src/app/components/saved_tracks/mod.rs
Normal 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::*;
|
15
src/app/components/saved_tracks/saved_tracks.blp
Normal 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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
117
src/app/components/saved_tracks/saved_tracks.rs
Normal 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) -> >k::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) -> >k::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);
|
||||
}
|
||||
}
|
147
src/app/components/saved_tracks/saved_tracks_model.rs
Normal 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))
|
||||
}
|
||||
}
|