first commit
This commit is contained in:
commit
15cf412840
255 changed files with 47845 additions and 0 deletions
14
src/app/components/playlist_details/mod.rs
Normal file
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
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
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
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
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
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
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
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
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);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue