From 34e25fac2c43a84975523d85a0e9155ee6eb1b5c Mon Sep 17 00:00:00 2001 From: AB Date: Thu, 28 May 2026 13:15:42 +0300 Subject: [PATCH] CORE: added 'connected devices' like in spotify --- Cargo.toml | 2 +- src/player/dto.rs | 71 +++++ src/player/mod.rs | 499 +++++++++++++++++++++++++++++++++- templates/player/scripts.html | 433 ++++++++++++++++++++++++++++- templates/player/shell.html | 45 +++ templates/player/styles.html | 106 ++++++++ 6 files changed, 1140 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e93877c..cf07ec8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.2.1" +version = "0.2.2" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/player/dto.rs b/src/player/dto.rs index 1df6fa5..0a406dc 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -137,6 +137,77 @@ pub(super) struct PlaybackStateDto { pub(super) volume: f64, } +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct DeviceHeartbeatRequest { + pub(super) device_id: String, + pub(super) user_agent: Option, + pub(super) playback_state: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct DeviceSelectRequest { + pub(super) device_id: String, + pub(super) current_device_id: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub(super) struct DeviceCommandRequest { + pub(super) target_device_id: Option, + pub(super) command: String, + #[serde(default)] + pub(super) payload: serde_json::Value, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlayerDeviceDto { + pub(super) id: String, + pub(super) name: String, + pub(super) kind: String, + pub(super) is_current: bool, + pub(super) is_active: bool, + pub(super) last_seen_ms: i64, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlayerDeviceCommandDto { + pub(super) id: String, + pub(super) command: String, + pub(super) payload: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub(super) struct PlayerDevicePlaybackStateDto { + pub(super) track: Option, + #[serde(default)] + pub(super) tracks: Vec, + pub(super) index: i32, + pub(super) position_seconds: f64, + pub(super) duration_seconds: f64, + pub(super) paused: bool, + pub(super) shuffle: bool, + pub(super) repeat_mode: String, + pub(super) volume: f64, + #[serde(default)] + pub(super) updated_at_ms: i64, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlayerDevicesResponse { + pub(super) device_id: String, + pub(super) active_device_id: Option, + pub(super) devices: Vec, + pub(super) playback_state: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlayerDevicePollResponse { + pub(super) device_id: String, + pub(super) active_device_id: Option, + pub(super) devices: Vec, + pub(super) commands: Vec, + pub(super) playback_state: Option, +} + #[derive(Debug, Serialize, JsonSchema)] pub(super) struct PlaylistDetail { pub(super) id: i64, diff --git a/src/player/mod.rs b/src/player/mod.rs index a5ab7ca..5716d3a 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -1,4 +1,5 @@ -use std::sync::Arc; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; use cot::db::Database; use cot::http::StatusCode; @@ -50,6 +51,347 @@ struct LocalUploadResponse { size: u64, } +const PLAYER_DEVICE_TTL_MS: i64 = 30_000; +const PLAYER_DEVICE_COMMAND_TTL_MS: i64 = 20_000; +const PLAYER_DEVICE_MAX_COMMANDS: usize = 32; + +#[derive(Debug, Clone)] +struct PlayerDevice { + id: String, + name: String, + kind: String, + last_seen_ms: i64, +} + +#[derive(Debug, Clone)] +struct PendingPlayerDeviceCommand { + id: String, + command: String, + payload: serde_json::Value, + created_at_ms: i64, +} + +#[derive(Debug, Default)] +struct PlayerDeviceHubState { + devices_by_user: HashMap>, + active_device_by_user: HashMap, + commands_by_device: HashMap<(i64, String), VecDeque>, + playback_state_by_user: HashMap, +} + +#[derive(Debug, Default)] +struct PlayerDeviceHub { + state: Mutex, +} + +impl PlayerDeviceHub { + fn heartbeat( + &self, + user_id: i64, + device_id: &str, + user_agent: Option<&str>, + playback_state: Option, + ) -> PlayerDevicesResponse { + let now = current_millis(); + let mut state = self.state.lock().expect("player device hub lock"); + self.prune_locked(&mut state, now); + self.touch_locked(&mut state, user_id, device_id, user_agent, now); + self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now); + self.snapshot_locked(&state, user_id, device_id, now) + } + + fn poll( + &self, + user_id: i64, + device_id: &str, + user_agent: Option<&str>, + playback_state: Option, + ) -> PlayerDevicePollResponse { + let now = current_millis(); + let mut state = self.state.lock().expect("player device hub lock"); + self.prune_locked(&mut state, now); + self.touch_locked(&mut state, user_id, device_id, user_agent, now); + self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now); + let commands = state + .commands_by_device + .remove(&(user_id, device_id.to_string())) + .unwrap_or_default() + .into_iter() + .map(|cmd| PlayerDeviceCommandDto { + id: cmd.id, + command: cmd.command, + payload: cmd.payload, + }) + .collect(); + let snapshot = self.snapshot_locked(&state, user_id, device_id, now); + PlayerDevicePollResponse { + device_id: snapshot.device_id, + active_device_id: snapshot.active_device_id, + devices: snapshot.devices, + commands, + playback_state: snapshot.playback_state, + } + } + + fn select( + &self, + user_id: i64, + current_device_id: &str, + target_device_id: &str, + ) -> Option { + let now = current_millis(); + let mut state = self.state.lock().expect("player device hub lock"); + self.prune_locked(&mut state, now); + let devices = state.devices_by_user.get(&user_id)?; + if !devices.contains_key(target_device_id) { + return None; + } + state + .active_device_by_user + .insert(user_id, target_device_id.to_string()); + Some(self.snapshot_locked(&state, user_id, current_device_id, now)) + } + + fn enqueue_command( + &self, + user_id: i64, + target_device_id: Option<&str>, + command: &str, + payload: serde_json::Value, + ) -> Result<(), &'static str> { + let now = current_millis(); + let mut state = self.state.lock().expect("player device hub lock"); + self.prune_locked(&mut state, now); + + let target_id = match target_device_id { + Some(id) => id.to_string(), + None => state + .active_device_by_user + .get(&user_id) + .cloned() + .ok_or("no active device")?, + }; + + let devices = state + .devices_by_user + .get(&user_id) + .ok_or("target device is offline")?; + if !devices.contains_key(&target_id) { + return Err("target device is offline"); + } + + let queue = state + .commands_by_device + .entry((user_id, target_id)) + .or_default(); + while queue.len() >= PLAYER_DEVICE_MAX_COMMANDS { + queue.pop_front(); + } + queue.push_back(PendingPlayerDeviceCommand { + id: uuid::Uuid::new_v4().simple().to_string(), + command: command.to_string(), + payload, + created_at_ms: now, + }); + Ok(()) + } + + fn touch_locked( + &self, + state: &mut PlayerDeviceHubState, + user_id: i64, + device_id: &str, + user_agent: Option<&str>, + now: i64, + ) { + let devices = state.devices_by_user.entry(user_id).or_default(); + let device = PlayerDevice { + id: device_id.to_string(), + name: device_name_from_user_agent(user_agent), + kind: device_kind_from_user_agent(user_agent).to_string(), + last_seen_ms: now, + }; + devices.insert(device_id.to_string(), device); + + let active_online = state + .active_device_by_user + .get(&user_id) + .is_some_and(|active_id| devices.contains_key(active_id)); + if !active_online { + state + .active_device_by_user + .insert(user_id, device_id.to_string()); + } + } + + fn update_playback_state_locked( + &self, + state: &mut PlayerDeviceHubState, + user_id: i64, + device_id: &str, + playback_state: Option, + now: i64, + ) { + let is_active = state + .active_device_by_user + .get(&user_id) + .is_some_and(|active_id| active_id == device_id); + if !is_active { + return; + } + let Some(mut playback_state) = playback_state else { + return; + }; + playback_state.updated_at_ms = now; + state.playback_state_by_user.insert(user_id, playback_state); + } + + fn snapshot_locked( + &self, + state: &PlayerDeviceHubState, + user_id: i64, + current_device_id: &str, + now: i64, + ) -> PlayerDevicesResponse { + let active_device_id = state.active_device_by_user.get(&user_id).cloned(); + let mut devices: Vec = state + .devices_by_user + .get(&user_id) + .map(|devices| { + devices + .values() + .map(|device| PlayerDeviceDto { + id: device.id.clone(), + name: device.name.clone(), + kind: device.kind.clone(), + is_current: device.id == current_device_id, + is_active: active_device_id.as_deref() == Some(device.id.as_str()), + last_seen_ms: now.saturating_sub(device.last_seen_ms), + }) + .collect() + }) + .unwrap_or_default(); + devices.sort_by(|a, b| { + b.is_active + .cmp(&a.is_active) + .then_with(|| b.is_current.cmp(&a.is_current)) + .then_with(|| a.name.cmp(&b.name)) + }); + PlayerDevicesResponse { + device_id: current_device_id.to_string(), + active_device_id, + devices, + playback_state: state.playback_state_by_user.get(&user_id).cloned(), + } + } + + fn prune_locked(&self, state: &mut PlayerDeviceHubState, now: i64) { + state.devices_by_user.retain(|user_id, devices| { + devices.retain(|_, device| { + now.saturating_sub(device.last_seen_ms) <= PLAYER_DEVICE_TTL_MS + }); + let active_valid = state + .active_device_by_user + .get(user_id) + .is_some_and(|active_id| devices.contains_key(active_id)); + if !active_valid { + if let Some(first_device_id) = devices.keys().next().cloned() { + state + .active_device_by_user + .insert(*user_id, first_device_id); + } else { + state.active_device_by_user.remove(user_id); + state.playback_state_by_user.remove(user_id); + } + } + !devices.is_empty() + }); + state + .playback_state_by_user + .retain(|user_id, _| state.devices_by_user.contains_key(user_id)); + + state + .commands_by_device + .retain(|(user_id, device_id), queue| { + let device_online = state + .devices_by_user + .get(user_id) + .is_some_and(|devices| devices.contains_key(device_id)); + if !device_online { + return false; + } + queue.retain(|cmd| { + now.saturating_sub(cmd.created_at_ms) <= PLAYER_DEVICE_COMMAND_TTL_MS + }); + !queue.is_empty() + }); + } +} + +fn current_millis() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +fn normalize_device_id(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.len() > 128 { + return None; + } + if !trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return None; + } + Some(trimmed.to_string()) +} + +fn device_name_from_user_agent(user_agent: Option<&str>) -> String { + let ua = user_agent.unwrap_or_default().to_ascii_lowercase(); + let browser = if ua.contains("edg/") || ua.contains("edgios/") || ua.contains("edga/") { + "Edge" + } else if ua.contains("firefox/") || ua.contains("fxios/") { + "Firefox" + } else if ua.contains("opr/") || ua.contains("opera") { + "Opera" + } else if ua.contains("chrome/") || ua.contains("crios/") { + "Chrome" + } else if ua.contains("safari/") { + "Safari" + } else { + "Browser" + }; + + let os = if ua.contains("iphone") { + "iPhone" + } else if ua.contains("ipad") { + "iPad" + } else if ua.contains("android") { + "Android" + } else if ua.contains("windows") { + "Windows" + } else if ua.contains("mac os") || ua.contains("macintosh") { + "macOS" + } else if ua.contains("linux") { + "Linux" + } else { + "Device" + }; + + format!("{browser} on {os}") +} + +fn device_kind_from_user_agent(user_agent: Option<&str>) -> &'static str { + let ua = user_agent.unwrap_or_default().to_ascii_lowercase(); + if ua.contains("iphone") || (ua.contains("android") && ua.contains("mobile")) { + "phone" + } else if ua.contains("ipad") || ua.contains("tablet") || ua.contains("android") { + "tablet" + } else { + "computer" + } +} + #[derive(Debug, sqlx::FromRow)] struct LastfmAccountApiRow { session_key: String, @@ -1779,6 +2121,113 @@ async fn cover_response( Ok(response) } +// --------------------------------------------------------------------------- +// Player devices +// --------------------------------------------------------------------------- + +async fn devices_heartbeat_handler( + session: Session, + db: Database, + hub: Arc, + Json(dto): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let Some(device_id) = normalize_device_id(&dto.device_id) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id")); + }; + + let response = hub.heartbeat( + user.id, + &device_id, + dto.user_agent.as_deref(), + dto.playback_state, + ); + Json(response).into_response() +} + +async fn devices_poll_handler( + session: Session, + db: Database, + hub: Arc, + Json(dto): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let Some(device_id) = normalize_device_id(&dto.device_id) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id")); + }; + + let response = hub.poll( + user.id, + &device_id, + dto.user_agent.as_deref(), + dto.playback_state, + ); + Json(response).into_response() +} + +async fn devices_select_handler( + session: Session, + db: Database, + hub: Arc, + Json(dto): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let Some(target_device_id) = normalize_device_id(&dto.device_id) else { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id")); + }; + let current_device_id = dto + .current_device_id + .as_deref() + .and_then(normalize_device_id) + .unwrap_or_else(|| target_device_id.clone()); + + let Some(response) = hub.select(user.id, ¤t_device_id, &target_device_id) else { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "target device is offline", + )); + }; + Json(response).into_response() +} + +async fn devices_command_handler( + session: Session, + db: Database, + hub: Arc, + Json(dto): Json, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + let command = dto.command.trim(); + if command.is_empty() || command.len() > 64 { + return Ok(json_error(StatusCode::BAD_REQUEST, "invalid command")); + } + let target_device_id = match dto.target_device_id.as_deref() { + Some(raw) => { + let Some(device_id) = normalize_device_id(raw) else { + return Ok(json_error( + StatusCode::BAD_REQUEST, + "invalid target device id", + )); + }; + Some(device_id) + } + None => None, + }; + + match hub.enqueue_command(user.id, target_device_id.as_deref(), command, dto.payload) { + Ok(()) => Json(serde_json::json!({"ok": true})).into_response(), + Err(message) => Ok(json_error(StatusCode::BAD_REQUEST, message)), + } +} + // --------------------------------------------------------------------------- // GET /api/player/state // --------------------------------------------------------------------------- @@ -2961,6 +3410,7 @@ async fn tracks_by_ids_handler( pub struct PlayerApp { config: Arc, scheduler_handle: Arc>>, + device_hub: Arc, } impl PlayerApp { @@ -2971,6 +3421,7 @@ impl PlayerApp { Self { config, scheduler_handle, + device_hub: Arc::new(PlayerDeviceHub::default()), } } } @@ -2985,6 +3436,7 @@ impl App for PlayerApp { let pool: Arc> = Arc::new(tokio::sync::OnceCell::new()); let torrent_service: Arc>> = Arc::new(tokio::sync::OnceCell::new()); + let device_hub = Arc::clone(&self.device_hub); Router::with_urls([ // -- Current user profile -- @@ -3989,6 +4441,51 @@ impl App for PlayerApp { }, "player_cover", ), + // -- Active browser devices -- + Route::with_handler_and_name( + "/devices/heartbeat", + post({ + let device_hub = Arc::clone(&device_hub); + move |session: Session, db: Database, json: Json| { + let device_hub = Arc::clone(&device_hub); + async move { devices_heartbeat_handler(session, db, device_hub, json).await } + } + }), + "player_devices_heartbeat", + ), + Route::with_handler_and_name( + "/devices/poll", + post({ + let device_hub = Arc::clone(&device_hub); + move |session: Session, db: Database, json: Json| { + let device_hub = Arc::clone(&device_hub); + async move { devices_poll_handler(session, db, device_hub, json).await } + } + }), + "player_devices_poll", + ), + Route::with_handler_and_name( + "/devices/active", + post({ + let device_hub = Arc::clone(&device_hub); + move |session: Session, db: Database, json: Json| { + let device_hub = Arc::clone(&device_hub); + async move { devices_select_handler(session, db, device_hub, json).await } + } + }), + "player_devices_active", + ), + Route::with_handler_and_name( + "/devices/command", + post({ + let device_hub = Arc::clone(&device_hub); + move |session: Session, db: Database, json: Json| { + let device_hub = Arc::clone(&device_hub); + async move { devices_command_handler(session, db, device_hub, json).await } + } + }), + "player_devices_command", + ), // -- Playback state GET -- Route::with_handler_and_name( "/state", diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 3c43afc..1466990 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -406,6 +406,10 @@ document.addEventListener('alpine:init', () => { _playbackStartedAt: null, _listenedSeconds: 0, _lastAudioTime: 0, + _remoteExecuting: false, + _remoteStateBaseTime: 0, + _remoteStateReceivedAt: 0, + _remoteStateTimer: null, init() { audio.volume = this.volume; @@ -440,29 +444,100 @@ document.addEventListener('alpine:init', () => { // Periodic state save this._saveTimer = setInterval(() => { - this._saveState(); + if (this._isLocalPlaybackDevice()) this._saveState(); }, 10000); + this._remoteStateTimer = setInterval(() => { + this._tickRemoteProgress(); + }, 250); // Restore state this._restoreState(); // Save state on page unload window.addEventListener('beforeunload', () => { - this._saveStateSync(); + if (this._isLocalPlaybackDevice()) this._saveStateSync(); }); }, play(track) { + if (!track) return; + if (this._shouldSendRemote()) { + this._mirrorRemoteTrack(track, true, 0); + this._sendRemote('play_track', this._remotePlaybackPayload(track, { + position_seconds: 0, + paused: false, + })); + return; + } + this._playLocal(track); + }, + + playQueueIndex(idx) { + const queue = Alpine.store('queue'); + if (!queue || idx < 0 || idx >= queue.tracks.length) return; + queue.currentIndex = idx; + const track = queue.tracks[idx]; + if (this._shouldSendRemote()) { + this._mirrorRemoteTrack(track, true, 0); + this._sendRemote('play_from_index', this._remotePlaybackPayload(track, { + index: idx, + position_seconds: 0, + paused: false, + })); + return; + } + this._playLocal(track); + }, + + _playLocal(track, options = {}) { this.currentTrack = track; this._historyRecorded = false; this._resetPlaybackTracking(); audio.src = track.stream_url; - audio.play().catch(() => {}); + const seekSeconds = Number(options.position_seconds || 0); + if (seekSeconds > 0) { + const onLoaded = () => { + audio.currentTime = seekSeconds; + this._lastAudioTime = audio.currentTime || 0; + audio.removeEventListener('loadedmetadata', onLoaded); + }; + audio.addEventListener('loadedmetadata', onLoaded); + } + if (options.paused) { + audio.pause(); + this.isPlaying = false; + } else { + audio.play().catch(() => {}); + } this._updateMediaSession(); }, - pause() { audio.pause(); }, - resume() { audio.play().catch(() => {}); }, + _pauseLocal() { + audio.pause(); + this.isPlaying = false; + }, + + pause() { + if (this._shouldSendRemote()) { + this.isPlaying = false; + this._remoteStateBaseTime = this.currentTime; + this._remoteStateReceivedAt = Date.now(); + this._sendRemote('pause'); + return; + } + this._pauseLocal(); + }, + + resume() { + if (this._shouldSendRemote()) { + this.isPlaying = true; + this._remoteStateBaseTime = this.currentTime; + this._remoteStateReceivedAt = Date.now(); + this._sendRemote('resume'); + return; + } + audio.play().catch(() => {}); + }, toggle() { if (!this.currentTrack) return; @@ -471,14 +546,25 @@ document.addEventListener('alpine:init', () => { }, seek(time) { - audio.currentTime = time; + const nextTime = Math.max(0, Number(time || 0)); + if (this._shouldSendRemote()) { + this.currentTime = nextTime; + this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0; + this._remoteStateBaseTime = nextTime; + this._remoteStateReceivedAt = Date.now(); + this._sendRemote('seek', { time: nextTime }); + return; + } + audio.currentTime = nextTime; this._lastAudioTime = audio.currentTime || 0; }, seekRelative(delta) { if (!this.currentTrack) return; - audio.currentTime = Math.max(0, Math.min(audio.duration || 0, audio.currentTime + delta)); - this._lastAudioTime = audio.currentTime || 0; + const duration = this.duration || audio.duration || 0; + const current = this._shouldSendRemote() ? this.currentTime : audio.currentTime; + const max = duration > 0 ? duration : Number.MAX_SAFE_INTEGER; + this.seek(Math.max(0, Math.min(max, current + delta))); }, seekFromClick(event) { @@ -491,6 +577,13 @@ document.addEventListener('alpine:init', () => { }, next() { + if (this._shouldSendRemote()) { + this._sendRemote('next', { + shuffle: this.shuffle, + repeat_mode: this.repeatMode, + }); + return; + } const queue = Alpine.store('queue'); if (queue.tracks.length === 0) return; @@ -516,10 +609,14 @@ document.addEventListener('alpine:init', () => { } } } - queue.playFromIndex(nextIdx); + this.playQueueIndex(nextIdx); }, prev() { + if (this._shouldSendRemote()) { + this._sendRemote('prev'); + return; + } if (this.currentTime > 3) { this.seek(0); return; @@ -536,11 +633,18 @@ document.addEventListener('alpine:init', () => { return; } } - queue.playFromIndex(prevIdx); + this.playQueueIndex(prevIdx); }, setVolume(v) { - this.volume = Math.max(0, Math.min(1, v)); + this._setVolumeLocal(v); + if (this._shouldSendRemote()) { + this._sendRemote('set_volume', { volume: this.volume }); + } + }, + + _setVolumeLocal(v) { + this.volume = Math.max(0, Math.min(1, Number(v || 0))); audio.volume = this.volume; }, @@ -583,12 +687,162 @@ document.addEventListener('alpine:init', () => { toggleShuffle() { this.shuffle = !this.shuffle; + if (this._shouldSendRemote()) { + this._sendRemote('set_options', { + shuffle: this.shuffle, + repeat_mode: this.repeatMode, + }); + } }, cycleRepeat() { if (this.repeatMode === 'off') this.repeatMode = 'all'; else if (this.repeatMode === 'all') this.repeatMode = 'one'; else this.repeatMode = 'off'; + if (this._shouldSendRemote()) { + this._sendRemote('set_options', { + shuffle: this.shuffle, + repeat_mode: this.repeatMode, + }); + } + }, + + _isLocalPlaybackDevice() { + const devices = Alpine.store('devices'); + return !devices || devices.isActive(); + }, + + _shouldSendRemote() { + const devices = Alpine.store('devices'); + return !!devices && !this._remoteExecuting && !devices.isActive(); + }, + + _sendRemote(command, payload = {}) { + const devices = Alpine.store('devices'); + if (!devices) return false; + devices.sendCommand(command, payload); + return true; + }, + + _remotePlaybackPayload(track, overrides = {}) { + const queue = Alpine.store('queue'); + const tracks = queue?.tracks?.length ? queue.tracks : (track ? [track] : []); + let index = Number.isInteger(overrides.index) ? overrides.index : (queue?.currentIndex ?? 0); + if (track && tracks[index]?.id !== track.id) { + const foundIndex = tracks.findIndex(item => item.id === track.id); + index = foundIndex >= 0 ? foundIndex : 0; + } + return { + track, + tracks, + index, + position_seconds: overrides.position_seconds ?? this.currentTime, + duration_seconds: overrides.duration_seconds ?? this._trackDuration(), + paused: overrides.paused ?? !this.isPlaying, + shuffle: this.shuffle, + repeat_mode: this.repeatMode, + volume: this.volume, + }; + }, + + _devicePlaybackStatePayload() { + const queue = Alpine.store('queue'); + const track = this.currentTrack || queue?.tracks?.[queue.currentIndex] || null; + if (!track && (!queue || queue.tracks.length === 0)) return null; + const payload = this._remotePlaybackPayload(track, { + position_seconds: audio.currentTime || this.currentTime || 0, + duration_seconds: this._trackDuration(), + paused: !this.isPlaying, + }); + payload.tracks = []; + return payload; + }, + + _mirrorRemoteTrack(track, playing, positionSeconds = null) { + if (!track) return; + this.currentTrack = track; + this.isPlaying = !!playing; + if (positionSeconds !== null) this.currentTime = Math.max(0, Number(positionSeconds || 0)); + this.duration = Number(track.duration_seconds || this.duration || 0); + this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0; + this._remoteStateBaseTime = this.currentTime; + this._remoteStateReceivedAt = Date.now(); + this._updateMediaSession(); + }, + + _applyRemotePlaybackState(state) { + if (!state) return; + const queue = Alpine.store('queue'); + const tracks = Array.isArray(state.tracks) ? state.tracks.filter(Boolean) : []; + if (queue && tracks.length > 0) { + queue.tracks = tracks; + queue.currentIndex = Math.max(0, Math.min(Number(state.index || 0), tracks.length - 1)); + } + const track = state.track || queue?.tracks?.[queue.currentIndex] || null; + if (track) { + this.currentTrack = track; + } + this.shuffle = !!state.shuffle; + this.repeatMode = state.repeat_mode || 'off'; + if (typeof state.volume === 'number') this._setVolumeLocal(state.volume); + this.duration = Number(state.duration_seconds || track?.duration_seconds || this.duration || 0); + this.isPlaying = !state.paused; + this._remoteStateBaseTime = Math.max(0, Number(state.position_seconds || 0)); + this._remoteStateReceivedAt = Date.now(); + this._tickRemoteProgress(true); + this._updateMediaSession(); + }, + + _tickRemoteProgress(force = false) { + if (this._isLocalPlaybackDevice() || !this.currentTrack) return; + if (!force && !this.isPlaying) return; + let nextTime = Number(this._remoteStateBaseTime || 0); + if (this.isPlaying && this._remoteStateReceivedAt > 0) { + nextTime += (Date.now() - this._remoteStateReceivedAt) / 1000; + } + const duration = Number(this.duration || this.currentTrack?.duration_seconds || 0); + if (duration > 0) nextTime = Math.min(nextTime, duration); + this.currentTime = Math.max(0, nextTime); + this.progress = duration > 0 ? (this.currentTime / duration) * 100 : 0; + }, + + _executeRemoteCommand(command) { + if (!command || !command.command) return; + const payload = command.payload || {}; + const queue = Alpine.store('queue'); + this._remoteExecuting = true; + try { + if (typeof payload.shuffle === 'boolean') this.shuffle = payload.shuffle; + if (payload.repeat_mode) this.repeatMode = payload.repeat_mode; + if (typeof payload.volume === 'number') this._setVolumeLocal(payload.volume); + + if (command.command === 'play_track' || command.command === 'play_from_index') { + if (Array.isArray(payload.tracks) && payload.tracks.length > 0) { + queue.tracks = payload.tracks; + queue.currentIndex = Math.max(0, Math.min(Number(payload.index || 0), queue.tracks.length - 1)); + } + const track = payload.track || queue.tracks[queue.currentIndex]; + if (track) this._playLocal(track, payload); + } else if (command.command === 'pause') { + this.pause(); + } else if (command.command === 'resume') { + this.resume(); + } else if (command.command === 'seek') { + this.seek(Number(payload.time || 0)); + } else if (command.command === 'next') { + this.next(); + } else if (command.command === 'prev') { + this.prev(); + } else if (command.command === 'set_volume') { + this.setVolume(payload.volume); + } else if (command.command === 'set_options') { + // Options were already applied above. + } + this._saveState(); + Alpine.store('devices')?.heartbeat(); + } finally { + this._remoteExecuting = false; + } }, _updateMediaSession() { @@ -645,7 +899,7 @@ document.addEventListener('alpine:init', () => { const state = await res.json(); this.shuffle = state.shuffle || false; this.repeatMode = state.repeat_mode || 'off'; - this.setVolume(typeof state.volume === 'number' ? state.volume : 0.7); + this._setVolumeLocal(typeof state.volume === 'number' ? state.volume : 0.7); // Restore queue if there are track IDs if (state.queue && state.queue.length > 0) { @@ -783,6 +1037,158 @@ document.addEventListener('alpine:init', () => { }, }); + // ----------------------------------------------------------------------- + // Playback devices store + // ----------------------------------------------------------------------- + Alpine.store('devices', { + id: null, + devices: [], + activeDeviceId: null, + open: false, + _pollTimer: null, + _stateRefreshTick: 0, + + init() { + this.id = this._ensureId(); + this.heartbeat(); + this._pollTimer = setInterval(() => this.poll(), 750); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) this.poll(); + }); + }, + + _ensureId() { + let id = sessionStorage.getItem('furu_player_device_id'); + if (!id) { + id = (crypto.randomUUID ? crypto.randomUUID() : this._fallbackId()).replace(/[^a-zA-Z0-9_-]/g, ''); + sessionStorage.setItem('furu_player_device_id', id); + } + return id; + }, + + _fallbackId() { + return 'device-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); + }, + + requestPayload() { + const player = Alpine.store('player'); + return { + device_id: this.id, + user_agent: navigator.userAgent || '', + playback_state: player && this.isActive() ? player._devicePlaybackStatePayload() : null, + }; + }, + + async heartbeat() { + try { + const res = await fetch('/api/player/devices/heartbeat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.requestPayload()), + }); + if (!res.ok) return; + this._apply(await res.json()); + } catch {} + }, + + async poll() { + try { + const res = await fetch('/api/player/devices/poll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.requestPayload()), + }); + if (!res.ok) return; + const data = await res.json(); + this._apply(data); + + const player = Alpine.store('player'); + if (player && Array.isArray(data.commands)) { + data.commands.forEach(command => player._executeRemoteCommand(command)); + } + if (player && !this.isActive()) { + if (data.playback_state) { + player._applyRemotePlaybackState(data.playback_state); + } else if (++this._stateRefreshTick % 8 === 0) { + player._restoreState(); + } + } + } catch {} + }, + + _apply(data) { + const wasActive = this.isActive(); + this.activeDeviceId = data.active_device_id || null; + this.devices = Array.isArray(data.devices) ? data.devices : []; + if (wasActive && !this.isActive()) { + Alpine.store('player')?._pauseLocal(); + } + }, + + isActive() { + return !this.activeDeviceId || this.activeDeviceId === this.id; + }, + + activeLabel() { + const active = this.devices.find(device => device.id === this.activeDeviceId); + return active ? active.name : 'Devices'; + }, + + toggle() { + this.open = !this.open; + if (this.open) this.poll(); + }, + + async select(deviceId) { + if (!deviceId) return; + const player = Alpine.store('player'); + const transferPayload = player?.currentTrack + ? player._remotePlaybackPayload(player.currentTrack, { + position_seconds: player.currentTime, + paused: !player.isPlaying, + }) + : null; + + try { + const res = await fetch('/api/player/devices/active', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_id: deviceId, + current_device_id: this.id, + }), + }); + if (!res.ok) return; + this._apply(await res.json()); + this.open = false; + + if (deviceId !== this.id && transferPayload) { + const sent = await this.sendCommand('play_from_index', transferPayload, deviceId); + if (sent && player?.isPlaying) player._pauseLocal(); + } + } catch {} + }, + + async sendCommand(command, payload = {}, targetDeviceId = null) { + const target = targetDeviceId || this.activeDeviceId; + if (!target || target === this.id) return false; + try { + const res = await fetch('/api/player/devices/command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + target_device_id: target, + command, + payload, + }), + }); + return res.ok; + } catch { + return false; + } + }, + }); + // ----------------------------------------------------------------------- // Queue store // ----------------------------------------------------------------------- @@ -812,8 +1218,7 @@ document.addEventListener('alpine:init', () => { playFromIndex(idx) { if (idx < 0 || idx >= this.tracks.length) return; - this.currentIndex = idx; - Alpine.store('player').play(this.tracks[idx]); + Alpine.store('player').playQueueIndex(idx); }, remove(idx) { diff --git a/templates/player/shell.html b/templates/player/shell.html index 39738b8..e819cd2 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -1046,5 +1046,50 @@ +
+ +
+ +
+
diff --git a/templates/player/styles.html b/templates/player/styles.html index 6ecc9d1..4e95af5 100644 --- a/templates/player/styles.html +++ b/templates/player/styles.html @@ -1342,6 +1342,104 @@ button.user-stat:hover { .queue-toggle-btn.active { color: var(--accent); } .queue-toggle-btn svg { width: 18px; height: 18px; } +.device-picker { + position: relative; + display: flex; + align-items: center; +} + +.device-toggle-btn { + display: flex; + align-items: center; + justify-content: center; +} + +.device-popover { + position: absolute; + right: 0; + bottom: 38px; + width: 260px; + max-width: calc(100vw - 24px); + max-height: min(320px, calc(100dvh - var(--player-bar-space) - 24px)); + overflow-y: auto; + padding: 6px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-elevated); + box-shadow: 0 16px 46px rgba(0,0,0,0.48); + z-index: 45; +} + +.device-row { + width: 100%; + min-height: 44px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + display: grid; + grid-template-columns: 28px minmax(0, 1fr) 22px; + align-items: center; + gap: 9px; + padding: 7px 8px; + cursor: pointer; + text-align: left; +} + +.device-row:hover, +.device-row.active { + background: var(--bg-hover); + color: var(--text-primary); +} + +.device-row.active { + color: var(--accent); +} + +.device-row-icon, +.device-row-check { + display: flex; + align-items: center; + justify-content: center; +} + +.device-row-icon svg { + width: 18px; + height: 18px; +} + +.device-row-check svg { + width: 16px; + height: 16px; +} + +.device-row-main { + min-width: 0; +} + +.device-row-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-row-name { + color: inherit; + font-size: 13px; + font-weight: 650; +} + +.device-row-current { + display: block; + width: 18px; + height: 3px; + margin-top: 5px; + border-radius: 999px; + background: currentColor; + opacity: 0.55; +} + /* Loading */ .loading-spinner { display: flex; @@ -3226,6 +3324,14 @@ button.user-stat:hover { bottom: calc(var(--player-bar-space) + 8px); } + .device-popover { + position: fixed; + right: 8px; + bottom: calc(var(--player-bar-space) + 8px); + width: min(280px, calc(100vw - 16px)); + max-height: 42dvh; + } + .player-bar { gap: 8px; padding-left: 10px;