mirror of
https://github.com/Felitendo/spotifast.git
synced 2026-11-01 17:33:26 +01:00
The mini player's playlist took its current row from the fetched queue, which lags a double-click by a Web API round trip and was only asked again every twenty seconds. What the player says is on right now wins, a queued copy of that song is left out until the fetched queue catches up, and the queue is fetched the moment the song changes while the playlist is open.
4979 lines
198 KiB
Rust
4979 lines
198 KiB
Rust
//! The application: state, event handling, and the actions views ask for.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use egui::Color32;
|
|
|
|
use crate::api::PlayRequest;
|
|
use crate::api::models::{
|
|
ArtistRef, Device, PlayableItem, PlaybackState, Playlist, Queue, Track, User, pick_image,
|
|
};
|
|
use crate::backend::{
|
|
ApiRequest, ApiResponse, AuthStatus, Backend, Command, Event, LocalPlayback, LyricsRequest,
|
|
PLAYLIST_PAGE_SIZE, RemoteAction, Waker,
|
|
};
|
|
use crate::media::{MediaCommand, MediaState, MediaTrack};
|
|
use crate::media_controls::MediaService;
|
|
use crate::model::*;
|
|
use crate::paths::AppDirs;
|
|
use crate::player::{EngineConfig, LoadSpec, LocalState, Playback, PlayerCommand, RepeatMode};
|
|
use crate::settings::{SessionState, Settings, ThemeChoice};
|
|
use crate::single_instance::ControlCommand;
|
|
use crate::theme::{self, Palette};
|
|
use crate::tray::{TrayCommand, TrayService};
|
|
use crate::util;
|
|
|
|
const REMOTE_POLL_ACTIVE: Duration = Duration::from_secs(4);
|
|
const REMOTE_POLL_IDLE: Duration = Duration::from_secs(20);
|
|
const REMOTE_FRESH: Duration = Duration::from_secs(45);
|
|
const DEVICES_FRESH: Duration = Duration::from_secs(12);
|
|
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(280);
|
|
const TOAST_LIFETIME: Duration = Duration::from_millis(3200);
|
|
const OPTIMISTIC_HOLD: Duration = Duration::from_millis(2500);
|
|
|
|
/// How long a context the app just started is shown as playing while
|
|
/// Spotify's state catches up. During a local takeover the cluster can
|
|
/// report the old context, then the new, then the old again; the whole
|
|
/// dance settles well inside this window, so no early hand-back.
|
|
const ASSUMED_CONTEXT_HOLD: Duration = Duration::from_secs(8);
|
|
/// How long the interface trusts its own play/pause over a polled state that
|
|
/// has not caught up yet. Spotify can take a moment to report a command it
|
|
/// has already carried out, and a button that springs back looks broken.
|
|
const PLAYBACK_HOLD: Duration = Duration::from_secs(6);
|
|
/// A second look after a command, so the button settles quickly rather than
|
|
/// waiting for the ordinary poll.
|
|
const REMOTE_RECHECK: Duration = Duration::from_millis(1200);
|
|
const CONTAINS_BATCH: usize = 40;
|
|
|
|
pub struct RemoteSnapshot {
|
|
pub state: PlaybackState,
|
|
pub received_at: Instant,
|
|
}
|
|
|
|
/// A context the interface asked Spotify to play and shows as playing
|
|
/// before any state says so.
|
|
struct AssumedContext {
|
|
uri: String,
|
|
/// `Some` when the play asked for shuffle too.
|
|
shuffle: Option<bool>,
|
|
at: Instant,
|
|
}
|
|
|
|
/// The playing item as the interface sees it, whichever device plays it.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct NowPlaying {
|
|
pub local: bool,
|
|
pub device_name: Option<String>,
|
|
pub uri: String,
|
|
pub id: Option<String>,
|
|
pub title: String,
|
|
pub artists: Vec<ArtistRef>,
|
|
pub subtitle: String,
|
|
pub album_name: String,
|
|
pub album_id: Option<String>,
|
|
pub show_id: Option<String>,
|
|
pub art_url: Option<String>,
|
|
pub art_small: Option<String>,
|
|
pub duration_ms: u32,
|
|
pub position_ms: u32,
|
|
pub playing: bool,
|
|
pub loading: bool,
|
|
pub shuffle: bool,
|
|
pub repeat: RepeatMode,
|
|
pub volume_percent: u8,
|
|
pub can_control: bool,
|
|
pub is_episode: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum Target {
|
|
Local,
|
|
Remote(Option<String>),
|
|
}
|
|
|
|
/// How the application is being started.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct AppOptions {
|
|
/// Register the MPRIS media-control service (Linux).
|
|
pub media_controls: bool,
|
|
/// Register the system-tray item (Linux).
|
|
pub tray: bool,
|
|
}
|
|
|
|
impl Default for AppOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
media_controls: true,
|
|
tray: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct App {
|
|
pub dirs: AppDirs,
|
|
pub settings: Settings,
|
|
settings_dirty: bool,
|
|
last_settings_save: Instant,
|
|
pub backend: Backend,
|
|
media_controls: Option<MediaService>,
|
|
tray: Option<TrayService>,
|
|
pub window_hidden: bool,
|
|
/// The window should close but the process should stay in the tray.
|
|
pub hide_intent: bool,
|
|
/// A hidden app was asked to show itself; the outer loop recreates the
|
|
/// window.
|
|
pub wants_show: bool,
|
|
/// The window should close and reopen at once as the other kind: the
|
|
/// big window or the Winamp mini player.
|
|
pub switch_intent: bool,
|
|
/// Commands from control clients (a second `fastpotify <verb>` launch,
|
|
/// a Raycast script), on the platforms where they do not arrive through
|
|
/// MPRIS. Drained every frame.
|
|
control_commands: Option<std::sync::Arc<std::sync::Mutex<Vec<ControlCommand>>>>,
|
|
/// Where the now-playing snapshot goes for the control channel's
|
|
/// `nowplaying` verb to answer from.
|
|
control_now_playing: Option<std::sync::Arc<std::sync::Mutex<String>>>,
|
|
/// The same, for its `devices` verb.
|
|
control_devices: Option<std::sync::Arc<std::sync::Mutex<String>>>,
|
|
/// Whether that device slot still matches [`Self::devices`]. The
|
|
/// now-playing snapshot is rebuilt every frame because its position
|
|
/// moves every frame; a device list changes when Spotify answers, which
|
|
/// is seconds apart, so it is written when it changes instead.
|
|
control_devices_stale: bool,
|
|
/// Sample data is loaded and nothing is asked of Spotify.
|
|
pub offline: bool,
|
|
pub palette: Palette,
|
|
applied_dark: Option<bool>,
|
|
|
|
pub auth: AuthStatus,
|
|
pub user: Option<User>,
|
|
pub local_device_id: Option<String>,
|
|
/// Local playback is authorized and the engine is connected.
|
|
pub local_ready: bool,
|
|
pub local_playback: LocalPlayback,
|
|
pub local: LocalState,
|
|
pub remote: Option<RemoteSnapshot>,
|
|
remote_polled_at: Instant,
|
|
remote_poll_pending: bool,
|
|
/// Serial of the newest playback poll sent; older answers are stale.
|
|
remote_poll_seq: u64,
|
|
/// The restorable session (sorts, recents, resume point) changed and
|
|
/// should be written shortly, not only at exit.
|
|
session_dirty: bool,
|
|
last_session_save: Instant,
|
|
/// The saved zoom has been applied to the context once.
|
|
zoom_applied: bool,
|
|
pub devices: Vec<Device>,
|
|
/// Receivers seen on the local network. Spotify lists a receiver only
|
|
/// once it has an account, so these are the ones it cannot see yet.
|
|
pub receivers: Vec<crate::zeroconf::Receiver>,
|
|
/// The receiver currently being handed the account, by name.
|
|
pub activating_receiver: Option<String>,
|
|
pub devices_loading: bool,
|
|
devices_fetched_at: Option<Instant>,
|
|
pub selected_device: Option<String>,
|
|
pub queue: Loadable<Queue>,
|
|
queue_fetched_at: Option<Instant>,
|
|
|
|
pub library: Library,
|
|
pub home: HomeData,
|
|
pub search: SearchState,
|
|
pub playlist_pages: HashMap<String, PlaylistPage>,
|
|
load_generation: u64,
|
|
pub album_pages: HashMap<String, AlbumPage>,
|
|
pub artist_pages: HashMap<String, ArtistPage>,
|
|
pub show_pages: HashMap<String, ShowPage>,
|
|
pub track_cache: HashMap<String, Track>,
|
|
track_requests: HashSet<String>,
|
|
|
|
pub history: Vec<Page>,
|
|
pub history_index: usize,
|
|
|
|
pub saved: HashMap<String, bool>,
|
|
saved_pending: HashSet<String>,
|
|
pub accents: HashMap<String, Color32>,
|
|
accent_pending: HashSet<String>,
|
|
|
|
pub dialog: Option<Dialog>,
|
|
pub show_queue_panel: bool,
|
|
pub show_lyrics_panel: bool,
|
|
/// The track the lyrics below are for.
|
|
pub lyrics_uri: Option<String>,
|
|
/// `Loaded(None)` when nobody has transcribed the track.
|
|
pub lyrics: Loadable<Option<crate::lyrics::Lyrics>>,
|
|
/// Whether the panel scrolls to the line being sung. Off once the
|
|
/// reader scrolls by hand, on again with the Follow button or a new
|
|
/// track.
|
|
pub lyrics_following: bool,
|
|
/// The line the panel last positioned itself for (`Some(None)` before
|
|
/// the first line), so it moves once per change; `None` until it has
|
|
/// positioned itself at all for this track.
|
|
pub lyrics_line_shown: Option<Option<usize>>,
|
|
pub show_devices: bool,
|
|
pub toasts: Vec<Toast>,
|
|
pub actions: Vec<Action>,
|
|
volume_before_mute: Option<u8>,
|
|
/// What was just asked to play, until Spotify visibly reacts: the keys
|
|
/// (context and track URIs) whose play buttons show a spinner.
|
|
pending_play_keys: Vec<String>,
|
|
pending_play_at: Option<Instant>,
|
|
/// A play request made while the local engine was still connecting; it
|
|
/// starts the moment the engine reports ready.
|
|
queued_play: Option<PlayRequest>,
|
|
/// The plain list of songs local playback was last given, so autoplay
|
|
/// can carry on from its last one when it ends: librespot only
|
|
/// continues from a context with a URI, and a list has none.
|
|
local_list: Option<Vec<String>>,
|
|
/// A receiver just activated, waiting for Spotify to list it so playback
|
|
/// can move there.
|
|
pending_transfer_to: Option<(String, Instant)>,
|
|
/// When to take a confirming look at remote playback after a command.
|
|
remote_recheck_at: Option<Instant>,
|
|
pub seek_preview: Option<f32>,
|
|
pub volume_preview: Option<f32>,
|
|
/// Window geometry to restore on next attach, from the session file.
|
|
session_window_size: Option<[f32; 2]>,
|
|
session_window_pos: Option<[f32; 2]>,
|
|
/// Last observed window geometry, updated each frame for saving.
|
|
last_window_size: Option<[f32; 2]>,
|
|
last_window_pos: Option<[f32; 2]>,
|
|
last_eviction: Instant,
|
|
pub sign_in_url: Option<String>,
|
|
/// The verified personal Web API application, when acceleration is ready.
|
|
pub web_app: Option<String>,
|
|
pending_remote_position: Option<(u32, Instant)>,
|
|
pending_remote_volume: Option<(u8, Instant)>,
|
|
/// A local volume set here that the engine has not echoed back yet. It
|
|
/// reports `VolumeChanged` asynchronously while position snapshots land
|
|
/// every second, so a snapshot must not undo the change on its way past.
|
|
pending_local_volume: Option<(u16, Instant)>,
|
|
optimistic_playing: Option<(bool, Instant)>,
|
|
/// The track a play just asked for, marked current at once; the
|
|
/// engine's report (or time) hands back to the reported state.
|
|
intent_track: Option<(String, Instant)>,
|
|
/// Shuffle as the listener set it: a mode, not a property of one
|
|
/// context. Every play of a context applies it until turned off.
|
|
shuffle_wanted: bool,
|
|
/// When the listener last set shuffle here, so an echo of that same
|
|
/// change from the engine is not mistaken for another client's toggle.
|
|
shuffle_set_at: Option<Instant>,
|
|
/// When tracks recently came up unavailable, to spot a key-service
|
|
/// cascade and reconnect once instead of skipping through an album.
|
|
unavailable_at: Vec<Instant>,
|
|
last_unavailable_reconnect: Option<Instant>,
|
|
/// The Premium notice has been shown for this sign-in.
|
|
premium_notice_shown: bool,
|
|
/// The context the interface just started, shown as playing until
|
|
/// Spotify's own state says the same thing.
|
|
assumed_context: Option<AssumedContext>,
|
|
last_now_playing_uri: Option<String>,
|
|
pub playlist_busy: bool,
|
|
pub quit_requested: bool,
|
|
/// The axis a scroll gesture settled on, and when it last moved.
|
|
scroll_lock: Option<(ScrollAxis, Instant)>,
|
|
/// Whether the current scroll gesture comes from a trackpad.
|
|
scroll_from_trackpad: bool,
|
|
/// Recent scroll positions, to read the gesture's speed when it ends.
|
|
scroll_history: egui::util::History<egui::Vec2>,
|
|
/// Where the gesture has scrolled to so far, for the history.
|
|
scroll_accum: egui::Vec2,
|
|
/// The speed still carrying the page after the fingers lifted.
|
|
glide: Option<egui::Vec2>,
|
|
/// When the last scroll event arrived, for lifts nobody announces.
|
|
scroll_last_event: Option<Instant>,
|
|
/// How each table is sorted, per page, for as long as the app runs.
|
|
pub table_sorts: HashMap<Page, TableSort>,
|
|
/// User ids resolved to display names; `None` while unknown, so an id
|
|
/// is asked about only once per run.
|
|
pub user_names: HashMap<String, Option<String>>,
|
|
pub user_names_revision: u64,
|
|
/// Context URIs most recently played, newest first: the sidebar's
|
|
/// order. Kept with the session, so it survives a restart.
|
|
pub recent_contexts: Vec<String>,
|
|
/// What was playing when the app last closed, to resume from cold.
|
|
resume_context: Option<String>,
|
|
resume_track: Option<String>,
|
|
resume_position_ms: u32,
|
|
/// A newer release than this build, once GitHub has said so.
|
|
pub update: Option<crate::updates::Release>,
|
|
last_update_check: Option<Instant>,
|
|
/// The Winamp window and the skin it wears.
|
|
pub winamp: crate::winamp::WinampState,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum ScrollAxis {
|
|
Horizontal,
|
|
Vertical,
|
|
}
|
|
|
|
/// A trackpad gesture that pauses this long has ended; the next movement
|
|
/// picks its axis afresh.
|
|
const SCROLL_GESTURE_GAP: Duration = Duration::from_millis(150);
|
|
|
|
/// How far short Linux trackpad deltas land of what other players scroll.
|
|
const TRACKPAD_SCALE: f32 = 1.8;
|
|
|
|
/// The glide's exponential decay time, in seconds; the speed below which a
|
|
/// lift starts no glide; and the speed at which a glide stops, points per
|
|
/// second.
|
|
const GLIDE_DECAY: f32 = 0.35;
|
|
const GLIDE_START: f32 = 120.0;
|
|
const GLIDE_STOP: f32 = 40.0;
|
|
|
|
impl App {
|
|
pub fn new(waker: &Waker, dirs: AppDirs, settings: Settings, options: AppOptions) -> Self {
|
|
let tap = crate::vis::AudioTap::new();
|
|
let eq = crate::eq::shared();
|
|
if let Ok(mut shared) = eq.lock() {
|
|
*shared = eq_settings(&settings);
|
|
}
|
|
let engine_config = engine_config(
|
|
&dirs,
|
|
&settings,
|
|
std::sync::Arc::clone(&tap),
|
|
std::sync::Arc::clone(&eq),
|
|
);
|
|
let backend = Backend::spawn(
|
|
dirs.clone(),
|
|
engine_config,
|
|
settings.web_client_id.clone(),
|
|
waker.clone(),
|
|
);
|
|
let wake = waker.clone();
|
|
let media_controls = options
|
|
.media_controls
|
|
.then(|| MediaService::spawn(move || wake.wake()));
|
|
let wake = waker.clone();
|
|
let tray = options
|
|
.tray
|
|
.then(|| TrayService::spawn(move || wake.wake()))
|
|
.flatten();
|
|
|
|
let session = SessionState::load(&dirs.session_file());
|
|
let first_page = session
|
|
.last_page
|
|
.as_deref()
|
|
.and_then(Page::decode)
|
|
.filter(|page| !matches!(page, Page::Settings | Page::Queue))
|
|
.unwrap_or(Page::Home);
|
|
|
|
let mut app = Self {
|
|
dirs,
|
|
settings,
|
|
settings_dirty: false,
|
|
last_settings_save: Instant::now(),
|
|
backend,
|
|
media_controls,
|
|
tray,
|
|
window_hidden: false,
|
|
hide_intent: false,
|
|
wants_show: false,
|
|
switch_intent: false,
|
|
control_commands: None,
|
|
control_now_playing: None,
|
|
control_devices: None,
|
|
control_devices_stale: true,
|
|
offline: false,
|
|
palette: Palette::dark(),
|
|
applied_dark: None,
|
|
auth: AuthStatus::Starting,
|
|
user: None,
|
|
local_device_id: None,
|
|
local_ready: false,
|
|
local_playback: LocalPlayback::Unavailable,
|
|
local: LocalState::default(),
|
|
remote: None,
|
|
remote_polled_at: Instant::now() - REMOTE_POLL_IDLE,
|
|
remote_poll_pending: false,
|
|
remote_poll_seq: 0,
|
|
session_dirty: false,
|
|
last_session_save: Instant::now(),
|
|
zoom_applied: false,
|
|
devices: Vec::new(),
|
|
receivers: Vec::new(),
|
|
activating_receiver: None,
|
|
devices_loading: false,
|
|
devices_fetched_at: None,
|
|
selected_device: None,
|
|
queue: Loadable::NotLoaded,
|
|
queue_fetched_at: None,
|
|
library: Library::default(),
|
|
home: HomeData::default(),
|
|
search: SearchState::default(),
|
|
playlist_pages: HashMap::new(),
|
|
load_generation: 0,
|
|
album_pages: HashMap::new(),
|
|
artist_pages: HashMap::new(),
|
|
show_pages: HashMap::new(),
|
|
track_cache: HashMap::new(),
|
|
track_requests: HashSet::new(),
|
|
history: vec![first_page],
|
|
history_index: 0,
|
|
saved: HashMap::new(),
|
|
saved_pending: HashSet::new(),
|
|
accents: HashMap::new(),
|
|
accent_pending: HashSet::new(),
|
|
dialog: None,
|
|
show_queue_panel: session.queue_open.unwrap_or(false),
|
|
show_lyrics_panel: false,
|
|
lyrics_uri: None,
|
|
lyrics: Loadable::NotLoaded,
|
|
lyrics_following: true,
|
|
lyrics_line_shown: None,
|
|
show_devices: false,
|
|
toasts: Vec::new(),
|
|
actions: Vec::new(),
|
|
volume_before_mute: None,
|
|
pending_play_keys: Vec::new(),
|
|
pending_play_at: None,
|
|
queued_play: None,
|
|
local_list: None,
|
|
pending_transfer_to: None,
|
|
remote_recheck_at: None,
|
|
seek_preview: None,
|
|
volume_preview: None,
|
|
session_window_size: session.window_size,
|
|
session_window_pos: session.window_pos,
|
|
last_window_size: None,
|
|
last_window_pos: None,
|
|
last_eviction: Instant::now(),
|
|
sign_in_url: None,
|
|
web_app: None,
|
|
pending_remote_position: None,
|
|
pending_remote_volume: None,
|
|
pending_local_volume: None,
|
|
optimistic_playing: None,
|
|
intent_track: None,
|
|
shuffle_wanted: session.shuffle_on,
|
|
shuffle_set_at: None,
|
|
unavailable_at: Vec::new(),
|
|
last_unavailable_reconnect: None,
|
|
premium_notice_shown: false,
|
|
assumed_context: None,
|
|
last_now_playing_uri: None,
|
|
playlist_busy: false,
|
|
quit_requested: false,
|
|
scroll_lock: None,
|
|
scroll_from_trackpad: false,
|
|
scroll_history: egui::util::History::new(2..16, 0.1),
|
|
scroll_accum: egui::Vec2::ZERO,
|
|
glide: None,
|
|
scroll_last_event: None,
|
|
table_sorts: session
|
|
.sorts
|
|
.iter()
|
|
.filter_map(|(page, sort)| Some((Page::decode(page)?, *sort)))
|
|
.collect(),
|
|
user_names: HashMap::new(),
|
|
user_names_revision: 0,
|
|
recent_contexts: session.recent_contexts.clone(),
|
|
resume_context: session.last_context.clone(),
|
|
resume_track: session.last_track.clone(),
|
|
resume_position_ms: session.last_position_ms,
|
|
update: None,
|
|
last_update_check: None,
|
|
winamp: crate::winamp::WinampState::new(session.winamp_pos, tap, eq),
|
|
};
|
|
app.local.volume = app.settings.volume;
|
|
app
|
|
}
|
|
|
|
/// Watches the queue control clients fill and keeps the snapshots they
|
|
/// read -- now playing, and the device list -- fresh.
|
|
pub fn set_remote_control(&mut self, guard: &crate::single_instance::Guard) {
|
|
self.control_commands = Some(guard.commands());
|
|
self.control_now_playing = Some(guard.now_playing_slot());
|
|
self.control_devices = Some(guard.devices_slot());
|
|
}
|
|
|
|
/// Per-window setup: fonts, icons, loaders, theme. Called every time a
|
|
/// window is (re)created around this long-lived application state.
|
|
pub fn attach(&mut self, ctx: &egui::Context) {
|
|
theme::install(ctx);
|
|
ctx.add_bytes_loader(std::sync::Arc::new(self.backend.art().clone()));
|
|
ctx.set_theme(match self.settings.theme {
|
|
ThemeChoice::Dark => egui::ThemePreference::Dark,
|
|
ThemeChoice::Light => egui::ThemePreference::Light,
|
|
ThemeChoice::System => egui::ThemePreference::System,
|
|
});
|
|
self.applied_dark = None;
|
|
self.winamp.forget_textures();
|
|
self.window_hidden = false;
|
|
self.hide_intent = false;
|
|
self.wants_show = false;
|
|
self.switch_intent = false;
|
|
if let Some(tray) = &mut self.tray {
|
|
tray.attach();
|
|
}
|
|
if self.settings.winamp_window {
|
|
// The mini player sizes itself; the big window's geometry
|
|
// waits here for its return.
|
|
return;
|
|
}
|
|
if let Some(size) = self.session_window_size.take() {
|
|
// Clamp to a sane range so a stale session never creates an
|
|
// unusable window; the OS will further clamp to the monitor.
|
|
if (400.0..=3000.0).contains(&size[0]) && (300.0..=2000.0).contains(&size[1]) {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
|
|
size[0], size[1],
|
|
)));
|
|
}
|
|
}
|
|
if let Some(pos) = self.session_window_pos.take() {
|
|
// On Wayland this is a no-op. Validate against a large virtual
|
|
// desktop so a window that was on a now-disconnected monitor
|
|
// doesn't open off-screen.
|
|
if (-1000.0..=5000.0).contains(&pos[0]) && (-1000.0..=5000.0).contains(&pos[1]) {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(
|
|
pos[0], pos[1],
|
|
)));
|
|
}
|
|
}
|
|
// egui's consensus wheel speed is 40 points per line, about a third
|
|
// of what every other player scrolls per notch; trackpads report
|
|
// pixels and are unaffected (#32).
|
|
ctx.options_mut(|options| options.input_options.line_scroll_speed = 120.0);
|
|
}
|
|
|
|
/// The window is gone but the process stays: audio, the tray, and the
|
|
/// media controls keep running until Show or Quit.
|
|
pub fn window_gone(&mut self) {
|
|
// The Winamp window went with it; it comes back where it was.
|
|
self.winamp.remember_position();
|
|
self.winamp.forget_textures();
|
|
self.window_hidden = true;
|
|
self.hide_intent = false;
|
|
self.wants_show = false;
|
|
if let Some(tray) = &mut self.tray {
|
|
tray.hidden();
|
|
}
|
|
}
|
|
|
|
/// Whether closing the window keeps the app in the tray rather than
|
|
/// quitting.
|
|
pub fn hides_to_tray(&self) -> bool {
|
|
self.tray.is_some() && self.settings.keep_playing_in_background
|
|
}
|
|
|
|
// ---- derived state -----------------------------------------------------
|
|
|
|
pub fn page(&self) -> &Page {
|
|
&self.history[self.history_index]
|
|
}
|
|
|
|
pub fn is_connected(&self) -> bool {
|
|
matches!(self.auth, AuthStatus::Connected { .. })
|
|
}
|
|
|
|
pub fn user_id(&self) -> Option<&str> {
|
|
self.user.as_ref().map(|user| user.id.as_str())
|
|
}
|
|
|
|
pub fn is_saved(&self, uri: &str) -> Option<bool> {
|
|
self.saved.get(uri).copied()
|
|
}
|
|
|
|
fn remote_fresh(&self) -> Option<&RemoteSnapshot> {
|
|
self.remote
|
|
.as_ref()
|
|
.filter(|remote| remote.received_at.elapsed() < REMOTE_FRESH)
|
|
}
|
|
|
|
/// Where playback commands go: this computer's player or a remote device.
|
|
pub fn target(&self) -> Target {
|
|
if self.local_ready && self.local.is_active() {
|
|
return Target::Local;
|
|
}
|
|
if let Some(selected) = &self.selected_device
|
|
&& Some(selected.as_str()) != self.local_device_id.as_deref()
|
|
{
|
|
return Target::Remote(Some(selected.clone()));
|
|
}
|
|
if let Some(remote) = self.remote_fresh() {
|
|
let device = remote.state.device.as_ref();
|
|
let is_local = device
|
|
.and_then(|device| device.id.as_deref())
|
|
.is_some_and(|id| Some(id) == self.local_device_id.as_deref());
|
|
if !is_local && (remote.state.is_playing || remote.state.item.is_some()) {
|
|
return Target::Remote(device.and_then(|device| device.id.clone()));
|
|
}
|
|
}
|
|
if self.local_ready {
|
|
Target::Local
|
|
} else {
|
|
Target::Remote(None)
|
|
}
|
|
}
|
|
|
|
/// The context playing as the interface should show it: the one just
|
|
/// asked for until Spotify's state confirms it, then Spotify's own.
|
|
pub fn playing_context_uri(&self) -> Option<String> {
|
|
let remote = self
|
|
.remote
|
|
.as_ref()
|
|
.and_then(|remote| remote.state.context.as_ref())
|
|
.map(|context| context.uri.clone());
|
|
if let Some(assumed) = &self.assumed_context {
|
|
let held = assumed.at.elapsed() < ASSUMED_CONTEXT_HOLD;
|
|
// A view of a context plays as plain tracks, so no state will
|
|
// ever name the context: the assumption stands while nothing
|
|
// names another one and something is believed to be playing.
|
|
let contradicted = remote.as_deref().is_some_and(|uri| uri != assumed.uri);
|
|
if held || (!contradicted && self.believed_playing()) {
|
|
return Some(assumed.uri.clone());
|
|
}
|
|
}
|
|
remote
|
|
}
|
|
|
|
/// Whether the playing context shuffles, honouring a shuffle the
|
|
/// interface just asked for ahead of Spotify's state.
|
|
/// The track the interface should mark as current: the one a click
|
|
/// just asked for, until a report names it or the moment passes.
|
|
pub fn current_track_uri(&self) -> Option<String> {
|
|
if let Some((uri, at)) = &self.intent_track
|
|
&& at.elapsed() < PLAYBACK_HOLD
|
|
{
|
|
return Some(uri.clone());
|
|
}
|
|
self.now_playing().map(|now| now.uri)
|
|
}
|
|
|
|
/// Whether something plays, as the interface should show it: what it
|
|
/// just asked for, before any state reports back.
|
|
pub fn believed_playing(&self) -> bool {
|
|
if let Some((playing, at)) = self.optimistic_playing
|
|
&& at.elapsed() < PLAYBACK_HOLD
|
|
{
|
|
return playing;
|
|
}
|
|
self.now_playing().is_some_and(|now| now.playing)
|
|
}
|
|
|
|
pub fn playing_context_shuffle(&self) -> bool {
|
|
self.shuffle_wanted
|
|
}
|
|
|
|
/// The playing thing as a playable item, for menus that act on it:
|
|
/// the cached full track when known, a minimal one otherwise.
|
|
pub fn now_playing_item(&self) -> Option<PlayableItem> {
|
|
let now = self.now_playing()?;
|
|
if now.is_episode {
|
|
return None;
|
|
}
|
|
if let Some(track) = now.id.as_deref().and_then(|id| self.track_cache.get(id)) {
|
|
return Some(PlayableItem::Track(track.clone()));
|
|
}
|
|
Some(PlayableItem::Track(Track {
|
|
id: now.id.clone(),
|
|
uri: now.uri.clone(),
|
|
name: now.title.clone(),
|
|
artists: now.artists.clone(),
|
|
duration_ms: now.duration_ms,
|
|
..Track::default()
|
|
}))
|
|
}
|
|
|
|
pub fn now_playing(&self) -> Option<NowPlaying> {
|
|
if self.local.is_active() {
|
|
let track = self.local.track.as_ref()?;
|
|
let cached = track
|
|
.uri
|
|
.rsplit(':')
|
|
.next()
|
|
.and_then(|id| self.track_cache.get(id));
|
|
let artists = cached
|
|
.map(|cached| cached.artists.clone())
|
|
.unwrap_or_else(|| {
|
|
track
|
|
.artists
|
|
.iter()
|
|
.map(|name| ArtistRef {
|
|
id: None,
|
|
name: name.clone(),
|
|
uri: None,
|
|
})
|
|
.collect()
|
|
});
|
|
let playing = match self.optimistic_playing {
|
|
Some((playing, at)) if at.elapsed() < PLAYBACK_HOLD => playing,
|
|
_ => self.local.playback == Playback::Playing,
|
|
};
|
|
return Some(NowPlaying {
|
|
local: true,
|
|
device_name: None,
|
|
uri: track.uri.clone(),
|
|
id: util::uri_id(&track.uri).map(str::to_string),
|
|
title: track.title.clone(),
|
|
subtitle: track.artist_names(),
|
|
artists,
|
|
album_name: track.album.clone(),
|
|
album_id: cached
|
|
.and_then(|cached| cached.album.as_ref())
|
|
.map(|album| album.id.clone()),
|
|
show_id: None,
|
|
art_url: track.art_url.clone(),
|
|
art_small: track
|
|
.art_small_url
|
|
.clone()
|
|
.or_else(|| track.art_url.clone()),
|
|
duration_ms: track.duration_ms,
|
|
position_ms: self.local.position_now(),
|
|
playing,
|
|
loading: self.local.playback == Playback::Loading,
|
|
shuffle: self.shuffle_wanted,
|
|
repeat: self.local.repeat,
|
|
volume_percent: volume_to_percent(self.local.volume),
|
|
can_control: true,
|
|
is_episode: track.is_episode,
|
|
});
|
|
}
|
|
let remote = self.remote_fresh()?;
|
|
// When the snapshot names this very computer as its device, the
|
|
// local engine is the truth, and it just said it has nothing: a
|
|
// poll from before a stop must not say otherwise.
|
|
if self.local_device_id.is_some()
|
|
&& remote
|
|
.state
|
|
.device
|
|
.as_ref()
|
|
.and_then(|device| device.id.as_deref())
|
|
== self.local_device_id.as_deref()
|
|
{
|
|
return None;
|
|
}
|
|
let item = remote.state.item.as_ref()?;
|
|
let device = remote.state.device.as_ref();
|
|
let playing = match self.optimistic_playing {
|
|
Some((playing, at)) if at.elapsed() < PLAYBACK_HOLD => playing,
|
|
_ => remote.state.is_playing,
|
|
};
|
|
let position = match self.pending_remote_position {
|
|
Some((position, at)) if at.elapsed() < OPTIMISTIC_HOLD => position,
|
|
_ => {
|
|
let base = remote.state.progress_ms.unwrap_or(0);
|
|
if remote.state.is_playing {
|
|
(base as u64 + remote.received_at.elapsed().as_millis() as u64)
|
|
.min(item.duration_ms() as u64) as u32
|
|
} else {
|
|
base
|
|
}
|
|
}
|
|
};
|
|
let volume = match self.pending_remote_volume {
|
|
Some((volume, at)) if at.elapsed() < OPTIMISTIC_HOLD => volume,
|
|
_ => device
|
|
.and_then(|device| device.volume_percent)
|
|
.unwrap_or(50),
|
|
};
|
|
let (artists, album_name, album_id, show_id, is_episode) = match item {
|
|
PlayableItem::Track(track) => (
|
|
track.artists.clone(),
|
|
track
|
|
.album
|
|
.as_ref()
|
|
.map(|album| album.name.clone())
|
|
.unwrap_or_default(),
|
|
track.album.as_ref().map(|album| album.id.clone()),
|
|
None,
|
|
false,
|
|
),
|
|
PlayableItem::Episode(episode) => (
|
|
Vec::new(),
|
|
episode
|
|
.show
|
|
.as_ref()
|
|
.map(|show| show.name.clone())
|
|
.unwrap_or_default(),
|
|
None,
|
|
episode.show.as_ref().map(|show| show.id.clone()),
|
|
true,
|
|
),
|
|
};
|
|
Some(NowPlaying {
|
|
local: false,
|
|
device_name: device.map(|device| device.name.clone()),
|
|
uri: item.uri().to_string(),
|
|
id: item.id().map(str::to_string),
|
|
title: item.name().to_string(),
|
|
subtitle: item.subtitle(),
|
|
artists,
|
|
album_name,
|
|
album_id,
|
|
show_id,
|
|
art_url: item.image(640).map(str::to_string),
|
|
art_small: item.image(64).map(str::to_string),
|
|
duration_ms: item.duration_ms(),
|
|
position_ms: position,
|
|
playing,
|
|
loading: false,
|
|
shuffle: self.shuffle_wanted,
|
|
repeat: RepeatMode::from_api(&remote.state.repeat_state),
|
|
volume_percent: volume,
|
|
can_control: device.is_none_or(|device| !device.is_restricted),
|
|
is_episode,
|
|
})
|
|
}
|
|
|
|
/// The play request for `key` (a context or track URI) is still waiting
|
|
/// for Spotify to react.
|
|
pub fn play_pending(&self, key: &str) -> bool {
|
|
self.pending_fresh() && self.pending_play_keys.iter().any(|k| k == key)
|
|
}
|
|
|
|
pub fn set_user_name(&mut self, id: String, name: Option<String>) {
|
|
if self.user_names.get(&id) != Some(&name) {
|
|
self.user_names.insert(id, name);
|
|
self.user_names_revision = self.user_names_revision.wrapping_add(1);
|
|
}
|
|
}
|
|
|
|
pub fn any_play_pending(&self) -> bool {
|
|
self.pending_fresh() && !self.pending_play_keys.is_empty()
|
|
}
|
|
|
|
fn pending_fresh(&self) -> bool {
|
|
// A request queued behind a connecting engine stays pending for as
|
|
// long as the engine may take; an ordinary request times out fast.
|
|
self.queued_play.is_some()
|
|
|| self
|
|
.pending_play_at
|
|
.is_some_and(|at| at.elapsed() < Duration::from_secs(8))
|
|
}
|
|
|
|
fn set_play_pending(&mut self, keys: Vec<String>) {
|
|
self.pending_play_keys = keys;
|
|
self.pending_play_at = Some(Instant::now());
|
|
}
|
|
|
|
fn clear_play_pending(&mut self) {
|
|
self.pending_play_keys.clear();
|
|
self.pending_play_at = None;
|
|
}
|
|
|
|
/// The colour to tint the interface with, from the playing art.
|
|
pub fn now_playing_tint(&self) -> Option<Color32> {
|
|
if !self.settings.accent_from_art {
|
|
return None;
|
|
}
|
|
let now = self.now_playing()?;
|
|
let url = now.art_small.or(now.art_url)?;
|
|
self.accents.get(&url).copied()
|
|
}
|
|
|
|
pub fn tint_for(&mut self, url: Option<&str>) -> Option<Color32> {
|
|
let url = url?;
|
|
if let Some(color) = self.accents.get(url) {
|
|
return Some(*color);
|
|
}
|
|
if self.accent_pending.insert(url.to_string()) {
|
|
self.backend.send(Command::Accent {
|
|
url: url.to_string(),
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
// ---- frame ---------------------------------------------------------------
|
|
|
|
fn handle_events(&mut self) {
|
|
for event in self.backend.poll() {
|
|
if self.offline {
|
|
continue;
|
|
}
|
|
match event {
|
|
Event::Auth(status) => self.handle_auth(status),
|
|
Event::Playback(status) => self.handle_playback(status),
|
|
Event::Receivers(receivers) => self.receivers = receivers,
|
|
Event::ReceiverActivated { name, result } => {
|
|
self.activating_receiver = None;
|
|
match result {
|
|
Ok(()) => {
|
|
self.toast(format!("{name} is ready"));
|
|
// It takes a moment to appear in the device list.
|
|
self.pending_transfer_to = Some((name, Instant::now()));
|
|
self.devices_fetched_at = None;
|
|
self.refresh_devices();
|
|
}
|
|
Err(error) => self.toast_error(format!("{name}: {error}")),
|
|
}
|
|
}
|
|
Event::Local(state) => self.handle_local(*state),
|
|
Event::Api(response) => self.handle_api(*response),
|
|
Event::Accent { url, color } => {
|
|
self.accent_pending.remove(&url);
|
|
let tint = self.palette.tint_from_art(color);
|
|
self.accents.insert(url, tint);
|
|
}
|
|
Event::Error(message) => self.toast_error(message),
|
|
Event::Lyrics { uri, result } => {
|
|
if self.lyrics_uri.as_deref() == Some(uri.as_str()) {
|
|
self.lyrics = match result {
|
|
Ok(found) => Loadable::Loaded(found),
|
|
Err(error) => Loadable::Failed(error),
|
|
};
|
|
}
|
|
}
|
|
Event::PlaylistCache {
|
|
account_id,
|
|
id,
|
|
snapshot,
|
|
items,
|
|
} => {
|
|
if self.user_id() != Some(account_id.as_str()) {
|
|
continue;
|
|
}
|
|
if let Some(page) = self.playlist_pages.get_mut(&id) {
|
|
page.pending_cache = Some((snapshot, items));
|
|
}
|
|
self.try_adopt_playlist_cache(&id);
|
|
}
|
|
Event::UserName { id, name } => {
|
|
self.set_user_name(id, name);
|
|
}
|
|
Event::WebApp { client_id } => self.web_app = client_id,
|
|
Event::UpdateAvailable { version, url } => {
|
|
let notice = crate::updates::Release { version, url };
|
|
if self.update.as_ref() != Some(¬ice) {
|
|
self.toast(format!("Fastpotify {} is out", notice.version));
|
|
}
|
|
self.update = Some(notice);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_auth(&mut self, status: AuthStatus) {
|
|
match &status {
|
|
AuthStatus::Connected { .. } => {
|
|
self.sign_in_url = None;
|
|
self.reset_data();
|
|
self.load_playlists();
|
|
self.ensure_loaded(self.page().clone());
|
|
self.poll_remote(true);
|
|
}
|
|
AuthStatus::WaitingForBrowser { url } => self.sign_in_url = Some(url.clone()),
|
|
AuthStatus::SignedOut => {
|
|
self.sign_in_url = None;
|
|
self.web_app = None;
|
|
self.user = None;
|
|
self.premium_notice_shown = false;
|
|
self.local = LocalState::default();
|
|
self.local_ready = false;
|
|
self.local_device_id = None;
|
|
self.local_playback = LocalPlayback::Unavailable;
|
|
self.remote = None;
|
|
self.reset_data();
|
|
}
|
|
AuthStatus::Failed(message) => {
|
|
self.sign_in_url = None;
|
|
self.toast_error(message.clone());
|
|
}
|
|
_ => {}
|
|
}
|
|
self.auth = status;
|
|
}
|
|
|
|
fn handle_playback(&mut self, status: LocalPlayback) {
|
|
match &status {
|
|
LocalPlayback::Ready { device_id } => {
|
|
self.local_device_id = Some(device_id.clone());
|
|
self.local_ready = true;
|
|
if let Some(request) = self.queued_play.take() {
|
|
self.play_request(request, false);
|
|
}
|
|
}
|
|
LocalPlayback::Unavailable => {
|
|
self.local_ready = false;
|
|
self.local_device_id = None;
|
|
}
|
|
LocalPlayback::Failed(message) => {
|
|
self.local_ready = false;
|
|
if self.queued_play.take().is_some() {
|
|
self.clear_play_pending();
|
|
}
|
|
self.toast_error(format!("Local playback: {message}"));
|
|
}
|
|
LocalPlayback::Authorizing | LocalPlayback::Connecting => {}
|
|
}
|
|
self.local_playback = status;
|
|
}
|
|
|
|
fn reset_data(&mut self) {
|
|
self.library = Library::default();
|
|
self.home = HomeData::default();
|
|
self.playlist_pages.clear();
|
|
self.album_pages.clear();
|
|
self.artist_pages.clear();
|
|
self.show_pages.clear();
|
|
self.saved.clear();
|
|
self.saved_pending.clear();
|
|
self.queue = Loadable::NotLoaded;
|
|
self.devices.clear();
|
|
self.control_devices_stale = true;
|
|
self.devices_fetched_at = None;
|
|
self.search.results = Loadable::NotLoaded;
|
|
self.search.committed.clear();
|
|
}
|
|
|
|
fn handle_local(&mut self, state: LocalState) {
|
|
let track_changed = state.track != self.local.track;
|
|
let reconnected = state.connected && !self.local.connected;
|
|
if state.shuffle != self.local.shuffle
|
|
&& self
|
|
.shuffle_set_at
|
|
.is_none_or(|at| at.elapsed() > Duration::from_secs(5))
|
|
{
|
|
// Another client toggled it; that is the listener too.
|
|
self.shuffle_wanted = state.shuffle;
|
|
}
|
|
if state.playback != self.local.playback {
|
|
self.optimistic_playing = None;
|
|
if matches!(state.playback, Playback::Playing | Playback::Loading) {
|
|
self.clear_play_pending();
|
|
}
|
|
}
|
|
if state.track != self.local.track {
|
|
self.clear_play_pending();
|
|
if let (Some(track), Some((intent, _))) = (&state.track, &self.intent_track)
|
|
&& track.uri == *intent
|
|
{
|
|
// The engine reports the very track that was asked for.
|
|
self.intent_track = None;
|
|
}
|
|
}
|
|
let held_volume = self.held_local_volume(state.volume);
|
|
if held_volume.is_none() && state.volume != self.settings.volume {
|
|
self.settings.volume = state.volume;
|
|
self.settings_dirty = true;
|
|
}
|
|
if state.seek_sequence != self.local.seek_sequence
|
|
&& let Some(controls) = &self.media_controls
|
|
{
|
|
controls.seeked(state.position_ms);
|
|
}
|
|
if let Some(error) = &state.error
|
|
&& self.local.error.as_deref() != Some(error.as_str())
|
|
{
|
|
self.toast_error(error.clone());
|
|
// One unavailable track is Spotify's catalogue; several in a
|
|
// row is the session's audio-key service gone bad, which
|
|
// leaves librespot feeding the decoder encrypted bytes and
|
|
// skipping through the whole album. A fresh session cures it.
|
|
if error.starts_with("This item isn't available") {
|
|
let now = Instant::now();
|
|
self.unavailable_at
|
|
.retain(|at| now.duration_since(*at) < Duration::from_secs(20));
|
|
self.unavailable_at.push(now);
|
|
if self.unavailable_at.len() >= 3
|
|
&& self
|
|
.last_unavailable_reconnect
|
|
.is_none_or(|at| at.elapsed() > Duration::from_secs(60))
|
|
{
|
|
self.unavailable_at.clear();
|
|
self.last_unavailable_reconnect = Some(now);
|
|
self.backend.send(Command::Reconnect);
|
|
self.toast("Spotify's audio service faltered; reconnecting local playback");
|
|
}
|
|
}
|
|
}
|
|
if let Some(seed) = autoplay_seed(
|
|
self.local_list.as_deref(),
|
|
self.settings.autoplay,
|
|
&self.local,
|
|
&state,
|
|
) {
|
|
log::info!("the list ended; playing what Spotify follows {seed} with");
|
|
self.local_list = None;
|
|
self.backend.player(PlayerCommand::Load(LoadSpec {
|
|
context_uri: Some(seed),
|
|
play: true,
|
|
autoplay: true,
|
|
..LoadSpec::default()
|
|
}));
|
|
}
|
|
self.local = state;
|
|
if let Some(volume) = held_volume {
|
|
self.local.volume = volume;
|
|
}
|
|
if track_changed {
|
|
self.on_now_playing_changed();
|
|
}
|
|
if reconnected {
|
|
if let Some(request) = self.queued_play.take() {
|
|
self.play_request(request, false);
|
|
}
|
|
// Names asked about before the session existed never got an
|
|
// answer and showed as bare ids; ask again now that someone
|
|
// can answer.
|
|
let unresolved: Vec<String> = self
|
|
.user_names
|
|
.iter()
|
|
.filter(|(_, name)| name.is_none())
|
|
.map(|(id, _)| id.clone())
|
|
.collect();
|
|
for id in &unresolved {
|
|
self.user_names.remove(id);
|
|
}
|
|
self.request_user_names(unresolved);
|
|
}
|
|
}
|
|
|
|
fn on_now_playing_changed(&mut self) {
|
|
let Some(now) = self.now_playing() else {
|
|
return;
|
|
};
|
|
if self.last_now_playing_uri.as_deref() == Some(now.uri.as_str()) {
|
|
return;
|
|
}
|
|
self.last_now_playing_uri = Some(now.uri.clone());
|
|
self.resume_context = self.playing_context_uri();
|
|
self.resume_track = Some(now.uri.clone());
|
|
self.resume_position_ms = 0;
|
|
if now.local
|
|
&& !now.is_episode
|
|
&& let Some(id) = &now.id
|
|
&& !self.track_cache.contains_key(id)
|
|
&& self.track_requests.insert(id.clone())
|
|
{
|
|
self.backend.api(ApiRequest::Track { id: id.clone() });
|
|
}
|
|
self.request_contains(vec![now.uri.clone()]);
|
|
if let Some(url) = now.art_small.or(now.art_url) {
|
|
self.tint_for(Some(&url));
|
|
}
|
|
if matches!(self.page(), Page::Queue)
|
|
|| self.show_queue_panel
|
|
|| (self.settings.winamp_window && self.settings.playlist_open)
|
|
{
|
|
self.refresh_queue(true);
|
|
}
|
|
if self.show_lyrics_panel {
|
|
self.request_lyrics();
|
|
}
|
|
}
|
|
|
|
/// Asks for the playing track's lyrics unless they are here or on the
|
|
/// way. Podcasts have no lyrics to ask for.
|
|
pub fn request_lyrics(&mut self) {
|
|
let Some(now) = self.now_playing() else {
|
|
return;
|
|
};
|
|
if self.lyrics_uri.as_deref() == Some(now.uri.as_str())
|
|
&& !matches!(self.lyrics, Loadable::NotLoaded | Loadable::Failed(_))
|
|
{
|
|
return;
|
|
}
|
|
self.lyrics_uri = Some(now.uri.clone());
|
|
self.lyrics_following = true;
|
|
self.lyrics_line_shown = None;
|
|
if now.is_episode || self.offline {
|
|
self.lyrics = Loadable::Loaded(None);
|
|
return;
|
|
}
|
|
self.lyrics = Loadable::Loading;
|
|
self.backend.send(Command::Lyrics(Box::new(LyricsRequest {
|
|
uri: now.uri,
|
|
query: crate::lyrics::Query {
|
|
artist: now
|
|
.artists
|
|
.first()
|
|
.map(|artist| artist.name.clone())
|
|
.unwrap_or_default(),
|
|
title: now.title,
|
|
album: now.album_name,
|
|
duration_ms: now.duration_ms,
|
|
},
|
|
})));
|
|
}
|
|
|
|
fn tick(&mut self, ctx: &egui::Context) {
|
|
let now = Instant::now();
|
|
if !self.zoom_applied {
|
|
self.zoom_applied = true;
|
|
let zoom = self.settings.zoom.clamp(0.5, 2.5);
|
|
if (zoom - 1.0).abs() > 0.001 {
|
|
ctx.set_zoom_factor(zoom);
|
|
}
|
|
} else {
|
|
let zoom = ctx.zoom_factor();
|
|
if (zoom - self.settings.zoom).abs() > 0.001 {
|
|
// Ctrl+plus/minus zoomed; remembered for the next start.
|
|
self.settings.zoom = zoom;
|
|
self.settings_dirty = true;
|
|
}
|
|
}
|
|
self.toasts
|
|
.retain(|toast| toast.created.elapsed() < TOAST_LIFETIME);
|
|
|
|
if self.settings.check_for_updates
|
|
&& !self.offline
|
|
&& self
|
|
.last_update_check
|
|
.is_none_or(|at| at.elapsed() >= crate::updates::CHECK_INTERVAL)
|
|
{
|
|
self.last_update_check = Some(now);
|
|
self.backend.send(Command::CheckForUpdates);
|
|
}
|
|
|
|
if self.is_connected() && !self.offline {
|
|
let interval = match self.target() {
|
|
Target::Local if self.local.is_active() => REMOTE_POLL_IDLE,
|
|
_ => REMOTE_POLL_ACTIVE,
|
|
};
|
|
if !self.remote_poll_pending && self.remote_polled_at.elapsed() >= interval {
|
|
self.poll_remote(false);
|
|
}
|
|
if let Some(due) = self.remote_recheck_at
|
|
&& Instant::now() >= due
|
|
{
|
|
self.remote_recheck_at = None;
|
|
self.poll_remote(true);
|
|
}
|
|
if self.show_devices
|
|
&& !self.devices_loading
|
|
&& self
|
|
.devices_fetched_at
|
|
.is_none_or(|at| at.elapsed() > DEVICES_FRESH)
|
|
{
|
|
self.refresh_devices();
|
|
}
|
|
let playlist_open = self.settings.winamp_window && self.settings.playlist_open;
|
|
if (self.show_queue_panel || matches!(self.page(), Page::Queue) || playlist_open)
|
|
&& !self.queue.is_loading()
|
|
&& self
|
|
.queue_fetched_at
|
|
.is_none_or(|at| at.elapsed() > Duration::from_secs(20))
|
|
{
|
|
self.refresh_queue(false);
|
|
}
|
|
}
|
|
|
|
if let Some(typed) = self.search.typed_at {
|
|
if typed.elapsed() >= SEARCH_DEBOUNCE {
|
|
self.search.typed_at = None;
|
|
let query = self.search.query.trim().to_string();
|
|
self.run_search(query);
|
|
} else {
|
|
ctx.request_repaint_after(SEARCH_DEBOUNCE - typed.elapsed());
|
|
}
|
|
}
|
|
|
|
if self.last_eviction.elapsed() > Duration::from_secs(20) {
|
|
self.last_eviction = now;
|
|
self.backend.art().evict_stale(ctx);
|
|
}
|
|
self.sync_skin(ctx);
|
|
if self.settings_dirty && self.last_settings_save.elapsed() > Duration::from_secs(2) {
|
|
self.save_settings();
|
|
}
|
|
if self.session_dirty && self.last_session_save.elapsed() > Duration::from_secs(2) {
|
|
self.save_session();
|
|
}
|
|
}
|
|
|
|
/// Keeps the Winamp window wearing the skin the settings name: starts
|
|
/// reading a newly chosen one, and puts it on once it is read. A skin
|
|
/// that cannot be read is announced and the setting goes back to the
|
|
/// one still on, so nothing is retried forever.
|
|
fn sync_skin(&mut self, ctx: &egui::Context) {
|
|
if self.settings.winamp_window
|
|
&& !self.winamp.is_loading()
|
|
&& self.winamp.worn != self.settings.skin
|
|
{
|
|
match self.settings.skin.clone() {
|
|
None => self.winamp.wear(None, crate::skin::Skin::builtin()),
|
|
Some(name) => self.winamp.load(name, &self.dirs.skins_dir(), ctx),
|
|
}
|
|
}
|
|
if let Some(loaded) = self.winamp.poll() {
|
|
self.skin_loaded(loaded);
|
|
}
|
|
}
|
|
|
|
/// A skin has been read. A dropped file becomes the choice; a chosen
|
|
/// name is already the choice, and if the choice moved on while this
|
|
/// one was read, the next tick reads that one.
|
|
fn skin_loaded(&mut self, loaded: crate::winamp::Loaded) {
|
|
match loaded.result {
|
|
Ok(skin) => {
|
|
self.winamp
|
|
.wear(Some(loaded.name.clone()), std::sync::Arc::new(skin));
|
|
if loaded.installed {
|
|
self.toast(format!(
|
|
"Added the {} skin",
|
|
crate::winamp::label(&loaded.name)
|
|
));
|
|
self.winamp.list_choices(&self.dirs.skins_dir());
|
|
self.settings.skin = Some(loaded.name);
|
|
self.settings_dirty = true;
|
|
}
|
|
}
|
|
Err(error) => {
|
|
self.toast_error(format!("{}: {error}", crate::winamp::label(&loaded.name)));
|
|
if !loaded.installed {
|
|
self.settings.skin = self.winamp.worn.clone();
|
|
self.settings_dirty = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Hands the equalizer settings to the player's thread and marks them
|
|
/// for saving.
|
|
fn push_eq(&mut self) {
|
|
if let Ok(mut shared) = self.winamp.eq.lock() {
|
|
*shared = eq_settings(&self.settings);
|
|
}
|
|
self.settings_dirty = true;
|
|
}
|
|
|
|
/// Note that a setting changed, so the file is saved shortly.
|
|
pub fn mark_settings_dirty(&mut self) {
|
|
self.settings_dirty = true;
|
|
}
|
|
|
|
/// Note that the restorable session changed, so it is saved shortly.
|
|
pub fn note_session_change(&mut self) {
|
|
self.session_dirty = true;
|
|
}
|
|
|
|
fn save_settings(&mut self) {
|
|
self.settings_dirty = false;
|
|
self.last_settings_save = Instant::now();
|
|
if self.offline {
|
|
// Demo data must never overwrite the person's real preferences.
|
|
return;
|
|
}
|
|
self.settings.save(&self.dirs.settings_file());
|
|
}
|
|
|
|
fn apply_theme(&mut self, ctx: &egui::Context) {
|
|
let dark = ctx.theme() == egui::Theme::Dark;
|
|
if self.applied_dark != Some(dark) {
|
|
self.palette = if dark {
|
|
Palette::dark()
|
|
} else {
|
|
Palette::light()
|
|
};
|
|
theme::apply(ctx, &self.palette);
|
|
self.applied_dark = Some(dark);
|
|
self.accents.clear();
|
|
self.accent_pending.clear();
|
|
}
|
|
}
|
|
|
|
fn handle_tray(&mut self) {
|
|
let Some(commands) = self.tray.as_ref().map(TrayService::drain_commands) else {
|
|
return;
|
|
};
|
|
for command in commands {
|
|
match command {
|
|
TrayCommand::Show => self.actions.push(Action::ShowWindow),
|
|
TrayCommand::ShowHide => self.actions.push(if self.window_hidden {
|
|
Action::ShowWindow
|
|
} else {
|
|
Action::HideWindow
|
|
}),
|
|
TrayCommand::PlayPause => self.actions.push(Action::TogglePlay),
|
|
TrayCommand::Next => self.actions.push(Action::Next),
|
|
TrayCommand::Previous => self.actions.push(Action::Previous),
|
|
TrayCommand::Quit => self.actions.push(Action::Quit),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_control_commands(&mut self) {
|
|
let Some(queue) = &self.control_commands else {
|
|
return;
|
|
};
|
|
let commands: Vec<ControlCommand> =
|
|
std::mem::take(&mut *queue.lock().unwrap_or_else(|p| p.into_inner()));
|
|
for command in commands {
|
|
let playing = self.now_playing().is_some_and(|now| now.playing);
|
|
let action = match command {
|
|
ControlCommand::Show => Some(Action::ShowWindow),
|
|
ControlCommand::PlayPause => Some(Action::TogglePlay),
|
|
ControlCommand::Play => (!playing).then_some(Action::TogglePlay),
|
|
ControlCommand::Pause => playing.then_some(Action::TogglePlay),
|
|
ControlCommand::Next => Some(Action::Next),
|
|
ControlCommand::Previous => Some(Action::Previous),
|
|
ControlCommand::SeekBy(offset) => Some(Action::SeekBy(offset)),
|
|
ControlCommand::VolumeBy(delta) => Some(Action::VolumeBy(delta)),
|
|
ControlCommand::SetVolume(volume) => Some(Action::SetVolume(volume.min(100))),
|
|
ControlCommand::ToggleMute => Some(Action::ToggleMute),
|
|
ControlCommand::ToggleShuffle => Some(Action::ToggleShuffle),
|
|
ControlCommand::CycleRepeat => Some(Action::CycleRepeat),
|
|
ControlCommand::SetShuffle(shuffle) => Some(Action::SetShuffle(shuffle)),
|
|
ControlCommand::SetRepeat(mode) => Some(Action::SetRepeat(mode)),
|
|
ControlCommand::SeekTo(position) => Some(Action::Seek(position)),
|
|
// Nothing playing is nothing to save, so the verb is a
|
|
// no-op rather than an error the client has to handle.
|
|
ControlCommand::ToggleSaved => {
|
|
self.now_playing().map(|now| Action::ToggleSaved(now.uri))
|
|
}
|
|
ControlCommand::PlayUri(uri) => Some(Action::PlayContext {
|
|
uri,
|
|
offset_uri: None,
|
|
offset_index: None,
|
|
}),
|
|
ControlCommand::Transfer(device_id) => Some(Action::Transfer(device_id)),
|
|
ControlCommand::RefreshDevices => Some(Action::RefreshDevices),
|
|
};
|
|
if let Some(action) = action {
|
|
self.actions.push(action);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_media_commands(&mut self) {
|
|
let Some(commands) = self
|
|
.media_controls
|
|
.as_ref()
|
|
.map(MediaService::drain_commands)
|
|
else {
|
|
return;
|
|
};
|
|
for command in commands {
|
|
let playing = self.now_playing().is_some_and(|now| now.playing);
|
|
let action = match command {
|
|
MediaCommand::Play => (!playing).then_some(Action::TogglePlay),
|
|
MediaCommand::Pause | MediaCommand::Stop => playing.then_some(Action::TogglePlay),
|
|
MediaCommand::PlayPause => Some(Action::TogglePlay),
|
|
MediaCommand::Next => Some(Action::Next),
|
|
MediaCommand::Previous => Some(Action::Previous),
|
|
MediaCommand::SeekBy(offset) => Some(Action::SeekBy(offset)),
|
|
MediaCommand::SetPosition {
|
|
track_uri,
|
|
position_ms,
|
|
} => self
|
|
.now_playing()
|
|
.filter(|now| now.uri == track_uri)
|
|
.map(|_| Action::Seek(position_ms)),
|
|
MediaCommand::SetVolume(volume) => Some(Action::SetVolume(
|
|
(volume.clamp(0.0, 1.0) * 100.0).round() as u8,
|
|
)),
|
|
MediaCommand::SetShuffle(shuffle) => Some(Action::SetShuffle(shuffle)),
|
|
MediaCommand::SetRepeat(mode) => Some(Action::SetRepeat(mode)),
|
|
MediaCommand::OpenUri(uri) => Some(Action::PlayContext {
|
|
uri,
|
|
offset_uri: None,
|
|
offset_index: None,
|
|
}),
|
|
MediaCommand::Raise => Some(Action::ShowWindow),
|
|
MediaCommand::Quit => Some(Action::Quit),
|
|
};
|
|
if let Some(action) = action {
|
|
self.actions.push(action);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sync_media_controls(&mut self) {
|
|
let state = match self.now_playing() {
|
|
Some(now) => MediaState {
|
|
playback: if now.playing {
|
|
Playback::Playing
|
|
} else if now.loading {
|
|
Playback::Loading
|
|
} else {
|
|
Playback::Paused
|
|
},
|
|
track: Some(MediaTrack {
|
|
uri: now.uri.clone(),
|
|
title: now.title.clone(),
|
|
artists: now
|
|
.artists
|
|
.iter()
|
|
.map(|artist| artist.name.clone())
|
|
.collect(),
|
|
album: now.album_name.clone(),
|
|
art_url: now.art_url.clone(),
|
|
duration_ms: now.duration_ms,
|
|
}),
|
|
position_ms: now.position_ms,
|
|
volume: f64::from(now.volume_percent) / 100.0,
|
|
shuffle: now.shuffle,
|
|
repeat: now.repeat,
|
|
can_control: now.can_control,
|
|
},
|
|
None => MediaState::default(),
|
|
};
|
|
if let Some(controls) = &mut self.media_controls {
|
|
controls.update(state);
|
|
}
|
|
let playing = self.now_playing().is_some_and(|now| now.playing);
|
|
if let Some(tray) = &mut self.tray {
|
|
tray.set_playing(playing);
|
|
}
|
|
if let Some(slot) = &self.control_now_playing {
|
|
let snapshot = self.control_snapshot();
|
|
*slot.lock().unwrap_or_else(|p| p.into_inner()) = snapshot;
|
|
}
|
|
if self.control_devices_stale
|
|
&& let Some(slot) = self.control_devices.clone()
|
|
{
|
|
let snapshot = self.control_devices_snapshot();
|
|
*slot.lock().unwrap_or_else(|p| p.into_inner()) = snapshot;
|
|
self.control_devices_stale = false;
|
|
}
|
|
}
|
|
|
|
/// One line for the control channel's `nowplaying` verb: tab-separated
|
|
/// `state, title, artists, album, position_ms, duration_ms, volume,
|
|
/// shuffle, repeat, art_url, saved, device`, or
|
|
/// [`crate::single_instance::NOTHING_PLAYING`].
|
|
///
|
|
/// The last three are what a Stream Deck key needs and a media key does
|
|
/// not: something to draw, whether the heart is filled, and where the
|
|
/// sound is coming out. They are appended rather than woven in, so a
|
|
/// script written against the older nine fields still reads correctly.
|
|
fn control_snapshot(&self) -> String {
|
|
let Some(now) = self.now_playing() else {
|
|
return crate::single_instance::NOTHING_PLAYING.to_owned();
|
|
};
|
|
let state = if now.playing { "playing" } else { "paused" };
|
|
// Not every track has been looked up yet; say so rather than
|
|
// claiming an unsaved track the client would draw as a hollow heart
|
|
// and then watch fill in a moment later.
|
|
let saved = match self.is_saved(&now.uri) {
|
|
Some(true) => "yes",
|
|
Some(false) => "no",
|
|
None => "unknown",
|
|
};
|
|
// Local playback is this computer, which Spotify has not named in
|
|
// the snapshot because it is not a remote device.
|
|
let device = match (&now.device_name, now.local) {
|
|
(Some(name), _) => name.as_str(),
|
|
(None, true) => self.settings.device_name.as_str(),
|
|
(None, false) => "",
|
|
};
|
|
// Tabs separate the fields, so a tab inside one would shift the rest.
|
|
// This runs every frame, and titles almost never contain one, so the
|
|
// usual answer borrows rather than allocating a copy per field.
|
|
fn clean(text: &str) -> std::borrow::Cow<'_, str> {
|
|
match text.contains('\t') {
|
|
true => std::borrow::Cow::Owned(text.replace('\t', " ")),
|
|
false => std::borrow::Cow::Borrowed(text),
|
|
}
|
|
}
|
|
format!(
|
|
"{state}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{saved}\t{}",
|
|
clean(&now.title),
|
|
clean(&now.subtitle),
|
|
clean(&now.album_name),
|
|
now.position_ms,
|
|
now.duration_ms,
|
|
now.volume_percent,
|
|
if now.shuffle { "on" } else { "off" },
|
|
now.repeat.api_name(),
|
|
clean(now.art_url.as_deref().unwrap_or_default()),
|
|
clean(device),
|
|
)
|
|
}
|
|
|
|
/// One line for the control channel's `devices` verb: the Spotify
|
|
/// Connect devices the app last saw, as JSON.
|
|
///
|
|
/// JSON rather than the tab-separated shape `nowplaying` uses because
|
|
/// there are several of them and a device carries a name its owner
|
|
/// chose, which is exactly the kind of free text a hand-rolled
|
|
/// separator gets wrong.
|
|
fn control_devices_snapshot(&self) -> String {
|
|
let devices: Vec<_> = self
|
|
.devices
|
|
.iter()
|
|
.filter_map(|device| {
|
|
Some(serde_json::json!({
|
|
"id": device.id.as_deref()?,
|
|
"name": device.name,
|
|
"kind": device.kind,
|
|
"active": device.is_active,
|
|
}))
|
|
})
|
|
.collect();
|
|
serde_json::to_string(&devices)
|
|
.unwrap_or_else(|_| crate::single_instance::NO_DEVICES.to_owned())
|
|
}
|
|
|
|
// ---- loading ---------------------------------------------------------------
|
|
|
|
fn load_playlists(&mut self) {
|
|
if self.library.playlists.is_loading() {
|
|
return;
|
|
}
|
|
self.library.playlists = Loadable::Loading;
|
|
self.library.playlists_next = None;
|
|
self.backend.api(ApiRequest::MyPlaylists { offset: 0 });
|
|
}
|
|
|
|
pub fn ensure_loaded(&mut self, page: Page) {
|
|
if !self.is_connected() {
|
|
return;
|
|
}
|
|
match page {
|
|
Page::Home => self.load_home(false),
|
|
Page::TopSongs => self.load_top_songs(false),
|
|
Page::Search => {}
|
|
Page::LikedSongs => {
|
|
if !self.library.liked.loaded_once {
|
|
self.load_more(Page::LikedSongs);
|
|
}
|
|
}
|
|
Page::Albums => {
|
|
if !self.library.albums.loaded_once {
|
|
self.load_more(Page::Albums);
|
|
}
|
|
}
|
|
Page::Artists => {
|
|
if !self.library.artists.loaded_once {
|
|
self.load_more(Page::Artists);
|
|
}
|
|
}
|
|
Page::Podcasts => {
|
|
if !self.library.shows.loaded_once {
|
|
self.load_more(Page::Podcasts);
|
|
}
|
|
}
|
|
Page::Episodes => {
|
|
if !self.library.episodes.loaded_once {
|
|
self.load_more(Page::Episodes);
|
|
}
|
|
}
|
|
Page::Playlist(id) => {
|
|
let needs_generation = self
|
|
.playlist_pages
|
|
.get(&id)
|
|
.is_none_or(|page| page.generation == 0);
|
|
if needs_generation {
|
|
self.load_generation += 1;
|
|
self.playlist_pages
|
|
.entry(id.clone())
|
|
.or_default()
|
|
.generation = self.load_generation;
|
|
}
|
|
let page = self.playlist_pages.entry(id.clone()).or_default();
|
|
let generation = page.generation;
|
|
if page.playlist.needs_load() {
|
|
page.playlist = Loadable::Loading;
|
|
self.backend.api(ApiRequest::Playlist {
|
|
id: id.clone(),
|
|
generation,
|
|
});
|
|
}
|
|
if !page.items.loaded_once && page.items.can_load_more() {
|
|
page.items.loading = true;
|
|
self.backend.api(ApiRequest::PlaylistItems {
|
|
id: id.clone(),
|
|
offset: 0,
|
|
generation,
|
|
});
|
|
// The disk may hold the whole list already; it is
|
|
// adopted only if Spotify's snapshot still matches.
|
|
self.backend
|
|
.send(Command::LoadPlaylistCache { id: id.clone() });
|
|
}
|
|
self.request_contains(vec![format!("spotify:playlist:{id}")]);
|
|
}
|
|
Page::Album(id) => {
|
|
let page = self.album_pages.entry(id.clone()).or_default();
|
|
if page.album.needs_load() {
|
|
page.album = Loadable::Loading;
|
|
self.backend.api(ApiRequest::Album { id: id.clone() });
|
|
}
|
|
self.request_contains(vec![format!("spotify:album:{id}")]);
|
|
}
|
|
Page::Artist(id) => {
|
|
let page = self.artist_pages.entry(id.clone()).or_default();
|
|
if page.artist.needs_load() {
|
|
page.artist = Loadable::Loading;
|
|
self.backend.api(ApiRequest::Artist { id: id.clone() });
|
|
}
|
|
let filter = page.filter;
|
|
self.load_artist_albums(&id, filter);
|
|
if page_related_needs_load(&self.artist_pages, &id) {
|
|
if let Some(page) = self.artist_pages.get_mut(&id) {
|
|
page.related = Loadable::Loading;
|
|
}
|
|
self.backend
|
|
.api(ApiRequest::RelatedArtists { id: id.clone() });
|
|
}
|
|
self.request_contains(vec![format!("spotify:artist:{id}")]);
|
|
}
|
|
Page::Show(id) => {
|
|
let page = self.show_pages.entry(id.clone()).or_default();
|
|
if page.show.needs_load() {
|
|
page.show = Loadable::Loading;
|
|
self.backend.api(ApiRequest::Show { id: id.clone() });
|
|
}
|
|
self.request_contains(vec![format!("spotify:show:{id}")]);
|
|
}
|
|
Page::Queue => self.refresh_queue(true),
|
|
Page::Settings => {}
|
|
}
|
|
}
|
|
|
|
fn load_artist_albums(&mut self, id: &str, filter: DiscographyFilter) {
|
|
let Some(page) = self.artist_pages.get_mut(id) else {
|
|
return;
|
|
};
|
|
let list = page.albums.entry(filter.groups().to_string()).or_default();
|
|
if !list.loaded_once && list.can_load_more() {
|
|
list.loading = true;
|
|
self.backend.api(ApiRequest::ArtistAlbums {
|
|
id: id.to_string(),
|
|
groups: filter.groups().to_string(),
|
|
offset: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn load_home(&mut self, force: bool) {
|
|
if self.home.requested
|
|
&& !force
|
|
&& self
|
|
.home
|
|
.loaded_at
|
|
.is_some_and(|at| at.elapsed() < Duration::from_secs(600))
|
|
{
|
|
return;
|
|
}
|
|
self.home.requested = true;
|
|
self.home.loaded_at = Some(Instant::now());
|
|
self.home.generation += 1;
|
|
let generation = self.home.generation;
|
|
if self.home.recently_played.get().is_none() {
|
|
self.home.recently_played = Loadable::Loading;
|
|
}
|
|
if self.home.top_artists.get().is_none() {
|
|
self.home.top_artists = Loadable::Loading;
|
|
}
|
|
if self.home.top_tracks.get().is_none() {
|
|
self.home.top_tracks = Loadable::Loading;
|
|
}
|
|
self.backend.api(ApiRequest::RecentlyPlayed { generation });
|
|
self.backend.api(ApiRequest::TopArtists { generation });
|
|
self.backend.api(ApiRequest::TopTracks {
|
|
offset: 0,
|
|
full: false,
|
|
generation,
|
|
});
|
|
self.home.discover_pending.clear();
|
|
for term in DISCOVER_TERMS {
|
|
self.home
|
|
.discover_pending
|
|
.insert((*term).to_string(), Loadable::Loading);
|
|
if !self.home.discover.contains_key(*term) {
|
|
self.home
|
|
.discover
|
|
.insert((*term).to_string(), Loadable::Loading);
|
|
}
|
|
self.backend.api(ApiRequest::Discover {
|
|
term: (*term).to_string(),
|
|
generation,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn load_top_songs(&mut self, force: bool) {
|
|
if self.home.top_songs_loading || (!force && self.home.top_songs_complete) {
|
|
return;
|
|
}
|
|
self.home.top_songs = Loadable::Loading;
|
|
self.home.top_songs_loading = true;
|
|
self.home.top_songs_complete = false;
|
|
self.home.top_songs_generation += 1;
|
|
self.backend.api(ApiRequest::TopTracks {
|
|
offset: 0,
|
|
full: true,
|
|
generation: self.home.top_songs_generation,
|
|
});
|
|
}
|
|
|
|
pub fn load_more(&mut self, page: Page) {
|
|
match page {
|
|
Page::LikedSongs => {
|
|
let list = &mut self.library.liked;
|
|
if let Some(offset) = list.next_offset.filter(|_| list.can_load_more()) {
|
|
list.loading = true;
|
|
self.backend.api(ApiRequest::SavedTracks { offset });
|
|
}
|
|
}
|
|
Page::Albums => {
|
|
let list = &mut self.library.albums;
|
|
if let Some(offset) = list.next_offset.filter(|_| list.can_load_more()) {
|
|
list.loading = true;
|
|
self.backend.api(ApiRequest::SavedAlbums { offset });
|
|
}
|
|
}
|
|
Page::Artists => {
|
|
let list = &mut self.library.artists;
|
|
if list.can_load_more() {
|
|
list.loading = true;
|
|
self.backend.api(ApiRequest::FollowedArtists {
|
|
after: list.after.clone(),
|
|
});
|
|
}
|
|
}
|
|
Page::Podcasts => {
|
|
let list = &mut self.library.shows;
|
|
if let Some(offset) = list.next_offset.filter(|_| list.can_load_more()) {
|
|
list.loading = true;
|
|
self.backend.api(ApiRequest::SavedShows { offset });
|
|
}
|
|
}
|
|
Page::Episodes => {
|
|
let list = &mut self.library.episodes;
|
|
if let Some(offset) = list.next_offset.filter(|_| list.can_load_more()) {
|
|
list.loading = true;
|
|
self.backend.api(ApiRequest::SavedEpisodes { offset });
|
|
}
|
|
}
|
|
Page::Playlist(id) => {
|
|
if let Some(page) = self.playlist_pages.get_mut(&id) {
|
|
let list = &mut page.items;
|
|
if let Some(offset) = list.next_offset.filter(|_| list.can_load_more()) {
|
|
list.loading = true;
|
|
self.backend.api(ApiRequest::PlaylistItems {
|
|
id,
|
|
offset,
|
|
generation: page.generation,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Page::Album(id) => {
|
|
if let Some(page) = self.album_pages.get_mut(&id) {
|
|
let list = &mut page.tracks;
|
|
if let Some(offset) = list.next_offset.filter(|_| list.can_load_more()) {
|
|
list.loading = true;
|
|
self.backend.api(ApiRequest::AlbumTracks { id, offset });
|
|
}
|
|
}
|
|
}
|
|
Page::Show(id) => {
|
|
if let Some(page) = self.show_pages.get_mut(&id) {
|
|
let list = &mut page.episodes;
|
|
if let Some(offset) = list.next_offset.filter(|_| list.can_load_more()) {
|
|
list.loading = true;
|
|
self.backend.api(ApiRequest::ShowEpisodes { id, offset });
|
|
}
|
|
}
|
|
}
|
|
Page::Home => {
|
|
if let Some(offset) = self.library.playlists_next.take() {
|
|
self.backend.api(ApiRequest::MyPlaylists { offset });
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn reload(&mut self, page: Page) {
|
|
match &page {
|
|
Page::Home => self.load_home(true),
|
|
Page::TopSongs => self.load_top_songs(true),
|
|
Page::LikedSongs => self.library.liked.reset(),
|
|
Page::Albums => self.library.albums.reset(),
|
|
Page::Artists => self.library.artists.reset(),
|
|
Page::Podcasts => self.library.shows.reset(),
|
|
Page::Episodes => self.library.episodes.reset(),
|
|
Page::Playlist(id) => {
|
|
if let Some(playlist) = self.playlist_pages.get_mut(id) {
|
|
self.load_generation += 1;
|
|
playlist.generation = self.load_generation;
|
|
playlist.items.loading = true;
|
|
playlist.cache_complete = false;
|
|
playlist.pending_cache = None;
|
|
self.backend.api(ApiRequest::Playlist {
|
|
id: id.clone(),
|
|
generation: playlist.generation,
|
|
});
|
|
self.backend.api(ApiRequest::PlaylistItems {
|
|
id: id.clone(),
|
|
offset: 0,
|
|
generation: playlist.generation,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
Page::Album(id) => {
|
|
self.album_pages.remove(id);
|
|
}
|
|
Page::Artist(id) => {
|
|
self.artist_pages.remove(id);
|
|
}
|
|
Page::Show(id) => {
|
|
self.show_pages.remove(id);
|
|
}
|
|
Page::Queue => self.queue = Loadable::NotLoaded,
|
|
_ => {}
|
|
}
|
|
self.ensure_loaded(page);
|
|
}
|
|
|
|
fn poll_remote(&mut self, _immediate: bool) {
|
|
if !self.is_connected() {
|
|
return;
|
|
}
|
|
self.remote_poll_pending = true;
|
|
self.remote_polled_at = Instant::now();
|
|
self.remote_poll_seq += 1;
|
|
self.backend.api(ApiRequest::PlaybackState {
|
|
seq: self.remote_poll_seq,
|
|
});
|
|
}
|
|
|
|
fn refresh_devices(&mut self) {
|
|
if !self.is_connected() || self.devices_loading {
|
|
return;
|
|
}
|
|
self.devices_loading = true;
|
|
self.backend.api(ApiRequest::Devices);
|
|
}
|
|
|
|
fn refresh_queue(&mut self, force: bool) {
|
|
if !self.is_connected() {
|
|
return;
|
|
}
|
|
if self.queue.is_loading() && !force {
|
|
return;
|
|
}
|
|
if !matches!(self.queue, Loadable::Loaded(_)) {
|
|
self.queue = Loadable::Loading;
|
|
}
|
|
self.queue_fetched_at = Some(Instant::now());
|
|
self.backend.api(ApiRequest::Queue);
|
|
}
|
|
|
|
fn run_search(&mut self, query: String) {
|
|
if query.is_empty() {
|
|
self.search.results = Loadable::NotLoaded;
|
|
self.search.committed.clear();
|
|
return;
|
|
}
|
|
if query == self.search.committed && !self.search.results.needs_load() {
|
|
return;
|
|
}
|
|
self.search.serial += 1;
|
|
self.search.committed = query.clone();
|
|
if self.search.results.get().is_none() {
|
|
self.search.results = Loadable::Loading;
|
|
}
|
|
self.backend.api(ApiRequest::Search {
|
|
query,
|
|
serial: self.search.serial,
|
|
});
|
|
}
|
|
|
|
/// Asks Spotify whether these items are in the library, in batches.
|
|
/// Resolve adder ids that have no known name yet.
|
|
pub fn request_user_names(&mut self, ids: Vec<String>) {
|
|
let unknown: Vec<String> = ids
|
|
.into_iter()
|
|
.filter(|id| !self.user_names.contains_key(id))
|
|
.collect();
|
|
if unknown.is_empty() {
|
|
return;
|
|
}
|
|
for id in &unknown {
|
|
self.user_names.insert(id.clone(), None);
|
|
}
|
|
self.backend.send(Command::UserNames(unknown));
|
|
}
|
|
|
|
pub fn request_contains(&mut self, uris: Vec<String>) {
|
|
let mut batch = Vec::new();
|
|
for uri in uris {
|
|
if uri.is_empty()
|
|
|| self.saved.contains_key(&uri)
|
|
|| self.saved_pending.contains(&uri)
|
|
|| uri.starts_with("spotify:local")
|
|
{
|
|
continue;
|
|
}
|
|
self.saved_pending.insert(uri.clone());
|
|
batch.push(uri);
|
|
if batch.len() == CONTAINS_BATCH {
|
|
self.backend.api(ApiRequest::Contains {
|
|
uris: std::mem::take(&mut batch),
|
|
});
|
|
}
|
|
}
|
|
if !batch.is_empty() {
|
|
self.backend.api(ApiRequest::Contains { uris: batch });
|
|
}
|
|
}
|
|
|
|
// ---- api responses -------------------------------------------------------
|
|
|
|
fn handle_api(&mut self, response: ApiResponse) {
|
|
match response {
|
|
ApiResponse::Me(result) => match result {
|
|
Ok(user) => {
|
|
// Spotify only takes playback commands from Premium
|
|
// accounts, here or on any device, so a Free account
|
|
// is told once rather than left pressing play.
|
|
let free = user
|
|
.product
|
|
.as_deref()
|
|
.is_some_and(|product| product != "premium");
|
|
if free && !self.premium_notice_shown {
|
|
self.premium_notice_shown = true;
|
|
self.dialog = Some(Dialog::PremiumNeeded);
|
|
}
|
|
self.user = Some(user);
|
|
let page = self.page().clone();
|
|
self.ensure_loaded(page);
|
|
if let Some(now) = self.now_playing() {
|
|
self.request_contains(vec![now.uri]);
|
|
}
|
|
}
|
|
Err(error) => {
|
|
if matches!(error, crate::api::ApiError::SignInExpired { .. }) {
|
|
self.auth = AuthStatus::Failed(
|
|
"Your Spotify sign-in expired. Please sign in again.".into(),
|
|
);
|
|
} else {
|
|
self.toast_error(format!("Couldn't load your profile: {error}"));
|
|
}
|
|
}
|
|
},
|
|
ApiResponse::Devices(result) => {
|
|
self.devices_loading = false;
|
|
self.devices_fetched_at = Some(Instant::now());
|
|
match result {
|
|
Ok(devices) => {
|
|
self.devices = devices;
|
|
self.control_devices_stale = true;
|
|
if let Some((name, since)) = self.pending_transfer_to.clone() {
|
|
let matching = self
|
|
.devices
|
|
.iter()
|
|
.find(|device| device.name == name)
|
|
.and_then(|device| device.id.clone());
|
|
if let Some(id) = matching {
|
|
self.pending_transfer_to = None;
|
|
self.transfer(id);
|
|
} else if since.elapsed() > Duration::from_secs(20) {
|
|
self.pending_transfer_to = None;
|
|
} else {
|
|
self.devices_fetched_at = None;
|
|
}
|
|
}
|
|
if let Some(selected) = &self.selected_device
|
|
&& !self
|
|
.devices
|
|
.iter()
|
|
.any(|device| device.id.as_deref() == Some(selected.as_str()))
|
|
{
|
|
self.selected_device = None;
|
|
}
|
|
}
|
|
Err(error) => self.toast_error(format!("Couldn't list devices: {error}")),
|
|
}
|
|
}
|
|
ApiResponse::PlaybackState { seq, result } => {
|
|
if seq != self.remote_poll_seq {
|
|
// An older poll finishing late describes the past.
|
|
return;
|
|
}
|
|
self.remote_poll_pending = false;
|
|
match result {
|
|
Ok(state) => {
|
|
let previous_uri = self.remote.as_ref().and_then(|remote| {
|
|
remote
|
|
.state
|
|
.item
|
|
.as_ref()
|
|
.map(|item| item.uri().to_string())
|
|
});
|
|
let previous_shuffle = self
|
|
.remote
|
|
.as_ref()
|
|
.map(|remote| remote.state.shuffle_state);
|
|
self.remote = state.map(|state| RemoteSnapshot {
|
|
state,
|
|
received_at: Instant::now(),
|
|
});
|
|
if let (Some(previous), Some(current)) = (
|
|
previous_shuffle,
|
|
self.remote
|
|
.as_ref()
|
|
.map(|remote| remote.state.shuffle_state),
|
|
) && previous != current
|
|
&& self
|
|
.shuffle_set_at
|
|
.is_none_or(|at| at.elapsed() > Duration::from_secs(5))
|
|
{
|
|
// Toggled on another device; that is the
|
|
// listener's setting too.
|
|
self.shuffle_wanted = current;
|
|
}
|
|
if let Some(context) = self
|
|
.remote
|
|
.as_ref()
|
|
.and_then(|remote| remote.state.context.as_ref())
|
|
.map(|context| context.uri.clone())
|
|
{
|
|
// Mid-takeover the cluster still names the old
|
|
// context; noting that would dance the sidebar
|
|
// back and forth.
|
|
let stale = self.assumed_context.as_ref().is_some_and(|assumed| {
|
|
assumed.at.elapsed() < ASSUMED_CONTEXT_HOLD
|
|
&& assumed.uri != context
|
|
});
|
|
if !stale {
|
|
self.note_recent_context(&context);
|
|
}
|
|
}
|
|
let uri = self.remote.as_ref().and_then(|remote| {
|
|
remote
|
|
.state
|
|
.item
|
|
.as_ref()
|
|
.map(|item| item.uri().to_string())
|
|
});
|
|
if let Some(remote) = &self.remote
|
|
&& let Some(device) = &remote.state.device
|
|
&& device.id.is_some()
|
|
&& let Some(known) =
|
|
self.devices.iter_mut().find(|known| known.id == device.id)
|
|
{
|
|
known.is_active = true;
|
|
known.volume_percent = device.volume_percent;
|
|
}
|
|
if let Some((wanted, _)) = self.optimistic_playing
|
|
&& self
|
|
.remote
|
|
.as_ref()
|
|
.is_some_and(|remote| remote.state.is_playing == wanted)
|
|
{
|
|
self.optimistic_playing = None;
|
|
}
|
|
if uri != previous_uri {
|
|
self.on_now_playing_changed();
|
|
}
|
|
}
|
|
Err(error) => log::debug!("playback state unavailable: {error}"),
|
|
}
|
|
}
|
|
ApiResponse::Queue(result) => {
|
|
self.queue = Loadable::from_result(result);
|
|
if let Some(queue) = self.queue.get() {
|
|
let uris: Vec<String> = queue
|
|
.queue
|
|
.iter()
|
|
.map(|item| item.uri().to_string())
|
|
.collect();
|
|
self.request_contains(uris);
|
|
}
|
|
}
|
|
ApiResponse::RecentlyPlayed { generation, result } => {
|
|
if generation != self.home.generation {
|
|
return;
|
|
}
|
|
if let Ok(history) = &result {
|
|
// Oldest first, so the newest ends up at the front.
|
|
let contexts: Vec<String> = history
|
|
.iter()
|
|
.rev()
|
|
.filter_map(|play| play.context.as_ref().map(|context| context.uri.clone()))
|
|
.collect();
|
|
for context in contexts {
|
|
self.note_recent_context(&context);
|
|
}
|
|
}
|
|
self.home.recently_played.refresh(result);
|
|
}
|
|
ApiResponse::TopTracks {
|
|
offset,
|
|
full,
|
|
generation,
|
|
result,
|
|
} => {
|
|
if full {
|
|
if generation != self.home.top_songs_generation {
|
|
return;
|
|
}
|
|
match result {
|
|
Ok(page) => {
|
|
let received = page.items.len() as u32;
|
|
let tracks = page.items;
|
|
let uris: Vec<String> =
|
|
tracks.iter().map(|track| track.uri.clone()).collect();
|
|
self.request_contains(uris);
|
|
if offset == 0 {
|
|
self.home.top_songs = Loadable::Loaded(tracks);
|
|
} else if let Some(current) = self.home.top_songs.get_mut() {
|
|
current.extend(tracks);
|
|
}
|
|
if page.next.is_some() && received > 0 && offset + received < 100 {
|
|
self.backend.api(ApiRequest::TopTracks {
|
|
offset: offset + received,
|
|
full: true,
|
|
generation,
|
|
});
|
|
} else {
|
|
self.home.top_songs_loading = false;
|
|
self.home.top_songs_complete = true;
|
|
}
|
|
}
|
|
Err(error) => {
|
|
self.home.top_songs.refresh(Err::<Vec<Track>, _>(error));
|
|
self.home.top_songs_loading = false;
|
|
}
|
|
}
|
|
} else if generation == self.home.generation
|
|
&& let Ok(page) = result
|
|
{
|
|
let tracks = page.items;
|
|
let seeds: Vec<String> = tracks
|
|
.iter()
|
|
.filter_map(|track| track.id.clone())
|
|
.take(5)
|
|
.collect();
|
|
if !seeds.is_empty() {
|
|
if self.home.recommendations.get().is_none() {
|
|
self.home.recommendations = Loadable::Loading;
|
|
}
|
|
self.backend.api(ApiRequest::Recommendations {
|
|
seed_tracks: seeds,
|
|
seed_artists: Vec::new(),
|
|
generation,
|
|
});
|
|
}
|
|
let uris: Vec<String> = tracks.iter().map(|track| track.uri.clone()).collect();
|
|
self.request_contains(uris);
|
|
self.home.top_tracks = Loadable::Loaded(tracks);
|
|
} else if generation == self.home.generation
|
|
&& offset == 0
|
|
&& let Err(error) = result
|
|
&& self.home.top_tracks.get().is_none()
|
|
{
|
|
self.home.top_tracks = Loadable::Failed(error.to_string());
|
|
}
|
|
}
|
|
ApiResponse::TopArtists { generation, result } => {
|
|
if generation != self.home.generation {
|
|
return;
|
|
}
|
|
self.home.top_artists.refresh(result);
|
|
}
|
|
ApiResponse::Recommendations { generation, result } => {
|
|
if generation != self.home.generation {
|
|
return;
|
|
}
|
|
if let Ok(tracks) = &result {
|
|
let uris: Vec<String> = tracks.iter().map(|track| track.uri.clone()).collect();
|
|
self.request_contains(uris);
|
|
}
|
|
self.home.recommendations.refresh(result);
|
|
}
|
|
ApiResponse::Discover {
|
|
term,
|
|
generation,
|
|
result,
|
|
} => {
|
|
if generation != self.home.generation {
|
|
return;
|
|
}
|
|
let filtered = result.map(|playlists| {
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut matching: Vec<Playlist> = playlists
|
|
.into_iter()
|
|
.filter(|playlist| {
|
|
let owner = playlist.owner.id.as_deref().unwrap_or("");
|
|
is_made_for_you(&playlist.name, &term)
|
|
&& (owner == "spotify" || playlist.owner_name() == "Spotify")
|
|
&& seen.insert(playlist.name.to_lowercase())
|
|
})
|
|
.collect();
|
|
matching.truncate(6);
|
|
matching
|
|
});
|
|
self.home
|
|
.discover_pending
|
|
.insert(term, Loadable::from_result(filtered));
|
|
let complete = DISCOVER_TERMS.iter().all(|term| {
|
|
self.home
|
|
.discover_pending
|
|
.get(*term)
|
|
.is_some_and(|result| !result.is_loading())
|
|
});
|
|
if complete {
|
|
self.home.discover = std::mem::take(&mut self.home.discover_pending);
|
|
}
|
|
}
|
|
ApiResponse::MyPlaylists { offset, result } => match result {
|
|
Ok(page) => {
|
|
let next_offset = page.next_offset();
|
|
match &mut self.library.playlists {
|
|
Loadable::Loaded(existing) if offset > 0 => existing.extend(page.items),
|
|
slot => *slot = Loadable::Loaded(page.items),
|
|
}
|
|
self.library.playlists_next = next_offset;
|
|
if next_offset.is_some() {
|
|
self.load_more(Page::Home);
|
|
}
|
|
if let Some(playlists) = self.library.playlists.get() {
|
|
for playlist in playlists {
|
|
self.saved.insert(playlist.uri.clone(), true);
|
|
}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
if offset == 0 {
|
|
self.library.playlists = Loadable::Failed(error.to_string());
|
|
} else {
|
|
self.toast_error(format!("Couldn't load more playlists: {error}"));
|
|
}
|
|
}
|
|
},
|
|
ApiResponse::Playlist {
|
|
id,
|
|
generation,
|
|
result,
|
|
} => {
|
|
if self
|
|
.playlist_pages
|
|
.get(&id)
|
|
.is_none_or(|page| page.generation != generation)
|
|
{
|
|
return;
|
|
}
|
|
if let Ok(playlist) = &result
|
|
&& let Some(image) = pick_image(&playlist.images, 300)
|
|
{
|
|
self.tint_for(Some(image));
|
|
}
|
|
if let Some(page) = self.playlist_pages.get_mut(&id) {
|
|
page.playlist.refresh(result);
|
|
}
|
|
self.try_adopt_playlist_cache(&id);
|
|
}
|
|
ApiResponse::PlaylistItems {
|
|
id,
|
|
offset,
|
|
generation,
|
|
result,
|
|
} => {
|
|
if self
|
|
.playlist_pages
|
|
.get(&id)
|
|
.is_none_or(|page| page.generation != generation)
|
|
{
|
|
return;
|
|
}
|
|
let mut uris = Vec::new();
|
|
let mut adders: Vec<String> = Vec::new();
|
|
if let Some(page) = self.playlist_pages.get_mut(&id) {
|
|
match result {
|
|
Ok(_) if page.cache_complete => {
|
|
// A page in flight from before the cache
|
|
// adopted; the list is already whole.
|
|
}
|
|
Ok(items) => {
|
|
uris = items
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| item.playable())
|
|
.map(|item| item.uri().to_string())
|
|
.collect();
|
|
adders = items
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| item.added_by.as_ref()?.id.clone())
|
|
.filter(|id| !id.is_empty())
|
|
.collect();
|
|
page.contributors.extend(adders.iter().cloned());
|
|
page.items.absorb(offset, items);
|
|
// The rows load from the top, and songs a friend
|
|
// added often sit at the end; look there once.
|
|
if !page.tail_checked {
|
|
page.tail_checked = true;
|
|
let loaded = page.items.items.len() as u32;
|
|
if let Some(total) =
|
|
page.items.total.filter(|total| *total > loaded)
|
|
{
|
|
self.backend.api(ApiRequest::PlaylistSample {
|
|
id: id.clone(),
|
|
offset: total.saturating_sub(PLAYLIST_PAGE_SIZE),
|
|
generation,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Err(error) => page.items.fail(friendly_page_error(&error)),
|
|
}
|
|
}
|
|
self.request_contains(uris);
|
|
self.request_user_names(adders);
|
|
// The whole list is here; remember it under its snapshot.
|
|
if let Some(page) = self.playlist_pages.get(&id)
|
|
&& page.items.is_complete()
|
|
&& !page.cache_complete
|
|
&& let Some(snapshot) = page
|
|
.playlist
|
|
.get()
|
|
.and_then(|playlist| playlist.snapshot_id.clone())
|
|
{
|
|
self.backend.send(Command::StorePlaylistCache {
|
|
id: id.clone(),
|
|
snapshot,
|
|
items: page.items.items.clone(),
|
|
});
|
|
}
|
|
// A sorted table means the whole list, not the loaded part.
|
|
if self.table_sorts.contains_key(&Page::Playlist(id.clone())) {
|
|
self.load_more(Page::Playlist(id));
|
|
}
|
|
}
|
|
ApiResponse::PlaylistSample {
|
|
id,
|
|
generation,
|
|
result,
|
|
} => {
|
|
if self
|
|
.playlist_pages
|
|
.get(&id)
|
|
.is_none_or(|page| page.generation != generation)
|
|
{
|
|
return;
|
|
}
|
|
let mut adders: Vec<String> = Vec::new();
|
|
if let Ok(items) = result
|
|
&& let Some(page) = self.playlist_pages.get_mut(&id)
|
|
{
|
|
adders = items
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| item.added_by.as_ref()?.id.clone())
|
|
.filter(|id| !id.is_empty())
|
|
.collect();
|
|
page.contributors.extend(adders.iter().cloned());
|
|
}
|
|
self.request_user_names(adders);
|
|
}
|
|
ApiResponse::PlaylistCreated(result) => {
|
|
self.playlist_busy = false;
|
|
match result {
|
|
Ok(playlist) => {
|
|
self.toast(format!("Created {}", playlist.name));
|
|
if let Some(playlists) = self.library.playlists.get_mut() {
|
|
playlists.insert(0, playlist.clone());
|
|
}
|
|
self.saved.insert(playlist.uri.clone(), true);
|
|
if let Some(Dialog::CreatePlaylist { add_uris, .. }) = self.dialog.take()
|
|
&& !add_uris.is_empty()
|
|
{
|
|
self.backend.api(ApiRequest::AddToPlaylist {
|
|
playlist_id: playlist.id.clone(),
|
|
playlist_name: playlist.name.clone(),
|
|
uris: add_uris,
|
|
});
|
|
}
|
|
self.open(Page::Playlist(playlist.id));
|
|
}
|
|
Err(error) => {
|
|
self.toast_error(format!("Couldn't create the playlist: {error}"))
|
|
}
|
|
}
|
|
}
|
|
ApiResponse::PlaylistUpdated { id, result } => {
|
|
self.playlist_busy = false;
|
|
match result {
|
|
Ok(()) => {
|
|
self.toast("Playlist updated");
|
|
self.playlist_pages.remove(&id);
|
|
self.load_playlists();
|
|
if matches!(self.page(), Page::Playlist(current) if *current == id) {
|
|
self.ensure_loaded(Page::Playlist(id));
|
|
}
|
|
}
|
|
Err(error) => {
|
|
self.toast_error(format!("Couldn't update the playlist: {error}"))
|
|
}
|
|
}
|
|
}
|
|
ApiResponse::PlaylistItemsChanged {
|
|
id,
|
|
message,
|
|
result,
|
|
} => {
|
|
self.playlist_busy = false;
|
|
match result {
|
|
Ok(snapshot) => {
|
|
if !message.is_empty() {
|
|
self.toast(message);
|
|
}
|
|
if let Some(page) = self.playlist_pages.get_mut(&id) {
|
|
if let Some(playlist) = page.playlist.get_mut()
|
|
&& snapshot.is_some()
|
|
{
|
|
playlist.snapshot_id = snapshot;
|
|
}
|
|
page.items.reset();
|
|
page.contributors.clear();
|
|
page.tail_checked = false;
|
|
page.cache_complete = false;
|
|
page.pending_cache = None;
|
|
}
|
|
if matches!(self.page(), Page::Playlist(current) if *current == id) {
|
|
self.ensure_loaded(Page::Playlist(id.clone()));
|
|
}
|
|
if let Some(playlists) = self.library.playlists.get_mut() {
|
|
for playlist in playlists.iter_mut().filter(|p| p.id == id) {
|
|
playlist.snapshot_id = None;
|
|
}
|
|
}
|
|
self.load_playlists();
|
|
}
|
|
Err(error) => {
|
|
self.toast_error(format!("Playlist change failed: {error}"));
|
|
if let Some(page) = self.playlist_pages.get_mut(&id) {
|
|
page.items.reset();
|
|
page.contributors.clear();
|
|
page.tail_checked = false;
|
|
page.cache_complete = false;
|
|
page.pending_cache = None;
|
|
}
|
|
self.ensure_loaded(Page::Playlist(id));
|
|
}
|
|
}
|
|
}
|
|
ApiResponse::PlaylistFollowChanged {
|
|
id,
|
|
followed,
|
|
result,
|
|
} => match result {
|
|
Ok(()) => {
|
|
self.saved
|
|
.insert(format!("spotify:playlist:{id}"), followed);
|
|
self.toast(if followed {
|
|
"Added to Your Library"
|
|
} else {
|
|
"Removed from Your Library"
|
|
});
|
|
self.load_playlists();
|
|
if !followed && matches!(self.page(), Page::Playlist(current) if *current == id)
|
|
{
|
|
self.open(Page::Home);
|
|
}
|
|
}
|
|
Err(error) => {
|
|
self.saved
|
|
.insert(format!("spotify:playlist:{id}"), !followed);
|
|
self.toast_error(format!("Couldn't update the playlist: {error}"));
|
|
}
|
|
},
|
|
ApiResponse::SavedTracks { offset, result } => {
|
|
match result {
|
|
Ok(page) => {
|
|
for item in &page.items {
|
|
self.saved.insert(item.track.uri.clone(), true);
|
|
}
|
|
self.library.liked.absorb(offset, page);
|
|
}
|
|
Err(error) => self.library.liked.fail(error.to_string()),
|
|
}
|
|
// A sorted table means the whole list, not the loaded part.
|
|
if self.table_sorts.contains_key(&Page::LikedSongs) {
|
|
self.load_more(Page::LikedSongs);
|
|
}
|
|
}
|
|
ApiResponse::SavedAlbums { offset, result } => match result {
|
|
Ok(page) => {
|
|
for item in &page.items {
|
|
self.saved.insert(item.album.uri.clone(), true);
|
|
}
|
|
self.library.albums.absorb(offset, page);
|
|
}
|
|
Err(error) => self.library.albums.fail(error.to_string()),
|
|
},
|
|
ApiResponse::FollowedArtists { after, result } => {
|
|
let list = &mut self.library.artists;
|
|
list.loading = false;
|
|
list.loaded_once = true;
|
|
match result {
|
|
Ok(page) => {
|
|
if after.is_none() {
|
|
list.items.clear();
|
|
}
|
|
let received = page.items.len();
|
|
for artist in &page.items {
|
|
self.saved.insert(artist.uri.clone(), true);
|
|
}
|
|
list.items.extend(page.items);
|
|
let next = page.cursors.and_then(|cursors| cursors.after);
|
|
list.complete = next.is_none() || received == 0;
|
|
list.after = next;
|
|
list.error = None;
|
|
}
|
|
Err(error) => list.error = Some(error.to_string()),
|
|
}
|
|
}
|
|
ApiResponse::SavedShows { offset, result } => match result {
|
|
Ok(page) => {
|
|
for item in &page.items {
|
|
self.saved.insert(item.show.uri.clone(), true);
|
|
}
|
|
self.library.shows.absorb(offset, page);
|
|
}
|
|
Err(error) => self.library.shows.fail(error.to_string()),
|
|
},
|
|
ApiResponse::SavedEpisodes { offset, result } => match result {
|
|
Ok(page) => {
|
|
for item in &page.items {
|
|
self.saved.insert(item.episode.uri.clone(), true);
|
|
}
|
|
self.library.episodes.absorb(offset, page);
|
|
}
|
|
Err(error) => self.library.episodes.fail(error.to_string()),
|
|
},
|
|
ApiResponse::SavedChanged {
|
|
uris,
|
|
saved,
|
|
result,
|
|
} => match result {
|
|
Ok(()) => {
|
|
for uri in &uris {
|
|
self.saved.insert(uri.clone(), saved);
|
|
match util::uri_kind(uri) {
|
|
Some("track") => {
|
|
if self.library.liked.loaded_once {
|
|
if saved {
|
|
let total = self
|
|
.library
|
|
.liked
|
|
.total
|
|
.map(|total| total.saturating_add(1));
|
|
self.library.liked.reset();
|
|
self.library.liked.total = total;
|
|
if matches!(self.page(), Page::LikedSongs) {
|
|
self.load_more(Page::LikedSongs);
|
|
}
|
|
} else {
|
|
self.library
|
|
.liked
|
|
.items
|
|
.retain(|item| item.track.uri != *uri);
|
|
if let Some(total) = self.library.liked.total.as_mut() {
|
|
*total = total.saturating_sub(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Some("album") => self.library.albums.reset(),
|
|
Some("artist") => self.library.artists.reset(),
|
|
Some("show") => self.library.shows.reset(),
|
|
Some("episode") => self.library.episodes.reset(),
|
|
_ => {}
|
|
}
|
|
}
|
|
let message = match (uris.first().and_then(|uri| util::uri_kind(uri)), saved) {
|
|
(Some("track"), true) => "Added to Liked Songs",
|
|
(Some("track"), false) => "Removed from Liked Songs",
|
|
(Some("artist"), true) => "Following artist",
|
|
(Some("artist"), false) => "Unfollowed artist",
|
|
(_, true) => "Saved to Your Library",
|
|
(_, false) => "Removed from Your Library",
|
|
};
|
|
self.toast(message);
|
|
}
|
|
Err(error) => {
|
|
for uri in &uris {
|
|
self.saved.insert(uri.clone(), !saved);
|
|
}
|
|
self.toast_error(format!("Couldn't update your library: {error}"));
|
|
}
|
|
},
|
|
ApiResponse::Contains { uris, result } => {
|
|
for uri in &uris {
|
|
self.saved_pending.remove(uri);
|
|
}
|
|
if let Ok(flags) = result {
|
|
for (uri, flag) in uris.into_iter().zip(flags) {
|
|
self.saved.insert(uri, flag);
|
|
}
|
|
}
|
|
}
|
|
ApiResponse::Search {
|
|
query,
|
|
serial,
|
|
result,
|
|
} => {
|
|
if serial != self.search.serial || query != self.search.committed {
|
|
return;
|
|
}
|
|
if let Ok(results) = &result {
|
|
let uris: Vec<String> = results
|
|
.tracks
|
|
.iter()
|
|
.flat_map(|page| page.items.iter())
|
|
.map(|track| track.uri.clone())
|
|
.collect();
|
|
self.request_contains(uris);
|
|
self.settings.remember_search(&query);
|
|
self.settings_dirty = true;
|
|
}
|
|
self.search.results.refresh(result);
|
|
}
|
|
ApiResponse::Artist { id, result } => {
|
|
if let Ok(artist) = &result {
|
|
if let Some(image) = pick_image(&artist.images, 300) {
|
|
self.tint_for(Some(image));
|
|
}
|
|
if let Some(page) = self.artist_pages.get_mut(&id)
|
|
&& page.top_tracks.needs_load()
|
|
{
|
|
page.top_tracks = Loadable::Loading;
|
|
self.backend
|
|
.api(ApiRequest::ArtistTopTracks { id: id.clone() });
|
|
}
|
|
}
|
|
if let Some(page) = self.artist_pages.get_mut(&id) {
|
|
page.artist = Loadable::from_result(result);
|
|
}
|
|
}
|
|
ApiResponse::ArtistTopTracks { id, result } => {
|
|
if let Ok(tracks) = &result {
|
|
let uris: Vec<String> = tracks.iter().map(|track| track.uri.clone()).collect();
|
|
self.request_contains(uris);
|
|
}
|
|
if let Some(page) = self.artist_pages.get_mut(&id) {
|
|
page.top_tracks = Loadable::from_result(result);
|
|
}
|
|
}
|
|
ApiResponse::ArtistAlbums {
|
|
id,
|
|
groups,
|
|
offset,
|
|
result,
|
|
} => {
|
|
if let Some(page) = self.artist_pages.get_mut(&id) {
|
|
let list = page.albums.entry(groups).or_default();
|
|
match result {
|
|
Ok(albums) => list.absorb(offset, albums),
|
|
Err(error) => list.fail(error.to_string()),
|
|
}
|
|
}
|
|
}
|
|
ApiResponse::RelatedArtists { id, result } => {
|
|
if let Some(page) = self.artist_pages.get_mut(&id) {
|
|
page.related = Loadable::from_result(result);
|
|
}
|
|
}
|
|
ApiResponse::Album { id, result } => {
|
|
let mut uris = Vec::new();
|
|
if let Ok(album) = &result
|
|
&& let Some(image) = pick_image(&album.images, 300)
|
|
{
|
|
self.tint_for(Some(image));
|
|
}
|
|
if let Some(page) = self.album_pages.get_mut(&id) {
|
|
match result {
|
|
Ok(mut album) => {
|
|
if let Some(tracks) = album.tracks.take() {
|
|
uris = tracks.items.iter().map(|track| track.uri.clone()).collect();
|
|
page.tracks.absorb(0, tracks);
|
|
}
|
|
page.album = Loadable::Loaded(album);
|
|
if !page.tracks.loaded_once {
|
|
page.tracks.loading = true;
|
|
self.backend.api(ApiRequest::AlbumTracks { id, offset: 0 });
|
|
}
|
|
}
|
|
Err(error) => page.album = Loadable::Failed(error.to_string()),
|
|
}
|
|
}
|
|
self.request_contains(uris);
|
|
}
|
|
ApiResponse::AlbumTracks { id, offset, result } => {
|
|
let mut uris = Vec::new();
|
|
if let Some(page) = self.album_pages.get_mut(&id) {
|
|
match result {
|
|
Ok(tracks) => {
|
|
uris = tracks.items.iter().map(|track| track.uri.clone()).collect();
|
|
page.tracks.absorb(offset, tracks);
|
|
}
|
|
Err(error) => page.tracks.fail(error.to_string()),
|
|
}
|
|
}
|
|
self.request_contains(uris);
|
|
// A sorted table means the whole list, not the loaded part.
|
|
if self.table_sorts.contains_key(&Page::Album(id.clone())) {
|
|
self.load_more(Page::Album(id));
|
|
}
|
|
}
|
|
ApiResponse::Show { id, result } => {
|
|
if let Ok(show) = &result
|
|
&& let Some(image) = pick_image(&show.images, 300)
|
|
{
|
|
self.tint_for(Some(image));
|
|
}
|
|
if let Some(page) = self.show_pages.get_mut(&id) {
|
|
match result {
|
|
Ok(mut show) => {
|
|
if let Some(episodes) = show.episodes.take() {
|
|
page.episodes.absorb(0, episodes);
|
|
}
|
|
page.show = Loadable::Loaded(show);
|
|
if !page.episodes.loaded_once {
|
|
page.episodes.loading = true;
|
|
self.backend.api(ApiRequest::ShowEpisodes { id, offset: 0 });
|
|
}
|
|
}
|
|
Err(error) => page.show = Loadable::Failed(error.to_string()),
|
|
}
|
|
}
|
|
}
|
|
ApiResponse::ShowEpisodes { id, offset, result } => {
|
|
if let Some(page) = self.show_pages.get_mut(&id) {
|
|
match result {
|
|
Ok(episodes) => page.episodes.absorb(offset, episodes),
|
|
Err(error) => page.episodes.fail(error.to_string()),
|
|
}
|
|
}
|
|
}
|
|
ApiResponse::Track { id, result } => {
|
|
self.track_requests.remove(&id);
|
|
if let Ok(track) = result {
|
|
self.track_cache.insert(id, track);
|
|
}
|
|
}
|
|
ApiResponse::Remote { action, result } => {
|
|
if matches!(action, RemoteAction::Play | RemoteAction::Pause) {
|
|
self.clear_play_pending();
|
|
}
|
|
match result {
|
|
Ok(()) => {
|
|
self.remote_recheck_at = Some(Instant::now() + REMOTE_RECHECK);
|
|
}
|
|
Err(error) => {
|
|
self.optimistic_playing = None;
|
|
self.pending_remote_position = None;
|
|
self.pending_remote_volume = None;
|
|
let hint = if error.status() == Some(404) {
|
|
" Choose a device from the devices menu first."
|
|
} else {
|
|
""
|
|
};
|
|
self.toast_error(format!(
|
|
"{}: {error}.{hint}",
|
|
remote_action_label(action)
|
|
));
|
|
}
|
|
}
|
|
self.poll_remote_soon();
|
|
}
|
|
ApiResponse::Transferred { device_id, result } => match result {
|
|
Ok(()) => {
|
|
self.selected_device = Some(device_id);
|
|
self.show_devices = false;
|
|
self.poll_remote_soon();
|
|
self.refresh_devices();
|
|
}
|
|
Err(error) => self.toast_error(format!("Couldn't switch device: {error}")),
|
|
},
|
|
ApiResponse::QueueAdded { label, result } => match result {
|
|
Ok(()) => {
|
|
self.toast(format!("Added {label} to queue"));
|
|
self.refresh_queue(true);
|
|
}
|
|
Err(error) => self.toast_error(format!("Couldn't add to queue: {error}")),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn poll_remote_soon(&mut self) {
|
|
self.remote_polled_at = Instant::now() - REMOTE_POLL_IDLE + Duration::from_millis(700);
|
|
}
|
|
|
|
// ---- navigation ------------------------------------------------------------
|
|
|
|
pub fn open(&mut self, page: Page) {
|
|
if *self.page() == page {
|
|
self.ensure_loaded(page);
|
|
return;
|
|
}
|
|
self.history.truncate(self.history_index + 1);
|
|
self.history.push(page.clone());
|
|
if self.history.len() > 60 {
|
|
self.history.remove(0);
|
|
}
|
|
self.history_index = self.history.len() - 1;
|
|
self.show_devices = false;
|
|
self.ensure_loaded(page);
|
|
}
|
|
|
|
pub fn can_go_back(&self) -> bool {
|
|
self.history_index > 0
|
|
}
|
|
|
|
pub fn can_go_forward(&self) -> bool {
|
|
self.history_index + 1 < self.history.len()
|
|
}
|
|
|
|
// ---- playback --------------------------------------------------------------
|
|
|
|
fn remote(&mut self, action: RemoteAction, device_id: Option<String>) {
|
|
if device_id.is_none() && self.remote_fresh().is_none() {
|
|
// Spotify would only answer "no active device found".
|
|
self.clear_play_pending();
|
|
self.toast("Nothing is playing. Pick something first");
|
|
return;
|
|
}
|
|
self.backend.api(ApiRequest::Remote {
|
|
action,
|
|
device_id,
|
|
play: None,
|
|
position_ms: 0,
|
|
percent: 0,
|
|
flag: false,
|
|
repeat: String::new(),
|
|
});
|
|
}
|
|
|
|
/// Remembers `uri` as the most recently played context, for the
|
|
/// sidebar's order.
|
|
fn note_recent_context(&mut self, uri: &str) {
|
|
self.session_dirty = true;
|
|
if !uri.contains(":playlist:") && !uri.contains(":album:") && !uri.contains(":collection") {
|
|
return;
|
|
}
|
|
self.recent_contexts.retain(|held| held != uri);
|
|
self.recent_contexts.insert(0, uri.to_string());
|
|
self.recent_contexts.truncate(60);
|
|
}
|
|
|
|
/// A random playable track of a context the app has rows for: the
|
|
/// start of a shuffle play. `None` when no rows are at hand.
|
|
fn random_track_in(&self, context_uri: &str) -> Option<String> {
|
|
let uris: Vec<&str> = if let Some(id) = context_uri.strip_prefix("spotify:playlist:") {
|
|
self.playlist_pages
|
|
.get(id)?
|
|
.items
|
|
.items
|
|
.iter()
|
|
.filter_map(|item| item.playable())
|
|
.map(|item| item.uri())
|
|
.collect()
|
|
} else if let Some(id) = context_uri.strip_prefix("spotify:album:") {
|
|
self.album_pages
|
|
.get(id)?
|
|
.tracks
|
|
.items
|
|
.iter()
|
|
.map(|track| track.uri.as_str())
|
|
.collect()
|
|
} else if context_uri.ends_with(":collection") {
|
|
self.library
|
|
.liked
|
|
.items
|
|
.iter()
|
|
.map(|item| item.track.uri.as_str())
|
|
.collect()
|
|
} else {
|
|
return None;
|
|
};
|
|
if uris.is_empty() {
|
|
return None;
|
|
}
|
|
Some(uris[rand::random_range(0..uris.len())].to_string())
|
|
}
|
|
|
|
/// How many songs Spotify last said a context holds, from the library
|
|
/// alone: enough to start a shuffle play at a random position when the
|
|
/// rows are not loaded, which is every play begun from the sidebar, a
|
|
/// card, or a context menu. `None` for anything unsaved, whose length
|
|
/// nobody here knows.
|
|
fn context_len(&self, context_uri: &str) -> Option<u32> {
|
|
if context_uri.ends_with(":collection") {
|
|
return self.library.liked.total;
|
|
}
|
|
match util::uri_kind(context_uri)? {
|
|
"playlist" => self
|
|
.library
|
|
.playlists
|
|
.get()?
|
|
.iter()
|
|
.find(|playlist| playlist.uri == context_uri)?
|
|
.tracks
|
|
.as_ref()
|
|
.map(|tracks| tracks.total),
|
|
"album" => {
|
|
self.library
|
|
.albums
|
|
.items
|
|
.iter()
|
|
.find(|saved| saved.album.uri == context_uri)?
|
|
.album
|
|
.total_tracks
|
|
}
|
|
"show" => {
|
|
self.library
|
|
.shows
|
|
.items
|
|
.iter()
|
|
.find(|saved| saved.show.uri == context_uri)?
|
|
.show
|
|
.total_episodes
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Where a shuffled play of `context_uri` begins, as an offset by
|
|
/// track URI or by position: a random one of the rows the app holds,
|
|
/// or, when it holds none, a random position within the length the
|
|
/// library knows. Neither, and the play carries no offset at all:
|
|
/// librespot then picks its own random track, and only the Web API,
|
|
/// which would start at track one, needs telling where to go.
|
|
fn shuffle_start(&self, context_uri: &str) -> (Option<String>, Option<u32>) {
|
|
if let Some(uri) = self.random_track_in(context_uri) {
|
|
return (Some(uri), None);
|
|
}
|
|
if matches!(self.target(), Target::Remote(Some(_)))
|
|
&& let Some(len) = self.context_len(context_uri)
|
|
&& len > 0
|
|
{
|
|
return (None, Some(rand::random_range(0..len)));
|
|
}
|
|
(None, None)
|
|
}
|
|
|
|
/// With `shuffle_first`, shuffle is turned on before playback starts,
|
|
/// in one ordered exchange: two independent requests race, and shuffle
|
|
/// sometimes lost.
|
|
fn play_request(&mut self, request: PlayRequest, shuffle_first: bool) {
|
|
// Shuffle is a mode the listener sets, not a property of one
|
|
// context: once on, every play shuffles until it is turned off,
|
|
// whichever playlist it starts. A chosen row still starts the
|
|
// play; without one, a random song does.
|
|
let mut request = request;
|
|
if shuffle_first {
|
|
self.shuffle_wanted = true;
|
|
self.shuffle_set_at = Some(Instant::now());
|
|
}
|
|
let shuffle = shuffle_first || self.shuffle_wanted;
|
|
if shuffle
|
|
&& request.offset_uri.is_none()
|
|
&& request.offset_position.is_none()
|
|
&& request.uris.is_empty()
|
|
&& let Some(context) = request.context_uri.clone()
|
|
{
|
|
(request.offset_uri, request.offset_position) = self.shuffle_start(&context);
|
|
}
|
|
let mut keys: Vec<String> = Vec::new();
|
|
if let Some(context) = &request.context_uri {
|
|
keys.push(context.clone());
|
|
}
|
|
if let Some(offset) = &request.offset_uri {
|
|
keys.push(offset.clone());
|
|
}
|
|
match request.offset_position {
|
|
// The play starts at a chosen row; only that row is starting.
|
|
Some(position) => {
|
|
if let Some(uri) = request.uris.get(position as usize) {
|
|
keys.push(uri.clone());
|
|
}
|
|
}
|
|
// No chosen row: the list starts at its first song.
|
|
None if request.offset_uri.is_none() => {
|
|
if let Some(first) = request.uris.first() {
|
|
keys.push(first.clone());
|
|
}
|
|
}
|
|
None => {}
|
|
}
|
|
self.intent_track = keys
|
|
.iter()
|
|
.find(|key| key.contains(":track:"))
|
|
.cloned()
|
|
.map(|uri| (uri, Instant::now()));
|
|
self.set_play_pending(keys);
|
|
if let Some(context) = request.context_uri.clone() {
|
|
self.note_recent_context(&context);
|
|
// Light the page and the sidebar up at once; Spotify's own
|
|
// state takes a poll or two to say the same thing.
|
|
self.assumed_context = Some(AssumedContext {
|
|
uri: context,
|
|
shuffle: shuffle.then_some(true),
|
|
at: Instant::now(),
|
|
});
|
|
}
|
|
match self.target() {
|
|
Target::Local if !self.local.connected => {
|
|
// The engine's session dropped (a sleep, a network change)
|
|
// and is reconnecting on its own; hold the request and play
|
|
// the moment it is back. The spinner keeps spinning.
|
|
self.queued_play = Some(request);
|
|
}
|
|
Target::Local => {
|
|
self.queued_play = None;
|
|
let load = local_load(&request, shuffle);
|
|
self.local_list = load.context_uri.is_none().then(|| load.uris.clone());
|
|
self.backend.player(PlayerCommand::Load(load));
|
|
self.optimistic_playing = Some((true, Instant::now()));
|
|
}
|
|
Target::Remote(Some(device_id)) => {
|
|
self.queued_play = None;
|
|
if shuffle {
|
|
self.backend.api(ApiRequest::ShufflePlay {
|
|
device_id: Some(device_id),
|
|
play: request,
|
|
});
|
|
} else {
|
|
self.backend.api(ApiRequest::Remote {
|
|
action: RemoteAction::Play,
|
|
device_id: Some(device_id),
|
|
play: Some(request),
|
|
position_ms: 0,
|
|
percent: 0,
|
|
flag: false,
|
|
repeat: String::new(),
|
|
});
|
|
}
|
|
self.optimistic_playing = Some((true, Instant::now()));
|
|
}
|
|
Target::Remote(None) => {
|
|
// No remote device is active, and this computer's player is
|
|
// not ready. Never ask Spotify to play "nowhere": either
|
|
// wait for the connecting engine or ask for a device.
|
|
if matches!(
|
|
self.local_playback,
|
|
LocalPlayback::Connecting | LocalPlayback::Authorizing
|
|
) {
|
|
self.queued_play = Some(request);
|
|
} else {
|
|
self.clear_play_pending();
|
|
self.queued_play = None;
|
|
self.toast("Choose a device, or enable playback on this computer");
|
|
self.show_devices = true;
|
|
self.refresh_devices();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Adopt a playlist's disk cache once both it and the live playlist
|
|
/// are here and Spotify's snapshot still matches; a stale cache is
|
|
/// discarded, never shown.
|
|
fn try_adopt_playlist_cache(&mut self, id: &str) {
|
|
let mut uris = Vec::new();
|
|
let mut adders: Vec<String> = Vec::new();
|
|
if let Some(page) = self.playlist_pages.get_mut(id) {
|
|
let Some(snapshot_now) = page
|
|
.playlist
|
|
.get()
|
|
.and_then(|playlist| playlist.snapshot_id.clone())
|
|
else {
|
|
return;
|
|
};
|
|
match &page.pending_cache {
|
|
Some((held, _)) if *held == snapshot_now => {}
|
|
Some(_) => {
|
|
// The playlist changed since; the cache is history.
|
|
page.pending_cache = None;
|
|
return;
|
|
}
|
|
None => return,
|
|
}
|
|
if page.items.is_complete() || page.cache_complete {
|
|
page.pending_cache = None;
|
|
return;
|
|
}
|
|
let Some((_, items)) = page.pending_cache.take() else {
|
|
return;
|
|
};
|
|
uris = items
|
|
.iter()
|
|
.filter_map(|item| item.playable())
|
|
.map(|item| item.uri().to_string())
|
|
.collect();
|
|
adders = items
|
|
.iter()
|
|
.filter_map(|item| item.added_by.as_ref()?.id.clone())
|
|
.filter(|id| !id.is_empty())
|
|
.collect();
|
|
page.contributors.extend(adders.iter().cloned());
|
|
page.items.set_cached(items);
|
|
page.cache_complete = true;
|
|
}
|
|
self.request_contains(uris);
|
|
self.request_user_names(adders);
|
|
}
|
|
|
|
/// Play what was playing when the app last closed. `false` when
|
|
/// nothing is known to resume.
|
|
fn resume_last(&mut self) -> bool {
|
|
let Some(track) = self.resume_track.clone() else {
|
|
return false;
|
|
};
|
|
let mut request = match self.resume_context.clone() {
|
|
Some(context) => PlayRequest::context(context).starting_at_uri(track),
|
|
None => PlayRequest::tracks(vec![track]),
|
|
};
|
|
request.position_ms = self.resume_position_ms;
|
|
self.play_request(request, false);
|
|
true
|
|
}
|
|
|
|
fn toggle_play(&mut self) {
|
|
let playing = self.now_playing().map(|now| now.playing);
|
|
match self.target() {
|
|
Target::Local => {
|
|
if self.local.is_active() {
|
|
self.backend.player(PlayerCommand::Toggle);
|
|
} else if let Some(remote) = self.remote_fresh() {
|
|
// Nothing is playing locally: resume on this computer.
|
|
let uri = remote
|
|
.state
|
|
.item
|
|
.as_ref()
|
|
.map(|item| item.uri().to_string());
|
|
let position = remote.state.progress_ms.unwrap_or(0);
|
|
if let Some(uri) = uri {
|
|
let mut request = match &remote.state.context {
|
|
Some(context) if !context.uri.is_empty() => {
|
|
PlayRequest::context(context.uri.clone()).starting_at_uri(uri)
|
|
}
|
|
_ => PlayRequest::tracks(vec![uri]),
|
|
};
|
|
request.position_ms = position;
|
|
self.play_request(request, false);
|
|
return;
|
|
}
|
|
if !self.resume_last() {
|
|
self.toast("Pick something to play");
|
|
}
|
|
return;
|
|
} else {
|
|
if !self.resume_last() {
|
|
self.toast("Pick something to play");
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
Target::Remote(device_id) => {
|
|
if device_id.is_none() && self.remote_fresh().is_none() {
|
|
// Nothing is known to be playing anywhere, which is how
|
|
// a fresh start looks before the local engine is ready:
|
|
// pick up where the last run left off, the way the
|
|
// local branch does. The engine plays it once it is up.
|
|
if !self.resume_last() {
|
|
self.toast("Pick a song, album, or playlist");
|
|
}
|
|
return;
|
|
}
|
|
self.set_play_pending(vec!["::toggle".into()]);
|
|
if playing == Some(true) {
|
|
self.remote(RemoteAction::Pause, device_id);
|
|
} else {
|
|
self.remote(RemoteAction::Play, device_id);
|
|
}
|
|
}
|
|
}
|
|
if let Some(playing) = playing {
|
|
self.optimistic_playing = Some((!playing, Instant::now()));
|
|
}
|
|
}
|
|
|
|
fn seek(&mut self, position_ms: u32) {
|
|
match self.target() {
|
|
Target::Local => self.backend.player(PlayerCommand::Seek(position_ms)),
|
|
Target::Remote(device_id) => {
|
|
self.pending_remote_position = Some((position_ms, Instant::now()));
|
|
self.backend.api(ApiRequest::Remote {
|
|
action: RemoteAction::Seek,
|
|
device_id,
|
|
play: None,
|
|
position_ms,
|
|
percent: 0,
|
|
flag: false,
|
|
repeat: String::new(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The volume this side set that the engine has yet to confirm, if the
|
|
/// hold is still good. Clears itself once the engine agrees or it expires.
|
|
fn held_local_volume(&mut self, reported: u16) -> Option<u16> {
|
|
match self.pending_local_volume {
|
|
Some((volume, at)) if volume != reported && at.elapsed() < OPTIMISTIC_HOLD => {
|
|
Some(volume)
|
|
}
|
|
_ => {
|
|
self.pending_local_volume = None;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `settle` is false while the slider is still moving: the level is heard
|
|
/// at once, and Spotify is told where it ended up on release.
|
|
fn set_volume(&mut self, percent: u8, settle: bool) {
|
|
let percent = percent.min(100);
|
|
match self.target() {
|
|
Target::Local => {
|
|
let volume = percent_to_volume(percent);
|
|
self.local.volume = volume;
|
|
self.pending_local_volume = Some((volume, Instant::now()));
|
|
// The engine echoes `VolumeChanged` only while this device
|
|
// holds the Connect session, so the snapshot that would
|
|
// otherwise persist this may never arrive.
|
|
if self.settings.volume != volume {
|
|
self.settings.volume = volume;
|
|
self.settings_dirty = true;
|
|
}
|
|
self.backend.player(if settle {
|
|
PlayerCommand::Volume(volume)
|
|
} else {
|
|
PlayerCommand::VolumePreview(volume)
|
|
});
|
|
}
|
|
Target::Remote(_) if !settle => {}
|
|
Target::Remote(device_id) => {
|
|
self.pending_remote_volume = Some((percent, Instant::now()));
|
|
self.backend.api(ApiRequest::Remote {
|
|
action: RemoteAction::Volume,
|
|
device_id,
|
|
play: None,
|
|
position_ms: 0,
|
|
percent,
|
|
flag: false,
|
|
repeat: String::new(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_shuffle(&mut self, shuffle: bool) {
|
|
self.shuffle_wanted = shuffle;
|
|
self.shuffle_set_at = Some(Instant::now());
|
|
self.session_dirty = true;
|
|
if let Some(assumed) = &mut self.assumed_context {
|
|
assumed.shuffle = Some(shuffle);
|
|
}
|
|
match self.target() {
|
|
Target::Local => {
|
|
self.local.shuffle = shuffle;
|
|
self.backend.player(PlayerCommand::Shuffle(shuffle));
|
|
}
|
|
Target::Remote(device_id) => {
|
|
if let Some(remote) = self.remote.as_mut() {
|
|
remote.state.shuffle_state = shuffle;
|
|
}
|
|
self.backend.api(ApiRequest::Remote {
|
|
action: RemoteAction::Shuffle,
|
|
device_id,
|
|
play: None,
|
|
position_ms: 0,
|
|
percent: 0,
|
|
flag: shuffle,
|
|
repeat: String::new(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_repeat(&mut self, mode: RepeatMode) {
|
|
match self.target() {
|
|
Target::Local => {
|
|
self.local.repeat = mode;
|
|
self.backend.player(PlayerCommand::Repeat(mode));
|
|
}
|
|
Target::Remote(device_id) => {
|
|
if let Some(remote) = self.remote.as_mut() {
|
|
remote.state.repeat_state = mode.api_name().to_string();
|
|
}
|
|
self.backend.api(ApiRequest::Remote {
|
|
action: RemoteAction::Repeat,
|
|
device_id,
|
|
play: None,
|
|
position_ms: 0,
|
|
percent: 0,
|
|
flag: false,
|
|
repeat: mode.api_name().to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn transfer(&mut self, device_id: String) {
|
|
if Some(device_id.as_str()) == self.local_device_id.as_deref() {
|
|
self.selected_device = None;
|
|
self.show_devices = false;
|
|
let was_playing = self.now_playing().is_some_and(|now| now.playing);
|
|
self.backend.player(PlayerCommand::Activate);
|
|
if let Some(remote) = self.remote_fresh()
|
|
&& let Some(item) = &remote.state.item
|
|
{
|
|
let uri = item.uri().to_string();
|
|
let position = {
|
|
let base = remote.state.progress_ms.unwrap_or(0);
|
|
if remote.state.is_playing {
|
|
base + remote.received_at.elapsed().as_millis() as u32
|
|
} else {
|
|
base
|
|
}
|
|
};
|
|
let mut request = match &remote.state.context {
|
|
Some(context) if !context.uri.is_empty() => {
|
|
PlayRequest::context(context.uri.clone()).starting_at_uri(uri)
|
|
}
|
|
_ => PlayRequest::tracks(vec![uri]),
|
|
};
|
|
request.position_ms = position;
|
|
self.backend.player(PlayerCommand::Load(LoadSpec {
|
|
context_uri: request.context_uri,
|
|
uris: request.uris,
|
|
offset_uri: request.offset_uri,
|
|
offset_index: None,
|
|
position_ms: request.position_ms,
|
|
play: was_playing,
|
|
shuffle: None,
|
|
autoplay: false,
|
|
}));
|
|
}
|
|
self.poll_remote_soon();
|
|
return;
|
|
}
|
|
let play = self.now_playing().is_some_and(|now| now.playing);
|
|
self.selected_device = Some(device_id.clone());
|
|
self.backend.api(ApiRequest::Transfer { device_id, play });
|
|
}
|
|
|
|
fn add_to_queue(&mut self, uri: String, label: String) {
|
|
let device_id = match self.target() {
|
|
Target::Local => self.local_device_id.clone(),
|
|
Target::Remote(device_id) => device_id,
|
|
};
|
|
self.backend.api(ApiRequest::AddToQueue {
|
|
uri,
|
|
device_id,
|
|
label,
|
|
});
|
|
}
|
|
|
|
fn set_saved(&mut self, uri: String, saved: bool) {
|
|
self.saved.insert(uri.clone(), saved);
|
|
if uri.starts_with("spotify:playlist:") {
|
|
let id = util::uri_id(&uri).unwrap_or_default().to_string();
|
|
self.backend
|
|
.api(ApiRequest::FollowPlaylist { id, follow: saved });
|
|
return;
|
|
}
|
|
self.backend.api(ApiRequest::SetSaved {
|
|
uris: vec![uri],
|
|
saved,
|
|
});
|
|
}
|
|
|
|
// ---- actions -----------------------------------------------------------------
|
|
|
|
fn apply_actions(&mut self, ctx: &egui::Context) {
|
|
let mut actions = std::mem::take(&mut self.actions);
|
|
while !actions.is_empty() {
|
|
for action in actions.drain(..) {
|
|
self.apply(action, ctx);
|
|
}
|
|
actions = std::mem::take(&mut self.actions);
|
|
}
|
|
}
|
|
|
|
fn apply(&mut self, action: Action, ctx: &egui::Context) {
|
|
match action {
|
|
Action::Open(page) => self.open(page),
|
|
Action::OpenUri(uri) => {
|
|
if let Some(page) = Page::from_uri(&uri) {
|
|
self.open(page);
|
|
}
|
|
}
|
|
Action::Back => {
|
|
if self.can_go_back() {
|
|
self.history_index -= 1;
|
|
let page = self.page().clone();
|
|
self.ensure_loaded(page);
|
|
}
|
|
}
|
|
Action::Forward => {
|
|
if self.can_go_forward() {
|
|
self.history_index += 1;
|
|
let page = self.page().clone();
|
|
self.ensure_loaded(page);
|
|
}
|
|
}
|
|
Action::PlayContext {
|
|
uri,
|
|
offset_uri,
|
|
offset_index,
|
|
} => {
|
|
let mut request = PlayRequest::context(uri);
|
|
request.offset_uri = offset_uri;
|
|
request.offset_position = offset_index;
|
|
self.play_request(request, false);
|
|
}
|
|
Action::PlayUris { uris, index } => {
|
|
if uris.is_empty() {
|
|
return;
|
|
}
|
|
let (uris, index) = cap_uris(uris, index);
|
|
let request = PlayRequest::tracks(uris).starting_at_index(index);
|
|
self.play_request(request, false);
|
|
}
|
|
Action::PlayFromRow {
|
|
context,
|
|
uri,
|
|
index,
|
|
} => match context {
|
|
RowContext::Context {
|
|
uri: context_uri, ..
|
|
} => {
|
|
let request = PlayRequest::context(context_uri).starting_at_uri(uri);
|
|
self.play_request(request, false);
|
|
}
|
|
RowContext::Uris(uris) => {
|
|
let (uris, index) = cap_uris(uris, index);
|
|
let request = PlayRequest::tracks(uris).starting_at_index(index);
|
|
self.play_request(request, false);
|
|
}
|
|
RowContext::View { uris, context_uri } => {
|
|
let (uris, index) = cap_uris(uris, index);
|
|
let request = PlayRequest::tracks(uris).starting_at_index(index);
|
|
self.play_request(request, false);
|
|
self.note_recent_context(&context_uri);
|
|
self.assumed_context = Some(AssumedContext {
|
|
uri: context_uri,
|
|
shuffle: None,
|
|
at: Instant::now(),
|
|
});
|
|
}
|
|
},
|
|
Action::ShufflePlay(uri) => {
|
|
// The random starting song is picked in `play_request`,
|
|
// which every shuffled play goes through.
|
|
self.play_request(PlayRequest::context(uri), true);
|
|
}
|
|
Action::TogglePlay => self.toggle_play(),
|
|
Action::Next => match self.target() {
|
|
Target::Local => self.backend.player(PlayerCommand::Next),
|
|
Target::Remote(device_id) => self.remote(RemoteAction::Next, device_id),
|
|
},
|
|
Action::Previous => match self.target() {
|
|
Target::Local => self.backend.player(PlayerCommand::Previous),
|
|
Target::Remote(device_id) => self.remote(RemoteAction::Previous, device_id),
|
|
},
|
|
Action::Seek(position_ms) => self.seek(position_ms),
|
|
Action::SeekBy(offset) => {
|
|
if let Some(now) = self.now_playing() {
|
|
let target = (i64::from(now.position_ms) + offset)
|
|
.clamp(0, i64::from(now.duration_ms))
|
|
as u32;
|
|
self.seek(target);
|
|
}
|
|
}
|
|
Action::SetVolume(percent) => {
|
|
self.volume_before_mute = None;
|
|
self.set_volume(percent, true);
|
|
}
|
|
Action::PreviewVolume(percent) => self.set_volume(percent, false),
|
|
Action::VolumeBy(delta) => {
|
|
if let Some(now) = self.now_playing() {
|
|
let next =
|
|
(i16::from(now.volume_percent) + i16::from(delta)).clamp(0, 100) as u8;
|
|
self.volume_before_mute = None;
|
|
self.set_volume(next, true);
|
|
} else if self.is_connected() {
|
|
let current = volume_to_percent(self.local.volume);
|
|
let next = (i16::from(current) + i16::from(delta)).clamp(0, 100) as u8;
|
|
self.set_volume(next, true);
|
|
}
|
|
}
|
|
Action::ToggleMute => {
|
|
let current = self
|
|
.now_playing()
|
|
.map(|now| now.volume_percent)
|
|
.unwrap_or_else(|| volume_to_percent(self.local.volume));
|
|
if current == 0 {
|
|
let restore = self.volume_before_mute.take().unwrap_or(50).max(5);
|
|
self.set_volume(restore, true);
|
|
} else {
|
|
self.volume_before_mute = Some(current);
|
|
self.set_volume(0, true);
|
|
}
|
|
}
|
|
Action::ToggleShuffle => {
|
|
let shuffle = self.now_playing().is_some_and(|now| now.shuffle);
|
|
self.set_shuffle(!shuffle);
|
|
}
|
|
Action::SetShuffle(shuffle) => self.set_shuffle(shuffle),
|
|
Action::CycleRepeat => {
|
|
let mode = self.now_playing().map(|now| now.repeat).unwrap_or_default();
|
|
self.set_repeat(mode.next());
|
|
}
|
|
Action::SetRepeat(mode) => self.set_repeat(mode),
|
|
Action::AddToQueue { uri, label } => self.add_to_queue(uri, label),
|
|
Action::ToggleSaved(uri) => {
|
|
let saved = self.saved.get(&uri).copied().unwrap_or(false);
|
|
self.set_saved(uri, !saved);
|
|
}
|
|
Action::AddToPlaylist {
|
|
playlist_id,
|
|
playlist_name,
|
|
uris,
|
|
} => {
|
|
self.playlist_busy = true;
|
|
self.backend.api(ApiRequest::AddToPlaylist {
|
|
playlist_id,
|
|
playlist_name,
|
|
uris,
|
|
});
|
|
}
|
|
Action::RemoveFromPlaylist { playlist_id, uris } => {
|
|
let snapshot_id = self
|
|
.playlist_pages
|
|
.get(&playlist_id)
|
|
.and_then(|page| page.playlist.get())
|
|
.and_then(|playlist| playlist.snapshot_id.clone());
|
|
if let Some(page) = self.playlist_pages.get_mut(&playlist_id) {
|
|
page.items.retain(|item| {
|
|
item.playable()
|
|
.is_none_or(|playable| !uris.iter().any(|uri| uri == playable.uri()))
|
|
});
|
|
}
|
|
self.playlist_busy = true;
|
|
self.backend.api(ApiRequest::RemoveFromPlaylist {
|
|
playlist_id,
|
|
uris,
|
|
snapshot_id,
|
|
});
|
|
}
|
|
Action::MoveInPlaylist {
|
|
playlist_id,
|
|
from,
|
|
to,
|
|
} => {
|
|
let snapshot_id = self
|
|
.playlist_pages
|
|
.get(&playlist_id)
|
|
.and_then(|page| page.playlist.get())
|
|
.and_then(|playlist| playlist.snapshot_id.clone());
|
|
if let Some(page) = self.playlist_pages.get_mut(&playlist_id) {
|
|
page.items.reorder(from as usize, to as usize);
|
|
}
|
|
self.playlist_busy = true;
|
|
self.backend.api(ApiRequest::ReorderPlaylist {
|
|
playlist_id,
|
|
range_start: from,
|
|
insert_before: to,
|
|
snapshot_id,
|
|
});
|
|
}
|
|
Action::ShowDialog(dialog) => self.dialog = Some(dialog),
|
|
Action::CloseDialog => self.dialog = None,
|
|
Action::CreatePlaylist {
|
|
name,
|
|
public,
|
|
add_uris,
|
|
} => {
|
|
self.playlist_busy = true;
|
|
self.dialog = Some(Dialog::CreatePlaylist {
|
|
name: name.clone(),
|
|
public,
|
|
add_uris,
|
|
});
|
|
self.backend.api(ApiRequest::CreatePlaylist {
|
|
name,
|
|
public,
|
|
description: String::new(),
|
|
});
|
|
}
|
|
Action::UpdatePlaylist {
|
|
id,
|
|
name,
|
|
description,
|
|
public,
|
|
} => {
|
|
self.dialog = None;
|
|
self.playlist_busy = true;
|
|
self.backend.api(ApiRequest::UpdatePlaylist {
|
|
id,
|
|
name: Some(name),
|
|
description: Some(description),
|
|
public: Some(public),
|
|
});
|
|
}
|
|
Action::DeletePlaylist(id) => {
|
|
self.dialog = None;
|
|
self.saved.insert(format!("spotify:playlist:{id}"), false);
|
|
if let Some(playlists) = self.library.playlists.get_mut() {
|
|
playlists.retain(|playlist| playlist.id != id);
|
|
}
|
|
self.backend
|
|
.api(ApiRequest::FollowPlaylist { id, follow: false });
|
|
}
|
|
Action::Transfer(device_id) => self.transfer(device_id),
|
|
Action::ActivateReceiver(receiver) => {
|
|
if self.activating_receiver.is_none() {
|
|
self.activating_receiver = Some(receiver.name.clone());
|
|
self.backend.send(Command::ActivateReceiver(receiver));
|
|
}
|
|
}
|
|
Action::RefreshDevices => {
|
|
self.devices_fetched_at = None;
|
|
self.refresh_devices();
|
|
self.backend.send(Command::DiscoverReceivers);
|
|
}
|
|
Action::RefreshQueue => self.refresh_queue(true),
|
|
Action::CopyLink(uri) => {
|
|
if let Some(url) = util::open_spotify_url(&uri) {
|
|
ctx.copy_text(url);
|
|
self.toast("Link copied");
|
|
}
|
|
}
|
|
Action::OpenInSpotify(uri) => {
|
|
if let Some(url) = util::open_spotify_url(&uri) {
|
|
ctx.open_url(egui::OpenUrl::new_tab(url));
|
|
}
|
|
}
|
|
Action::Search(query) => {
|
|
self.search.query = query.clone();
|
|
self.search.typed_at = None;
|
|
self.open(Page::Search);
|
|
self.run_search(query.trim().to_string());
|
|
}
|
|
Action::SetSearchFilter(filter) => self.search.filter = filter,
|
|
Action::FocusSearch => {
|
|
self.search.focus_requested = true;
|
|
if !matches!(self.page(), Page::Search) {
|
|
self.open(Page::Search);
|
|
}
|
|
}
|
|
Action::LoadMore(page) => self.load_more(page),
|
|
Action::LoadMoreArtistAlbums(id) => {
|
|
let Some(page) = self.artist_pages.get_mut(&id) else {
|
|
return;
|
|
};
|
|
let groups = page.filter.groups().to_string();
|
|
let list = page.albums.entry(groups.clone()).or_default();
|
|
if let Some(offset) = list.next_offset.filter(|_| list.can_load_more()) {
|
|
list.loading = true;
|
|
self.backend
|
|
.api(ApiRequest::ArtistAlbums { id, groups, offset });
|
|
}
|
|
}
|
|
Action::SetDiscographyFilter { artist_id, filter } => {
|
|
if let Some(page) = self.artist_pages.get_mut(&artist_id) {
|
|
page.filter = filter;
|
|
}
|
|
self.load_artist_albums(&artist_id, filter);
|
|
}
|
|
Action::ToggleShowAllTop(id) => {
|
|
if let Some(page) = self.artist_pages.get_mut(&id) {
|
|
page.show_all_top = !page.show_all_top;
|
|
}
|
|
}
|
|
Action::Reload(page) => self.reload(page),
|
|
Action::SignIn => self.backend.send(Command::SignIn),
|
|
Action::CancelSignIn => {
|
|
self.backend.send(Command::CancelSignIn);
|
|
self.sign_in_url = None;
|
|
self.auth = AuthStatus::SignedOut;
|
|
}
|
|
Action::ConfigurePersonalWebApp => {
|
|
self.save_settings();
|
|
self.backend.send(Command::ConfigurePersonalWebApp(
|
|
self.settings.web_client_id.clone(),
|
|
));
|
|
}
|
|
Action::SignOut => {
|
|
self.backend.send(Command::SignOut);
|
|
self.history = vec![Page::Home];
|
|
self.history_index = 0;
|
|
}
|
|
Action::ToggleSidebar => {
|
|
self.settings.sidebar_visible = !self.settings.sidebar_visible;
|
|
self.settings_dirty = true;
|
|
}
|
|
Action::ToggleQueuePanel => {
|
|
self.show_queue_panel = !self.show_queue_panel;
|
|
if self.show_queue_panel {
|
|
self.show_lyrics_panel = false;
|
|
self.refresh_queue(true);
|
|
}
|
|
}
|
|
Action::ToggleLyricsPanel => {
|
|
self.show_lyrics_panel = !self.show_lyrics_panel;
|
|
if self.show_lyrics_panel {
|
|
self.show_queue_panel = false;
|
|
self.lyrics_following = true;
|
|
self.request_lyrics();
|
|
}
|
|
}
|
|
Action::ToggleDevicesPopup => {
|
|
self.show_devices = !self.show_devices;
|
|
if self.show_devices {
|
|
self.refresh_devices();
|
|
// Receivers waiting on the network are invisible to the
|
|
// Web API, so look for them ourselves.
|
|
self.backend.send(Command::DiscoverReceivers);
|
|
}
|
|
}
|
|
Action::SettingsChanged => {
|
|
self.settings_dirty = true;
|
|
ctx.set_theme(match self.settings.theme {
|
|
ThemeChoice::Dark => egui::ThemePreference::Dark,
|
|
ThemeChoice::Light => egui::ThemePreference::Light,
|
|
ThemeChoice::System => egui::ThemePreference::System,
|
|
});
|
|
}
|
|
Action::RestartEngine => {
|
|
self.save_settings();
|
|
let config = engine_config(
|
|
&self.dirs,
|
|
&self.settings,
|
|
std::sync::Arc::clone(&self.winamp.tap),
|
|
std::sync::Arc::clone(&self.winamp.eq),
|
|
);
|
|
self.backend.send(Command::RestartEngine(config));
|
|
if self.local_ready {
|
|
self.toast("Restarting local playback");
|
|
}
|
|
}
|
|
Action::ShowWindow => {
|
|
if self.window_hidden {
|
|
// No window exists; the outer loop creates one.
|
|
self.wants_show = true;
|
|
} else {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
|
}
|
|
}
|
|
Action::HideWindow => {
|
|
if self.tray.is_some() {
|
|
self.hide_intent = true;
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
}
|
|
}
|
|
Action::EnablePlayback => {
|
|
let free = self
|
|
.user
|
|
.as_ref()
|
|
.and_then(|user| user.product.as_deref())
|
|
.is_some_and(|product| product != "premium");
|
|
if free {
|
|
self.toast_error("Local playback needs Spotify Premium");
|
|
} else if !self.local_ready
|
|
&& !matches!(
|
|
self.local_playback,
|
|
LocalPlayback::Authorizing | LocalPlayback::Connecting
|
|
)
|
|
{
|
|
self.settings.playback_authorized = true;
|
|
self.settings_dirty = true;
|
|
self.backend.send(Command::AuthorizePlayback);
|
|
self.toast("Opening Spotify to enable playback here");
|
|
}
|
|
}
|
|
Action::OpenUrl(url) => {
|
|
// The door the sign-in uses, off the interface thread. egui's
|
|
// own link opening runs through a chain of features and a
|
|
// guessing launcher, and a click that does nothing is worse
|
|
// than no link.
|
|
std::thread::spawn(move || {
|
|
if let Err(error) = open::that(&url) {
|
|
log::warn!("unable to open {url}: {error}");
|
|
}
|
|
});
|
|
}
|
|
Action::ClearArtCache => match self.backend.art().clear_disk_cache() {
|
|
Ok(bytes) => {
|
|
ctx.forget_all_images();
|
|
self.toast(format!(
|
|
"Cleared {:.1} MB of artwork",
|
|
bytes as f64 / 1_048_576.0
|
|
));
|
|
}
|
|
Err(error) => self.toast_error(format!("Couldn't clear artwork: {error}")),
|
|
},
|
|
Action::ToggleWinampWindow => {
|
|
// One window at a time: this one closes and the loop in
|
|
// `main` opens the other kind where each was last.
|
|
if self.settings.winamp_window {
|
|
self.winamp.remember_position();
|
|
} else {
|
|
self.session_window_size = self.last_window_size.or(self.session_window_size);
|
|
self.session_window_pos = self.last_window_pos.or(self.session_window_pos);
|
|
}
|
|
self.settings.winamp_window = !self.settings.winamp_window;
|
|
self.settings_dirty = true;
|
|
self.switch_intent = true;
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
}
|
|
Action::SetSkin(name) => {
|
|
self.settings.skin = name;
|
|
self.settings_dirty = true;
|
|
}
|
|
Action::InstallSkin(path) => {
|
|
self.winamp.install(path, &self.dirs.skins_dir(), ctx);
|
|
}
|
|
Action::SetSkinScale(scale) => {
|
|
self.settings.skin_scale = Some(scale);
|
|
self.settings_dirty = true;
|
|
}
|
|
Action::ToggleWinampOnTop => {
|
|
self.settings.winamp_on_top = !self.settings.winamp_on_top;
|
|
self.settings_dirty = true;
|
|
}
|
|
Action::ToggleWinampPlaylist => {
|
|
self.settings.playlist_open = !self.settings.playlist_open;
|
|
self.settings_dirty = true;
|
|
if self.settings.playlist_open {
|
|
self.refresh_queue(false);
|
|
}
|
|
}
|
|
Action::SetPlaylistHeight(height) => {
|
|
self.settings.playlist_height = height.clamp(
|
|
crate::skin::layout::PLAYLIST_MIN_HEIGHT,
|
|
crate::skin::layout::PLAYLIST_MAX_HEIGHT,
|
|
);
|
|
self.settings_dirty = true;
|
|
}
|
|
Action::ToggleWinampEq => {
|
|
self.settings.eq_open = !self.settings.eq_open;
|
|
self.settings_dirty = true;
|
|
}
|
|
Action::ToggleEq => {
|
|
self.settings.eq_on = !self.settings.eq_on;
|
|
self.push_eq();
|
|
}
|
|
Action::SetEqBand(band, gain_db) => {
|
|
if let Some(slot) = self.settings.eq_bands_db.get_mut(band) {
|
|
*slot = gain_db.clamp(-crate::eq::RANGE_DB, crate::eq::RANGE_DB);
|
|
self.push_eq();
|
|
}
|
|
}
|
|
Action::SetEqPreamp(gain_db) => {
|
|
self.settings.eq_preamp_db =
|
|
gain_db.clamp(-crate::eq::RANGE_DB, crate::eq::RANGE_DB);
|
|
self.push_eq();
|
|
}
|
|
Action::ApplyEqPreset(index) => {
|
|
if let Some(preset) = crate::eq::PRESETS.get(index) {
|
|
self.settings.eq_bands_db = preset.bands_db;
|
|
self.settings.eq_on = true;
|
|
self.push_eq();
|
|
}
|
|
}
|
|
Action::SetBalance(balance) => {
|
|
self.settings.balance = balance.clamp(-1.0, 1.0);
|
|
self.push_eq();
|
|
}
|
|
Action::ToggleMono => {
|
|
self.settings.mono = !self.settings.mono;
|
|
self.push_eq();
|
|
}
|
|
Action::ToggleWinampShade => {
|
|
self.settings.winamp_shaded = !self.settings.winamp_shaded;
|
|
self.settings_dirty = true;
|
|
}
|
|
Action::ToggleWinampPlaylistShade => {
|
|
self.settings.playlist_shaded = !self.settings.playlist_shaded;
|
|
self.settings_dirty = true;
|
|
}
|
|
Action::ToggleWinampEqShade => {
|
|
self.settings.eq_shaded = !self.settings.eq_shaded;
|
|
self.settings_dirty = true;
|
|
}
|
|
// The same request the window's own close button makes, so the
|
|
// close-to-tray setting decides what follows.
|
|
Action::CloseWindow => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
|
Action::CycleVisualiser => {
|
|
self.settings.vis = self.settings.vis.next();
|
|
self.settings_dirty = true;
|
|
self.winamp.analyser.reset();
|
|
}
|
|
Action::OpenSkinsFolder => {
|
|
let folder = self.dirs.skins_dir();
|
|
let opened = std::fs::create_dir_all(&folder).and_then(|()| open::that(&folder));
|
|
if let Err(error) = opened {
|
|
self.toast_error(format!("Couldn't open {}: {error}", folder.display()));
|
|
}
|
|
}
|
|
Action::Quit => {
|
|
self.quit_requested = true;
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn toast(&mut self, message: impl Into<String>) {
|
|
self.toasts.push(Toast {
|
|
message: message.into(),
|
|
kind: ToastKind::Info,
|
|
created: Instant::now(),
|
|
});
|
|
self.toasts.truncate(4);
|
|
}
|
|
|
|
pub fn toast_error(&mut self, message: impl Into<String>) {
|
|
let message = message.into();
|
|
log::warn!("{message}");
|
|
self.toasts.push(Toast {
|
|
message,
|
|
kind: ToastKind::Error,
|
|
created: Instant::now(),
|
|
});
|
|
}
|
|
|
|
/// Playlists the signed-in user can add to.
|
|
pub fn editable_playlists(&self) -> Vec<(String, String)> {
|
|
let Some(user_id) = self.user_id() else {
|
|
return Vec::new();
|
|
};
|
|
self.library
|
|
.playlists
|
|
.get()
|
|
.map(|playlists| {
|
|
playlists
|
|
.iter()
|
|
.filter(|playlist| playlist.owned_by(user_id) || playlist.collaborative)
|
|
.map(|playlist| (playlist.id.clone(), playlist.name.clone()))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
impl App {
|
|
/// Everything that must keep happening whether or not a window exists:
|
|
/// backend events, MPRIS, the tray, polling, and pending actions. The
|
|
/// headless loop in `main` drives this with a windowless context while
|
|
/// the app lives in the tray.
|
|
pub fn background_frame(&mut self, ctx: &egui::Context) {
|
|
self.handle_control_commands();
|
|
self.handle_events();
|
|
self.handle_media_commands();
|
|
self.handle_tray();
|
|
self.tick(ctx);
|
|
self.apply_actions(ctx);
|
|
self.sync_media_controls();
|
|
}
|
|
|
|
pub fn frame_ui(&mut self, ui: &mut egui::Ui) {
|
|
let ctx = ui.ctx().clone();
|
|
let ctx = &ctx;
|
|
self.apply_theme(ctx);
|
|
self.lock_scroll_axis(ctx);
|
|
// The mini player has no sign-in screen; someone who needs one gets
|
|
// the big window.
|
|
let needs_sign_in = !(self.is_connected() && self.user.is_some())
|
|
&& !matches!(self.auth, AuthStatus::Connecting | AuthStatus::Starting)
|
|
&& !(self.is_connected() && self.user.is_none());
|
|
if self.settings.winamp_window && needs_sign_in && !self.switch_intent {
|
|
self.actions.push(Action::ToggleWinampWindow);
|
|
}
|
|
if self.settings.winamp_window {
|
|
crate::ui::winamp::show(self, ui);
|
|
} else {
|
|
crate::ui::show(self, ui);
|
|
}
|
|
self.apply_actions(ctx);
|
|
self.sync_media_controls();
|
|
|
|
if !self.settings.winamp_window {
|
|
if let Some(rect) = ctx.input(|input| input.viewport().inner_rect) {
|
|
self.last_window_size = Some([rect.width(), rect.height()]);
|
|
}
|
|
if let Some(rect) = ctx.input(|input| input.viewport().outer_rect) {
|
|
self.last_window_pos = Some([rect.min.x, rect.min.y]);
|
|
}
|
|
}
|
|
|
|
let playing = self.now_playing().is_some_and(|now| now.playing);
|
|
if playing {
|
|
ctx.request_repaint_after(Duration::from_millis(250));
|
|
}
|
|
if !self.toasts.is_empty() {
|
|
ctx.request_repaint_after(Duration::from_millis(120));
|
|
}
|
|
if self.any_play_pending() {
|
|
ctx.request_repaint_after(Duration::from_millis(120));
|
|
}
|
|
if self.is_connected() {
|
|
ctx.request_repaint_after(REMOTE_POLL_ACTIVE);
|
|
}
|
|
if ctx.input(|input| input.viewport().close_requested())
|
|
&& !self.quit_requested
|
|
&& !self.switch_intent
|
|
&& self.hides_to_tray()
|
|
{
|
|
// The window genuinely closes; the process stays in the tray and
|
|
// the outer loop recreates a window on demand. No compositor
|
|
// tricks: this works the same on every desktop.
|
|
self.hide_intent = true;
|
|
}
|
|
}
|
|
|
|
/// Keeps a scroll gesture on one axis.
|
|
///
|
|
/// A trackpad reports a little of the other axis during a one-axis
|
|
/// gesture, so a page whose rows scroll sideways drifted diagonally. The
|
|
/// axis is chosen from the first movement of a gesture and held until it
|
|
/// pauses, the way the platforms' own scrolling behaves.
|
|
fn lock_scroll_axis(&mut self, ctx: &egui::Context) {
|
|
let (raw, from_trackpad, ended) = ctx.input(|input| {
|
|
let mut sum = egui::Vec2::ZERO;
|
|
let mut pointish = false;
|
|
let mut ended = false;
|
|
for event in &input.events {
|
|
if let egui::Event::MouseWheel {
|
|
unit, delta, phase, ..
|
|
} = event
|
|
{
|
|
sum += *delta;
|
|
pointish |= *unit == egui::MouseWheelUnit::Point;
|
|
ended |= matches!(phase, egui::TouchPhase::End | egui::TouchPhase::Cancel);
|
|
}
|
|
}
|
|
(sum, pointish, ended)
|
|
});
|
|
let now = Instant::now();
|
|
if raw != egui::Vec2::ZERO {
|
|
self.scroll_from_trackpad = from_trackpad;
|
|
}
|
|
// Linux compositors hand touchpad deltas through unscaled and they
|
|
// land well short of what other players scroll; wheels arrive as
|
|
// lines and are scaled already. macOS feels right as delivered.
|
|
let trackpad_here = cfg!(target_os = "linux") && self.scroll_from_trackpad;
|
|
if trackpad_here {
|
|
ctx.input_mut(|input| input.smooth_scroll_delta *= TRACKPAD_SCALE);
|
|
}
|
|
// macOS glides after the fingers lift; Linux hands over raw deltas
|
|
// that stop dead. While the fingers move, remember where the gesture
|
|
// has been; the frame it ends, carry its speed of the last tenth of
|
|
// a second on, decaying, the way native scroll views here feel.
|
|
if trackpad_here && raw != egui::Vec2::ZERO {
|
|
self.glide = None;
|
|
self.scroll_accum += raw * TRACKPAD_SCALE;
|
|
self.scroll_history
|
|
.add(ctx.input(|input| input.time), self.scroll_accum);
|
|
self.scroll_last_event = Some(now);
|
|
// Wayland announces the lift; where nothing does, the quiet-gap
|
|
// check below needs a frame to run in.
|
|
ctx.request_repaint_after(Duration::from_millis(60));
|
|
} else if raw != egui::Vec2::ZERO || ctx.input(|input| input.pointer.any_down()) {
|
|
// A wheel takes over, or a press catches the page.
|
|
self.glide = None;
|
|
self.scroll_history.clear();
|
|
self.scroll_last_event = None;
|
|
}
|
|
let quiet = self
|
|
.scroll_last_event
|
|
.is_some_and(|at| now.duration_since(at).as_secs_f32() > 0.15);
|
|
if ended || quiet {
|
|
let mut velocity = self.scroll_history.velocity().unwrap_or(egui::Vec2::ZERO);
|
|
if let Some((axis, _)) = self.scroll_lock {
|
|
match axis {
|
|
ScrollAxis::Horizontal => velocity.y = 0.0,
|
|
ScrollAxis::Vertical => velocity.x = 0.0,
|
|
}
|
|
}
|
|
self.glide = (velocity.length() > GLIDE_START).then_some(velocity);
|
|
self.scroll_history.clear();
|
|
self.scroll_accum = egui::Vec2::ZERO;
|
|
self.scroll_last_event = None;
|
|
}
|
|
if let Some(velocity) = self.glide {
|
|
if raw == egui::Vec2::ZERO {
|
|
let dt = ctx.input(|input| input.stable_dt).clamp(0.001, 0.05);
|
|
ctx.input_mut(|input| input.smooth_scroll_delta += velocity * dt);
|
|
let slower = velocity * (-dt / GLIDE_DECAY).exp();
|
|
self.glide = (slower.length() > GLIDE_STOP).then_some(slower);
|
|
}
|
|
ctx.request_repaint();
|
|
}
|
|
let held = self
|
|
.scroll_lock
|
|
.filter(|(_, at)| now.duration_since(*at) < SCROLL_GESTURE_GAP)
|
|
.map(|(axis, _)| axis);
|
|
let moved = raw != egui::Vec2::ZERO;
|
|
let axis = match held {
|
|
Some(axis) => axis,
|
|
None if moved && raw.x.abs() > raw.y.abs() * 1.2 => ScrollAxis::Horizontal,
|
|
None if moved => ScrollAxis::Vertical,
|
|
None => {
|
|
self.scroll_lock = None;
|
|
return;
|
|
}
|
|
};
|
|
if moved {
|
|
self.scroll_lock = Some((axis, now));
|
|
}
|
|
ctx.input_mut(|input| match axis {
|
|
ScrollAxis::Horizontal => input.smooth_scroll_delta.y = 0.0,
|
|
ScrollAxis::Vertical => input.smooth_scroll_delta.x = 0.0,
|
|
});
|
|
}
|
|
|
|
/// Persist state when a window closes (to the tray or for good).
|
|
pub fn save_state(&mut self) {
|
|
self.save_settings();
|
|
self.save_session();
|
|
}
|
|
|
|
/// Write the restorable session: page, recents, resume point, sorts.
|
|
fn save_session(&mut self) {
|
|
self.session_dirty = false;
|
|
self.last_session_save = Instant::now();
|
|
if let Some(now) = self.now_playing() {
|
|
self.resume_context = self.playing_context_uri();
|
|
self.resume_track = Some(now.uri.clone());
|
|
self.resume_position_ms = now.position_ms;
|
|
}
|
|
if !self.offline {
|
|
SessionState {
|
|
last_page: Some(self.page().encode()),
|
|
recent_contexts: self.recent_contexts.clone(),
|
|
last_context: self.resume_context.clone(),
|
|
last_track: self.resume_track.clone(),
|
|
last_position_ms: self.resume_position_ms,
|
|
shuffle_on: self.shuffle_wanted,
|
|
sorts: self
|
|
.table_sorts
|
|
.iter()
|
|
.map(|(page, sort)| (page.encode(), *sort))
|
|
.collect(),
|
|
window_size: self.last_window_size.or(self.session_window_size),
|
|
window_pos: self.last_window_pos.or(self.session_window_pos),
|
|
queue_open: Some(self.show_queue_panel),
|
|
winamp_pos: self.winamp.last_pos.or(self.winamp.restore_pos),
|
|
}
|
|
.save(&self.dirs.session_file());
|
|
}
|
|
}
|
|
|
|
/// Final teardown at real quit.
|
|
pub fn shutdown(&mut self) {
|
|
self.save_state();
|
|
self.backend.shutdown();
|
|
}
|
|
}
|
|
|
|
pub fn engine_config(
|
|
dirs: &AppDirs,
|
|
settings: &Settings,
|
|
tap: std::sync::Arc<crate::vis::AudioTap>,
|
|
eq: crate::eq::SharedEq,
|
|
) -> EngineConfig {
|
|
EngineConfig {
|
|
tap,
|
|
eq,
|
|
device_name: settings.device_name.trim().to_string(),
|
|
bitrate_kbps: settings.bitrate,
|
|
normalisation: settings.normalisation,
|
|
autoplay: settings.autoplay,
|
|
gapless: settings.gapless,
|
|
backend: settings.platform_backend(),
|
|
audio_device: settings
|
|
.audio_device
|
|
.clone()
|
|
.filter(|device| !device.trim().is_empty()),
|
|
initial_volume: settings.volume,
|
|
credentials_dir: dirs.credentials_dir(),
|
|
volume_dir: dirs.volume_dir(),
|
|
audio_cache_dir: settings.audio_cache.then(|| dirs.audio_cache_dir()),
|
|
audio_cache_limit: Some(settings.audio_cache_mb.max(64) * 1024 * 1024),
|
|
}
|
|
}
|
|
|
|
/// The equalizer as the settings describe it.
|
|
pub fn eq_settings(settings: &Settings) -> crate::eq::EqSettings {
|
|
crate::eq::EqSettings {
|
|
on: settings.eq_on,
|
|
preamp_db: settings.eq_preamp_db,
|
|
bands_db: settings.eq_bands_db,
|
|
balance: settings.balance,
|
|
mono: settings.mono,
|
|
}
|
|
.clamped()
|
|
}
|
|
|
|
pub fn volume_to_percent(volume: u16) -> u8 {
|
|
((u32::from(volume) * 100 + u32::from(u16::MAX) / 2) / u32::from(u16::MAX)) as u8
|
|
}
|
|
|
|
pub fn percent_to_volume(percent: u8) -> u16 {
|
|
((u32::from(percent.min(100)) * u32::from(u16::MAX)) / 100) as u16
|
|
}
|
|
|
|
fn page_related_needs_load(pages: &HashMap<String, ArtistPage>, id: &str) -> bool {
|
|
pages.get(id).is_some_and(|page| page.related.needs_load())
|
|
}
|
|
|
|
fn remote_action_label(action: RemoteAction) -> &'static str {
|
|
match action {
|
|
RemoteAction::Play => "Couldn't start playback",
|
|
RemoteAction::Pause => "Couldn't pause",
|
|
RemoteAction::Next => "Couldn't skip",
|
|
RemoteAction::Previous => "Couldn't go back",
|
|
RemoteAction::Seek => "Couldn't seek",
|
|
RemoteAction::Volume => "Couldn't change the volume",
|
|
RemoteAction::Shuffle => "Couldn't change shuffle",
|
|
RemoteAction::Repeat => "Couldn't change repeat",
|
|
}
|
|
}
|
|
|
|
fn friendly_page_error(error: &crate::api::ApiError) -> String {
|
|
match error.status() {
|
|
Some(403) | Some(404) => {
|
|
"Spotify doesn't make this playlist's songs available to third-party apps.".to_string()
|
|
}
|
|
_ => error.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Whether a Spotify-owned playlist named `name` is the personal one the
|
|
/// Made for you shelf looks for under `term`. The name has to be the term
|
|
/// itself, or "Daily Mix" with a number: Spotify also makes "<Artist> Mix",
|
|
/// "This Is <Artist>", and "<Artist> Radio" for every artist, and an artist
|
|
/// called "Discover Weekly" put those on the shelf (#89).
|
|
fn is_made_for_you(name: &str, term: &str) -> bool {
|
|
let name = name.trim().to_lowercase();
|
|
let term = term.to_lowercase();
|
|
if name == term {
|
|
return true;
|
|
}
|
|
term == "daily mix"
|
|
&& name
|
|
.strip_prefix("daily mix ")
|
|
.is_some_and(|rest| !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()))
|
|
}
|
|
|
|
/// What the engine is told to play. A single song goes as a context of
|
|
/// its own rather than a list of one: Spotify resolves a track's URI as a
|
|
/// context, and a context with a URI is what librespot's autoplay carries
|
|
/// on from when it ends, the way one song from a search does in Spotify.
|
|
fn local_load(request: &PlayRequest, shuffle: bool) -> LoadSpec {
|
|
let single_song = request.context_uri.is_none()
|
|
&& request.uris.len() == 1
|
|
&& request.uris[0].contains(":track:");
|
|
if single_song {
|
|
return LoadSpec {
|
|
context_uri: Some(request.uris[0].clone()),
|
|
position_ms: request.position_ms,
|
|
play: true,
|
|
..LoadSpec::default()
|
|
};
|
|
}
|
|
LoadSpec {
|
|
context_uri: request.context_uri.clone(),
|
|
uris: request.uris.clone(),
|
|
offset_uri: request.offset_uri.clone(),
|
|
offset_index: request.offset_position,
|
|
position_ms: request.position_ms,
|
|
play: true,
|
|
shuffle: shuffle.then_some(true),
|
|
autoplay: false,
|
|
}
|
|
}
|
|
|
|
/// The song to seed autoplay with when local playback stops at the end of
|
|
/// a plain list: the list's last one, provided the stop is the list's end
|
|
/// rather than the listener's, the session still stands, and autoplay is
|
|
/// on.
|
|
fn autoplay_seed(
|
|
list: Option<&[String]>,
|
|
autoplay: bool,
|
|
before: &LocalState,
|
|
after: &LocalState,
|
|
) -> Option<String> {
|
|
if !autoplay
|
|
|| !after.connected
|
|
|| after.playback != Playback::Stopped
|
|
|| before.playback != Playback::Playing
|
|
{
|
|
return None;
|
|
}
|
|
let track = before.track.as_ref()?;
|
|
if list?.last() != Some(&track.uri) || track.duration_ms == 0 {
|
|
return None;
|
|
}
|
|
let near_end = before.position_now() + 3_000 >= track.duration_ms;
|
|
near_end.then(|| track.uri.clone())
|
|
}
|
|
|
|
/// Spotify balks at gigantic track lists, so a play that starts deep in
|
|
/// one keeps the five hundred songs from its start onward.
|
|
fn cap_uris(uris: Vec<String>, index: u32) -> (Vec<String>, u32) {
|
|
const MAX: usize = 500;
|
|
if uris.len() <= MAX {
|
|
return (uris, index);
|
|
}
|
|
let start = (index as usize).min(uris.len() - 1);
|
|
let end = (start + MAX).min(uris.len());
|
|
(uris[start..end].to_vec(), 0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn volume_conversions_round_trip() {
|
|
assert_eq!(volume_to_percent(u16::MAX), 100);
|
|
assert_eq!(volume_to_percent(0), 0);
|
|
assert_eq!(volume_to_percent(percent_to_volume(70)), 70);
|
|
assert_eq!(percent_to_volume(200), u16::MAX);
|
|
}
|
|
|
|
fn headless_app() -> App {
|
|
let root =
|
|
std::env::temp_dir().join(format!("fastpotify-volume-test-{}", std::process::id()));
|
|
let dirs = AppDirs {
|
|
config: root.join("config"),
|
|
state: root.join("state"),
|
|
cache: root.join("cache"),
|
|
};
|
|
let mut app = App::new(
|
|
&Waker::default(),
|
|
dirs,
|
|
Settings::default(),
|
|
AppOptions {
|
|
media_controls: false,
|
|
tray: false,
|
|
},
|
|
);
|
|
app.local_ready = true;
|
|
app
|
|
}
|
|
|
|
/// A shuffle play must not start at track one, wherever it is begun
|
|
/// from: on the rows the app holds it picks one of them, and with no
|
|
/// rows at hand the Web API is sent a position drawn from the length
|
|
/// the library knows. Local playback is given neither, because
|
|
/// librespot draws its own starting track.
|
|
#[test]
|
|
fn a_shuffled_play_does_not_start_at_track_one() {
|
|
use crate::api::models::{
|
|
Album, PlayableItem, Playlist, PlaylistItem, SavedAlbum, Track, TrackCount,
|
|
};
|
|
let track = |uri: &str| {
|
|
Some(PlayableItem::Track(Track {
|
|
uri: uri.into(),
|
|
..Default::default()
|
|
}))
|
|
};
|
|
let mut app = headless_app();
|
|
app.library.playlists = Loadable::Loaded(vec![
|
|
Playlist {
|
|
uri: "spotify:playlist:open".into(),
|
|
tracks: Some(TrackCount { total: 3 }),
|
|
..Default::default()
|
|
},
|
|
Playlist {
|
|
uri: "spotify:playlist:unopened".into(),
|
|
tracks: Some(TrackCount { total: 57 }),
|
|
..Default::default()
|
|
},
|
|
]);
|
|
app.library.albums.items = vec![SavedAlbum {
|
|
album: Album {
|
|
uri: "spotify:album:saved".into(),
|
|
total_tracks: Some(12),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
}];
|
|
app.library.liked.total = Some(9);
|
|
app.playlist_pages.insert(
|
|
"open".into(),
|
|
PlaylistPage {
|
|
items: PagedList {
|
|
items: vec![
|
|
PlaylistItem {
|
|
item: track("spotify:track:one"),
|
|
..Default::default()
|
|
},
|
|
PlaylistItem {
|
|
item: track("spotify:track:two"),
|
|
..Default::default()
|
|
},
|
|
],
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
},
|
|
);
|
|
// Playing on a phone: the Web API needs the offset.
|
|
app.selected_device = Some("phone".into());
|
|
assert!(matches!(app.target(), Target::Remote(Some(_))));
|
|
|
|
// The playlist on screen: the start is one of its own rows.
|
|
let (uri, position) = app.shuffle_start("spotify:playlist:open");
|
|
assert!(
|
|
matches!(
|
|
uri.as_deref(),
|
|
Some("spotify:track:one" | "spotify:track:two")
|
|
),
|
|
"the start comes from the rows, got {uri:?}"
|
|
);
|
|
assert_eq!(position, None);
|
|
|
|
// Begun from the sidebar or a menu, with no rows loaded: a
|
|
// position inside the length the library reported.
|
|
for (context, len) in [
|
|
("spotify:playlist:unopened", 57),
|
|
("spotify:album:saved", 12),
|
|
("spotify:user:someone:collection", 9),
|
|
] {
|
|
for _ in 0..50 {
|
|
let (uri, position) = app.shuffle_start(context);
|
|
assert_eq!(uri, None, "{context} has no rows to name");
|
|
let position = position.unwrap_or_else(|| panic!("{context} got no offset"));
|
|
assert!(
|
|
position < len,
|
|
"{context} offset {position} is outside {len}"
|
|
);
|
|
}
|
|
}
|
|
// Over 50 draws a 57-song playlist should not have sat still on
|
|
// one song, let alone on the first.
|
|
let drawn: std::collections::HashSet<Option<u32>> = (0..50)
|
|
.map(|_| app.shuffle_start("spotify:playlist:unopened").1)
|
|
.collect();
|
|
assert!(drawn.len() > 1, "the starting position never moved");
|
|
|
|
// Nothing saved, nothing loaded: no offset to give.
|
|
assert_eq!(app.shuffle_start("spotify:playlist:unknown"), (None, None));
|
|
|
|
// Local playback: librespot picks the starting track itself.
|
|
app.selected_device = None;
|
|
assert!(matches!(app.target(), Target::Local));
|
|
assert_eq!(
|
|
app.shuffle_start("spotify:playlist:unopened"),
|
|
(None, None),
|
|
"librespot is left to draw its own"
|
|
);
|
|
}
|
|
|
|
/// A Free account is told once per sign-in that nothing will play;
|
|
/// a Premium one is not bothered.
|
|
#[test]
|
|
fn a_free_account_is_told_once_that_it_cannot_play() {
|
|
let me = |product: &str| {
|
|
ApiResponse::Me(Ok(crate::api::models::User {
|
|
id: "someone".into(),
|
|
product: Some(product.into()),
|
|
..Default::default()
|
|
}))
|
|
};
|
|
let mut app = headless_app();
|
|
app.handle_api(me("free"));
|
|
assert!(matches!(app.dialog, Some(Dialog::PremiumNeeded)));
|
|
app.dialog = None;
|
|
app.handle_api(me("free"));
|
|
assert!(app.dialog.is_none(), "the notice is shown once");
|
|
|
|
let mut app = headless_app();
|
|
app.handle_api(me("premium"));
|
|
assert!(app.dialog.is_none());
|
|
}
|
|
|
|
/// Only the personal playlists themselves belong on the shelf, not
|
|
/// what Spotify generates for an artist who took one of their names.
|
|
#[test]
|
|
fn the_shelf_takes_the_playlist_and_not_an_artist_named_after_it() {
|
|
assert!(is_made_for_you("Discover Weekly", "Discover Weekly"));
|
|
assert!(is_made_for_you("release radar", "Release Radar"));
|
|
assert!(is_made_for_you("daylist", "daylist"));
|
|
assert!(is_made_for_you("Daily Mix 3", "Daily Mix"));
|
|
assert!(is_made_for_you("Daily Mix", "Daily Mix"));
|
|
assert!(!is_made_for_you("Discover Weekly Mix", "Discover Weekly"));
|
|
assert!(!is_made_for_you(
|
|
"This Is Discover Weekly",
|
|
"Discover Weekly"
|
|
));
|
|
assert!(!is_made_for_you("Release Radar Radio", "Release Radar"));
|
|
assert!(!is_made_for_you("Daily Mix Radio", "Daily Mix"));
|
|
assert!(!is_made_for_you("Daily Mix 3", "Discover Weekly"));
|
|
}
|
|
|
|
/// One song plays as a context of its own, so librespot's autoplay
|
|
/// follows it; a list stays a list.
|
|
#[test]
|
|
fn one_song_is_loaded_as_a_context() {
|
|
let one = local_load(&PlayRequest::tracks(vec!["spotify:track:a".into()]), false);
|
|
assert_eq!(one.context_uri.as_deref(), Some("spotify:track:a"));
|
|
assert!(one.uris.is_empty() && !one.autoplay);
|
|
let two = local_load(
|
|
&PlayRequest::tracks(vec!["spotify:track:a".into(), "spotify:track:b".into()])
|
|
.starting_at_index(1),
|
|
true,
|
|
);
|
|
assert_eq!(two.context_uri, None);
|
|
assert_eq!(two.uris.len(), 2);
|
|
assert_eq!(two.offset_index, Some(1));
|
|
assert_eq!(two.shuffle, Some(true));
|
|
let episode = local_load(
|
|
&PlayRequest::tracks(vec!["spotify:episode:e".into()]),
|
|
false,
|
|
);
|
|
assert_eq!(episode.context_uri, None);
|
|
assert_eq!(episode.uris.len(), 1);
|
|
}
|
|
|
|
/// A plain list that plays out seeds autoplay with its last song; a
|
|
/// stop anywhere else, a dropped session, or autoplay off does not.
|
|
#[test]
|
|
fn a_list_that_ends_seeds_autoplay_with_its_last_song() {
|
|
let track = |uri: &str| {
|
|
Some(crate::player::LocalTrack {
|
|
uri: uri.into(),
|
|
duration_ms: 200_000,
|
|
..Default::default()
|
|
})
|
|
};
|
|
let playing = LocalState {
|
|
playback: Playback::Playing,
|
|
track: track("spotify:track:last"),
|
|
position_ms: 198_500,
|
|
connected: true,
|
|
..LocalState::default()
|
|
};
|
|
let stopped = LocalState {
|
|
playback: Playback::Stopped,
|
|
track: track("spotify:track:last"),
|
|
connected: true,
|
|
..LocalState::default()
|
|
};
|
|
let list: Vec<String> = vec!["spotify:track:first".into(), "spotify:track:last".into()];
|
|
assert_eq!(
|
|
autoplay_seed(Some(&list), true, &playing, &stopped).as_deref(),
|
|
Some("spotify:track:last")
|
|
);
|
|
assert_eq!(autoplay_seed(Some(&list), false, &playing, &stopped), None);
|
|
assert_eq!(autoplay_seed(None, true, &playing, &stopped), None);
|
|
let mid_song = LocalState {
|
|
position_ms: 60_000,
|
|
..playing.clone()
|
|
};
|
|
assert_eq!(autoplay_seed(Some(&list), true, &mid_song, &stopped), None);
|
|
let not_last = LocalState {
|
|
track: track("spotify:track:first"),
|
|
..playing.clone()
|
|
};
|
|
assert_eq!(autoplay_seed(Some(&list), true, ¬_last, &stopped), None);
|
|
let dropped = LocalState {
|
|
connected: false,
|
|
..stopped.clone()
|
|
};
|
|
assert_eq!(autoplay_seed(Some(&list), true, &playing, &dropped), None);
|
|
}
|
|
|
|
fn snapshot_at(percent: u8) -> LocalState {
|
|
LocalState {
|
|
volume: percent_to_volume(percent),
|
|
..LocalState::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_volume_set_here_is_saved_immediately() {
|
|
let mut app = headless_app();
|
|
app.set_volume(80, true);
|
|
assert_eq!(volume_to_percent(app.settings.volume), 80);
|
|
assert!(app.settings_dirty);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stale_engine_snapshot_does_not_pull_the_volume_back() {
|
|
let mut app = headless_app();
|
|
app.set_volume(80, true);
|
|
|
|
// The engine reports `VolumeChanged` asynchronously, so its next
|
|
// snapshot still carries the volume from before the change.
|
|
app.handle_local(snapshot_at(20));
|
|
assert_eq!(volume_to_percent(app.local.volume), 80);
|
|
assert_eq!(volume_to_percent(app.settings.volume), 80);
|
|
|
|
// Once it has caught up, its snapshots are trusted again.
|
|
app.handle_local(snapshot_at(80));
|
|
assert_eq!(volume_to_percent(app.local.volume), 80);
|
|
}
|
|
|
|
#[test]
|
|
fn a_volume_changed_outside_the_app_is_adopted() {
|
|
let mut app = headless_app();
|
|
app.handle_local(snapshot_at(35));
|
|
assert_eq!(volume_to_percent(app.local.volume), 35);
|
|
assert_eq!(volume_to_percent(app.settings.volume), 35);
|
|
}
|
|
|
|
/// What a Raycast script sends becomes the same action a menu pick or a
|
|
/// media key would produce.
|
|
#[test]
|
|
fn a_control_command_becomes_the_action_it_names() {
|
|
// #given
|
|
let mut app = headless_app();
|
|
let queue: std::sync::Arc<std::sync::Mutex<Vec<ControlCommand>>> = Default::default();
|
|
app.control_commands = Some(std::sync::Arc::clone(&queue));
|
|
|
|
// #when
|
|
queue.lock().expect("the queue").extend([
|
|
ControlCommand::Next,
|
|
ControlCommand::Previous,
|
|
ControlCommand::SeekBy(-15_000),
|
|
ControlCommand::VolumeBy(10),
|
|
ControlCommand::SetVolume(240),
|
|
ControlCommand::ToggleShuffle,
|
|
ControlCommand::Show,
|
|
]);
|
|
app.handle_control_commands();
|
|
|
|
// #then
|
|
assert!(
|
|
matches!(
|
|
app.actions.as_slice(),
|
|
[
|
|
Action::Next,
|
|
Action::Previous,
|
|
Action::SeekBy(-15_000),
|
|
Action::VolumeBy(10),
|
|
// A percentage above the scale is clamped, not wrapped.
|
|
Action::SetVolume(100),
|
|
Action::ToggleShuffle,
|
|
Action::ShowWindow,
|
|
]
|
|
),
|
|
"{:?}",
|
|
app.actions
|
|
);
|
|
assert!(queue.lock().expect("the queue").is_empty());
|
|
}
|
|
|
|
/// The verbs a Stream Deck key needs and a media key never asked for:
|
|
/// a state said outright rather than toggled, an absolute position, a
|
|
/// URI, and moving the sound to another device.
|
|
#[test]
|
|
fn a_key_can_ask_for_a_state_rather_than_a_toggle() {
|
|
// #given
|
|
let mut app = headless_app();
|
|
let queue: std::sync::Arc<std::sync::Mutex<Vec<ControlCommand>>> = Default::default();
|
|
app.control_commands = Some(std::sync::Arc::clone(&queue));
|
|
|
|
// #when
|
|
queue.lock().expect("the queue").extend([
|
|
ControlCommand::SetShuffle(true),
|
|
ControlCommand::SetRepeat(RepeatMode::Track),
|
|
ControlCommand::SeekTo(90_000),
|
|
ControlCommand::PlayUri("spotify:playlist:pl1".to_owned()),
|
|
ControlCommand::Transfer("abc123".to_owned()),
|
|
ControlCommand::RefreshDevices,
|
|
// Nothing is playing in a headless app, so there is no track to
|
|
// save and this one falls away rather than erroring.
|
|
ControlCommand::ToggleSaved,
|
|
]);
|
|
app.handle_control_commands();
|
|
|
|
// #then
|
|
assert!(
|
|
matches!(
|
|
app.actions.as_slice(),
|
|
[
|
|
Action::SetShuffle(true),
|
|
Action::SetRepeat(RepeatMode::Track),
|
|
Action::Seek(90_000),
|
|
Action::PlayContext {
|
|
offset_uri: None,
|
|
offset_index: None,
|
|
..
|
|
},
|
|
Action::Transfer(_),
|
|
Action::RefreshDevices,
|
|
]
|
|
),
|
|
"{:?}",
|
|
app.actions
|
|
);
|
|
assert!(queue.lock().expect("the queue").is_empty());
|
|
}
|
|
|
|
/// The snapshot a client polls keeps its first nine fields where they
|
|
/// were, so a script written against the older shape still reads them,
|
|
/// and says "unknown" rather than guessing at a saved flag nobody has
|
|
/// told it yet.
|
|
#[test]
|
|
fn the_snapshot_appends_what_a_key_needs_without_moving_what_was_there() {
|
|
// #given
|
|
let mut app = headless_app();
|
|
app.handle_local(LocalState {
|
|
playback: Playback::Playing,
|
|
track: Some(crate::player::LocalTrack {
|
|
uri: "spotify:track:t1".to_owned(),
|
|
title: "Go".to_owned(),
|
|
artists: vec!["The Band".to_owned()],
|
|
album: "First".to_owned(),
|
|
art_url: Some("https://i.scdn.co/image/abc".to_owned()),
|
|
duration_ms: 200_000,
|
|
..Default::default()
|
|
}),
|
|
position_ms: 20_000,
|
|
volume: percent_to_volume(35),
|
|
shuffle: true,
|
|
repeat: RepeatMode::Track,
|
|
..LocalState::default()
|
|
});
|
|
|
|
// #when
|
|
let snapshot = app.control_snapshot();
|
|
let fields: Vec<&str> = snapshot.split('\t').collect();
|
|
|
|
// #then
|
|
assert_eq!(
|
|
fields,
|
|
[
|
|
// The nine a media key or a Raycast script already read.
|
|
"playing",
|
|
"Go",
|
|
"The Band",
|
|
"First",
|
|
"20000",
|
|
"200000",
|
|
"35",
|
|
"on",
|
|
"track",
|
|
// The three a Stream Deck key needs, appended.
|
|
"https://i.scdn.co/image/abc",
|
|
// Not signed in, so nobody has said whether this is saved.
|
|
"unknown",
|
|
// Local playback is this computer, which Spotify has not
|
|
// named because it is not a remote device.
|
|
"Fastpotify",
|
|
]
|
|
);
|
|
// No devices seen yet is an empty array, not an empty string, so a
|
|
// client never special-cases the answer.
|
|
assert_eq!(
|
|
app.control_devices_snapshot(),
|
|
crate::single_instance::NO_DEVICES
|
|
);
|
|
}
|
|
|
|
/// The device slot is written when Spotify answers rather than every
|
|
/// frame, so the thing to check is that an answer still reaches it.
|
|
#[test]
|
|
fn a_device_list_reaches_the_slot_when_spotify_answers() {
|
|
// #given
|
|
let mut app = headless_app();
|
|
let slot = std::sync::Arc::new(std::sync::Mutex::new(
|
|
crate::single_instance::NO_DEVICES.to_owned(),
|
|
));
|
|
app.control_devices = Some(std::sync::Arc::clone(&slot));
|
|
app.control_devices_stale = false;
|
|
|
|
// #when
|
|
app.handle_api(ApiResponse::Devices(Ok(vec![Device {
|
|
id: Some("abc123".to_owned()),
|
|
name: "Kitchen\tspeaker".to_owned(),
|
|
kind: "Speaker".to_owned(),
|
|
is_active: true,
|
|
..Device::default()
|
|
}])));
|
|
app.sync_media_controls();
|
|
|
|
// #then
|
|
let written = slot.lock().expect("the slot").clone();
|
|
assert_eq!(
|
|
written,
|
|
r#"[{"active":true,"id":"abc123","kind":"Speaker","name":"Kitchen\tspeaker"}]"#,
|
|
"a name is carried whole, tab and all, because JSON escapes it \
|
|
where the tab-separated snapshot could not"
|
|
);
|
|
// Written once and not again until the next answer.
|
|
assert!(!app.control_devices_stale);
|
|
}
|
|
|
|
/// `play` and `pause` say what state to end in, so the one that would
|
|
/// undo the current state does nothing.
|
|
#[test]
|
|
fn play_and_pause_do_not_toggle_the_wrong_way() {
|
|
let mut app = headless_app();
|
|
let queue: std::sync::Arc<std::sync::Mutex<Vec<ControlCommand>>> = Default::default();
|
|
app.control_commands = Some(std::sync::Arc::clone(&queue));
|
|
|
|
// Nothing is playing in a headless app, so `pause` has nothing to do
|
|
// and `play` asks for the toggle.
|
|
queue
|
|
.lock()
|
|
.expect("the queue")
|
|
.extend([ControlCommand::Pause, ControlCommand::Play]);
|
|
app.handle_control_commands();
|
|
|
|
assert!(
|
|
matches!(app.actions.as_slice(), [Action::TogglePlay]),
|
|
"{:?}",
|
|
app.actions
|
|
);
|
|
}
|
|
|
|
/// A skin with a bitmap in it, for pretending one was read.
|
|
fn some_skin(name: &str) -> crate::skin::Skin {
|
|
let image = image::RgbImage::from_pixel(275, 116, image::Rgb([9, 9, 9]));
|
|
let mut png = std::io::Cursor::new(Vec::new());
|
|
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
|
|
let archive = crate::skin::zip::write(&[("main.bmp", png.get_ref(), false)]);
|
|
crate::skin::Skin::from_archive(name, &archive).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn a_skin_read_late_does_not_override_a_newer_choice() {
|
|
let mut app = headless_app();
|
|
app.settings.winamp_window = true;
|
|
app.settings.skin = Some("B.wsz".into());
|
|
app.skin_loaded(crate::winamp::Loaded {
|
|
name: "A.wsz".into(),
|
|
result: Ok(some_skin("A")),
|
|
installed: false,
|
|
});
|
|
assert_eq!(app.winamp.worn.as_deref(), Some("A.wsz"));
|
|
assert_eq!(app.settings.skin.as_deref(), Some("B.wsz"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_dropped_skin_becomes_the_choice_and_a_failed_one_is_forgotten() {
|
|
let mut app = headless_app();
|
|
app.settings.winamp_window = true;
|
|
app.skin_loaded(crate::winamp::Loaded {
|
|
name: "Dropped.wsz".into(),
|
|
result: Ok(some_skin("Dropped")),
|
|
installed: true,
|
|
});
|
|
assert_eq!(app.settings.skin.as_deref(), Some("Dropped.wsz"));
|
|
assert_eq!(app.winamp.worn.as_deref(), Some("Dropped.wsz"));
|
|
assert!(
|
|
app.toasts
|
|
.iter()
|
|
.any(|toast| toast.message == "Added the Dropped skin")
|
|
);
|
|
|
|
app.settings.skin = Some("Gone.wsz".into());
|
|
app.skin_loaded(crate::winamp::Loaded {
|
|
name: "Gone.wsz".into(),
|
|
result: Err(crate::skin::SkinError::Empty),
|
|
installed: false,
|
|
});
|
|
assert_eq!(app.settings.skin.as_deref(), Some("Dropped.wsz"));
|
|
assert_eq!(app.winamp.worn.as_deref(), Some("Dropped.wsz"));
|
|
assert!(
|
|
app.toasts
|
|
.iter()
|
|
.any(|toast| toast.message.starts_with("Gone: "))
|
|
);
|
|
}
|
|
}
|