From 072c00a48e44fe8e7fc8f2f309ece5820a8025e5 Mon Sep 17 00:00:00 2001 From: AB Date: Thu, 28 May 2026 15:17:59 +0300 Subject: [PATCH] PLAYER: Fixed connected device logic. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/i18n/phrases.rs | 4 +++ src/player/mod.rs | 56 +++++++++++++++++++++++++++++++++-- templates/player/scripts.html | 41 +++++++++++++++---------- templates/player/shell.html | 24 +++++++++++---- templates/player/styles.html | 36 +++++++++++++++++----- 7 files changed, 132 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a1b6f2..5823493 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index cf07ec8..47ae974 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.2.2" +version = "0.2.3" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index a8e25b3..f52a048 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -338,6 +338,10 @@ translations! { player_lastfm_connected: "Connected as {user}" , "Подключён: {user}"; player_lastfm_reconnect: "Reconnect Last.fm" , "Переподключить Last.fm"; player_lastfm_not_configured: "Last.fm is not configured" , "Last.fm не настроен"; + player_lastfm_status_connect: "connect account" , "подключить аккаунт"; + player_lastfm_status_connected: "connected" , "подключён"; + player_lastfm_status_reconnect: "reconnect account" , "переподключить аккаунт"; + player_lastfm_status_not_configured: "not configured" , "не настроен"; player_lastfm_disconnect_confirm: "Disconnect Last.fm account {user}?" , "Отвязать аккаунт Last.fm {user}?"; player_lastfm_connect_failed: "Could not open Last.fm connection" , "Не удалось открыть подключение Last.fm"; player_lastfm_disconnect_failed: "Could not disconnect Last.fm" , "Не удалось отвязать Last.fm"; diff --git a/src/player/mod.rs b/src/player/mod.rs index 5716d3a..07475a5 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -146,9 +146,32 @@ impl PlayerDeviceHub { if !devices.contains_key(target_device_id) { return None; } + let previous_active_id = state.active_device_by_user.get(&user_id).cloned(); + let transfer_state = state + .playback_state_by_user + .get(&user_id) + .cloned() + .map(|playback_state| playback_state_at(playback_state, now)); state .active_device_by_user .insert(user_id, target_device_id.to_string()); + if previous_active_id.as_deref() != Some(target_device_id) { + if let Some(transfer_state) = transfer_state { + state + .playback_state_by_user + .insert(user_id, transfer_state.clone()); + if let Ok(payload) = serde_json::to_value(transfer_state) { + self.enqueue_command_locked( + &mut state, + user_id, + target_device_id, + "transfer_state", + payload, + now, + ); + } + } + } Some(self.snapshot_locked(&state, user_id, current_device_id, now)) } @@ -180,9 +203,22 @@ impl PlayerDeviceHub { return Err("target device is offline"); } + self.enqueue_command_locked(&mut state, user_id, &target_id, command, payload, now); + Ok(()) + } + + fn enqueue_command_locked( + &self, + state: &mut PlayerDeviceHubState, + user_id: i64, + target_device_id: &str, + command: &str, + payload: serde_json::Value, + now: i64, + ) { let queue = state .commands_by_device - .entry((user_id, target_id)) + .entry((user_id, target_device_id.to_string())) .or_default(); while queue.len() >= PLAYER_DEVICE_MAX_COMMANDS { queue.pop_front(); @@ -193,7 +229,6 @@ impl PlayerDeviceHub { payload, created_at_ms: now, }); - Ok(()) } fn touch_locked( @@ -332,6 +367,23 @@ fn current_millis() -> i64 { chrono::Utc::now().timestamp_millis() } +fn playback_state_at( + mut playback_state: PlayerDevicePlaybackStateDto, + now: i64, +) -> PlayerDevicePlaybackStateDto { + if !playback_state.paused && playback_state.updated_at_ms > 0 { + let elapsed_seconds = now.saturating_sub(playback_state.updated_at_ms) as f64 / 1000.0; + playback_state.position_seconds += elapsed_seconds; + if playback_state.duration_seconds > 0.0 { + playback_state.position_seconds = playback_state + .position_seconds + .min(playback_state.duration_seconds); + } + } + playback_state.updated_at_ms = now; + playback_state +} + fn normalize_device_id(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() || trimmed.len() > 128 { diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 1466990..a1959e1 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -35,6 +35,10 @@ const T = { lastfmConnected: "{{ t.player_lastfm_connected }}", lastfmReconnect: "{{ t.player_lastfm_reconnect }}", lastfmNotConfigured: "{{ t.player_lastfm_not_configured }}", + lastfmStatusConnect: "{{ t.player_lastfm_status_connect }}", + lastfmStatusConnected: "{{ t.player_lastfm_status_connected }}", + lastfmStatusReconnect: "{{ t.player_lastfm_status_reconnect }}", + lastfmStatusNotConfigured: "{{ t.player_lastfm_status_not_configured }}", lastfmDisconnectConfirm: "{{ t.player_lastfm_disconnect_confirm }}", lastfmConnectFailed: "{{ t.player_lastfm_connect_failed }}", lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}", @@ -257,6 +261,16 @@ document.addEventListener('alpine:init', () => { return T.lastfmConnect; }, + lastfmStatusLabel() { + if (!this.lastfm?.configured) return T.lastfmStatusNotConfigured; + if (this.lastfm?.connected && this.lastfm?.reauth_required) return T.lastfmStatusReconnect; + if (this.lastfm?.connected) { + const user = this.lastfm.username || T.unknown; + return `${T.lastfmStatusConnected}: ${user}`; + } + return T.lastfmStatusConnect; + }, + lastfmClass() { if (!this.lastfm?.configured) return 'not-configured'; if (this.lastfm?.connected && this.lastfm?.reauth_required) return 'needs-auth'; @@ -749,13 +763,11 @@ document.addEventListener('alpine:init', () => { 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, { + return 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) { @@ -816,7 +828,7 @@ document.addEventListener('alpine:init', () => { 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 (command.command === 'play_track' || command.command === 'play_from_index' || command.command === 'transfer_state') { 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)); @@ -1051,7 +1063,7 @@ document.addEventListener('alpine:init', () => { init() { this.id = this._ensureId(); this.heartbeat(); - this._pollTimer = setInterval(() => this.poll(), 750); + this._pollTimer = setInterval(() => this.poll(), 500); document.addEventListener('visibilitychange', () => { if (!document.hidden) this.poll(); }); @@ -1142,12 +1154,6 @@ document.addEventListener('alpine:init', () => { 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', { @@ -1159,12 +1165,17 @@ document.addEventListener('alpine:init', () => { }), }); if (!res.ok) return; - this._apply(await res.json()); + const data = await res.json(); + this._apply(data); this.open = false; - if (deviceId !== this.id && transferPayload) { - const sent = await this.sendCommand('play_from_index', transferPayload, deviceId); - if (sent && player?.isPlaying) player._pauseLocal(); + if (deviceId === this.id && data.playback_state && player) { + player._executeRemoteCommand({ + command: 'transfer_state', + payload: data.playback_state, + }); + } else if (deviceId !== this.id && player && this.id !== this.activeDeviceId) { + player._applyRemotePlaybackState(data.playback_state); } } catch {} }, diff --git a/templates/player/shell.html b/templates/player/shell.html index e819cd2..aab8f79 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -41,9 +41,15 @@