PLAYER: Fixed connected device logic.
Build and Publish / Build and Publish Docker Image (push) Successful in 2m50s
Build and Publish / Build and Publish Docker Image (push) Successful in 2m50s
This commit is contained in:
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
|
|||||||
@@ -338,6 +338,10 @@ translations! {
|
|||||||
player_lastfm_connected: "Connected as {user}" , "Подключён: {user}";
|
player_lastfm_connected: "Connected as {user}" , "Подключён: {user}";
|
||||||
player_lastfm_reconnect: "Reconnect Last.fm" , "Переподключить Last.fm";
|
player_lastfm_reconnect: "Reconnect Last.fm" , "Переподключить Last.fm";
|
||||||
player_lastfm_not_configured: "Last.fm is not configured" , "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_disconnect_confirm: "Disconnect Last.fm account {user}?" , "Отвязать аккаунт Last.fm {user}?";
|
||||||
player_lastfm_connect_failed: "Could not open Last.fm connection" , "Не удалось открыть подключение Last.fm";
|
player_lastfm_connect_failed: "Could not open Last.fm connection" , "Не удалось открыть подключение Last.fm";
|
||||||
player_lastfm_disconnect_failed: "Could not disconnect Last.fm" , "Не удалось отвязать Last.fm";
|
player_lastfm_disconnect_failed: "Could not disconnect Last.fm" , "Не удалось отвязать Last.fm";
|
||||||
|
|||||||
+54
-2
@@ -146,9 +146,32 @@ impl PlayerDeviceHub {
|
|||||||
if !devices.contains_key(target_device_id) {
|
if !devices.contains_key(target_device_id) {
|
||||||
return None;
|
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
|
state
|
||||||
.active_device_by_user
|
.active_device_by_user
|
||||||
.insert(user_id, target_device_id.to_string());
|
.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))
|
Some(self.snapshot_locked(&state, user_id, current_device_id, now))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,9 +203,22 @@ impl PlayerDeviceHub {
|
|||||||
return Err("target device is offline");
|
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
|
let queue = state
|
||||||
.commands_by_device
|
.commands_by_device
|
||||||
.entry((user_id, target_id))
|
.entry((user_id, target_device_id.to_string()))
|
||||||
.or_default();
|
.or_default();
|
||||||
while queue.len() >= PLAYER_DEVICE_MAX_COMMANDS {
|
while queue.len() >= PLAYER_DEVICE_MAX_COMMANDS {
|
||||||
queue.pop_front();
|
queue.pop_front();
|
||||||
@@ -193,7 +229,6 @@ impl PlayerDeviceHub {
|
|||||||
payload,
|
payload,
|
||||||
created_at_ms: now,
|
created_at_ms: now,
|
||||||
});
|
});
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn touch_locked(
|
fn touch_locked(
|
||||||
@@ -332,6 +367,23 @@ fn current_millis() -> i64 {
|
|||||||
chrono::Utc::now().timestamp_millis()
|
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<String> {
|
fn normalize_device_id(raw: &str) -> Option<String> {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if trimmed.is_empty() || trimmed.len() > 128 {
|
if trimmed.is_empty() || trimmed.len() > 128 {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ const T = {
|
|||||||
lastfmConnected: "{{ t.player_lastfm_connected }}",
|
lastfmConnected: "{{ t.player_lastfm_connected }}",
|
||||||
lastfmReconnect: "{{ t.player_lastfm_reconnect }}",
|
lastfmReconnect: "{{ t.player_lastfm_reconnect }}",
|
||||||
lastfmNotConfigured: "{{ t.player_lastfm_not_configured }}",
|
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 }}",
|
lastfmDisconnectConfirm: "{{ t.player_lastfm_disconnect_confirm }}",
|
||||||
lastfmConnectFailed: "{{ t.player_lastfm_connect_failed }}",
|
lastfmConnectFailed: "{{ t.player_lastfm_connect_failed }}",
|
||||||
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
|
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
|
||||||
@@ -257,6 +261,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return T.lastfmConnect;
|
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() {
|
lastfmClass() {
|
||||||
if (!this.lastfm?.configured) return 'not-configured';
|
if (!this.lastfm?.configured) return 'not-configured';
|
||||||
if (this.lastfm?.connected && this.lastfm?.reauth_required) return 'needs-auth';
|
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 queue = Alpine.store('queue');
|
||||||
const track = this.currentTrack || queue?.tracks?.[queue.currentIndex] || null;
|
const track = this.currentTrack || queue?.tracks?.[queue.currentIndex] || null;
|
||||||
if (!track && (!queue || queue.tracks.length === 0)) return 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,
|
position_seconds: audio.currentTime || this.currentTime || 0,
|
||||||
duration_seconds: this._trackDuration(),
|
duration_seconds: this._trackDuration(),
|
||||||
paused: !this.isPlaying,
|
paused: !this.isPlaying,
|
||||||
});
|
});
|
||||||
payload.tracks = [];
|
|
||||||
return payload;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_mirrorRemoteTrack(track, playing, positionSeconds = null) {
|
_mirrorRemoteTrack(track, playing, positionSeconds = null) {
|
||||||
@@ -816,7 +828,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (payload.repeat_mode) this.repeatMode = payload.repeat_mode;
|
if (payload.repeat_mode) this.repeatMode = payload.repeat_mode;
|
||||||
if (typeof payload.volume === 'number') this._setVolumeLocal(payload.volume);
|
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) {
|
if (Array.isArray(payload.tracks) && payload.tracks.length > 0) {
|
||||||
queue.tracks = payload.tracks;
|
queue.tracks = payload.tracks;
|
||||||
queue.currentIndex = Math.max(0, Math.min(Number(payload.index || 0), queue.tracks.length - 1));
|
queue.currentIndex = Math.max(0, Math.min(Number(payload.index || 0), queue.tracks.length - 1));
|
||||||
@@ -1051,7 +1063,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
init() {
|
init() {
|
||||||
this.id = this._ensureId();
|
this.id = this._ensureId();
|
||||||
this.heartbeat();
|
this.heartbeat();
|
||||||
this._pollTimer = setInterval(() => this.poll(), 750);
|
this._pollTimer = setInterval(() => this.poll(), 500);
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (!document.hidden) this.poll();
|
if (!document.hidden) this.poll();
|
||||||
});
|
});
|
||||||
@@ -1142,12 +1154,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
async select(deviceId) {
|
async select(deviceId) {
|
||||||
if (!deviceId) return;
|
if (!deviceId) return;
|
||||||
const player = Alpine.store('player');
|
const player = Alpine.store('player');
|
||||||
const transferPayload = player?.currentTrack
|
|
||||||
? player._remotePlaybackPayload(player.currentTrack, {
|
|
||||||
position_seconds: player.currentTime,
|
|
||||||
paused: !player.isPlaying,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/player/devices/active', {
|
const res = await fetch('/api/player/devices/active', {
|
||||||
@@ -1159,12 +1165,17 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
this._apply(await res.json());
|
const data = await res.json();
|
||||||
|
this._apply(data);
|
||||||
this.open = false;
|
this.open = false;
|
||||||
|
|
||||||
if (deviceId !== this.id && transferPayload) {
|
if (deviceId === this.id && data.playback_state && player) {
|
||||||
const sent = await this.sendCommand('play_from_index', transferPayload, deviceId);
|
player._executeRemoteCommand({
|
||||||
if (sent && player?.isPlaying) player._pauseLocal();
|
command: 'transfer_state',
|
||||||
|
payload: data.playback_state,
|
||||||
|
});
|
||||||
|
} else if (deviceId !== this.id && player && this.id !== this.activeDeviceId) {
|
||||||
|
player._applyRemotePlaybackState(data.playback_state);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,9 +41,15 @@
|
|||||||
<button class="lastfm-profile-action"
|
<button class="lastfm-profile-action"
|
||||||
:class="$store.user.lastfmClass()"
|
:class="$store.user.lastfmClass()"
|
||||||
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
||||||
@click="$store.user.handleLastfm()">
|
@click="$store.user.handleLastfm()"
|
||||||
|
:title="$store.user.lastfmLabel()"
|
||||||
|
:aria-label="$store.user.lastfmLabel()">
|
||||||
<span class="lastfm-dot"></span>
|
<span class="lastfm-dot"></span>
|
||||||
<span class="lastfm-profile-text" x-text="$store.user.lastfmLabel()"></span>
|
<span class="lastfm-profile-text">
|
||||||
|
<span class="lastfm-profile-brand">{{ t.player_lastfm_profile }}</span>
|
||||||
|
<span class="lastfm-profile-separator">·</span>
|
||||||
|
<span class="lastfm-profile-status" x-text="$store.user.lastfmStatusLabel()"></span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -348,9 +354,15 @@
|
|||||||
<button class="lastfm-profile-action"
|
<button class="lastfm-profile-action"
|
||||||
:class="$store.user.lastfmClass()"
|
:class="$store.user.lastfmClass()"
|
||||||
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
||||||
@click="$store.user.handleLastfm()">
|
@click="$store.user.handleLastfm()"
|
||||||
|
:title="$store.user.lastfmLabel()"
|
||||||
|
:aria-label="$store.user.lastfmLabel()">
|
||||||
<span class="lastfm-dot"></span>
|
<span class="lastfm-dot"></span>
|
||||||
<span class="lastfm-profile-text" x-text="$store.user.lastfmLabel()"></span>
|
<span class="lastfm-profile-text">
|
||||||
|
<span class="lastfm-profile-brand">{{ t.player_lastfm_profile }}</span>
|
||||||
|
<span class="lastfm-profile-separator">·</span>
|
||||||
|
<span class="lastfm-profile-status" x-text="$store.user.lastfmStatusLabel()"></span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="modal-btn modal-btn-primary mobile-account-logout"
|
<button class="modal-btn modal-btn-primary mobile-account-logout"
|
||||||
@click="$store.user.logout()">
|
@click="$store.user.logout()">
|
||||||
@@ -1048,7 +1060,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="device-picker" @click.outside="$store.devices.open = false">
|
<div class="device-picker" @click.outside="$store.devices.open = false">
|
||||||
<button class="queue-toggle-btn device-toggle-btn"
|
<button class="queue-toggle-btn device-toggle-btn"
|
||||||
:class="{ active: !$store.devices.isActive() || $store.devices.open }"
|
:class="{ active: $store.devices.isActive() }"
|
||||||
@click="$store.devices.toggle()"
|
@click="$store.devices.toggle()"
|
||||||
:title="$store.devices.activeLabel()"
|
:title="$store.devices.activeLabel()"
|
||||||
aria-label="Devices">
|
aria-label="Devices">
|
||||||
@@ -1080,7 +1092,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="device-row-main">
|
<span class="device-row-main">
|
||||||
<span class="device-row-name" x-text="device.name"></span>
|
<span class="device-row-name" x-text="device.name"></span>
|
||||||
<span class="device-row-current" x-show="device.is_current"></span>
|
<span class="device-row-current" x-show="device.is_current">This device</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="device-row-check" x-show="device.is_active">
|
<span class="device-row-check" x-show="device.is_active">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
||||||
|
|||||||
@@ -219,12 +219,32 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lastfm-profile-text {
|
.lastfm-profile-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastfm-profile-brand {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastfm-profile-separator {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastfm-profile-status {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
@@ -1432,12 +1452,12 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.device-row-current {
|
.device-row-current {
|
||||||
display: block;
|
display: block;
|
||||||
width: 18px;
|
margin-top: 2px;
|
||||||
height: 3px;
|
color: var(--text-subdued);
|
||||||
margin-top: 5px;
|
font-size: 11px;
|
||||||
border-radius: 999px;
|
overflow: hidden;
|
||||||
background: currentColor;
|
text-overflow: ellipsis;
|
||||||
opacity: 0.55;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading */
|
/* Loading */
|
||||||
|
|||||||
Reference in New Issue
Block a user