Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d71845509d | ||
|
|
4efcfdc539 | ||
|
|
d61d7a6bac | ||
|
|
cad8b2280f |
Generated
+7
-5
@@ -1707,8 +1707,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "federation-net"
|
||||
version = "0.1.0"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15a8707baeccb46b5935138f9cb3df3c988c0730b807a2634d901f26b39250d6"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"data-encoding",
|
||||
@@ -1835,7 +1836,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.9.5"
|
||||
version = "0.9.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -3613,8 +3614,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "music-dht"
|
||||
version = "0.2.0"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f32daa9edf769fb5686e92ae6884e9fda6ea082452e4208309fc14301e26aef"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.9.6"
|
||||
version = "0.9.7"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
@@ -39,4 +39,4 @@ uuid = "1"
|
||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||
# P2P federation: publishes the library into a shared DHT and serves audio /
|
||||
# catalogs to furumi peers (TUI clients) over the frid stack.
|
||||
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
|
||||
music-dht = "0.3"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Informational publication of the protocol versions exposed by this peer.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use music_dht::StreamAcceptor;
|
||||
use music_dht::capabilities::{
|
||||
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, JAM_ID, read_message,
|
||||
write_message,
|
||||
};
|
||||
|
||||
use super::serve::AUDIO_PROTOCOL_VERSION;
|
||||
|
||||
fn local_manifest() -> CapabilityManifest {
|
||||
CapabilityManifest::frid("furumusic", env!("CARGO_PKG_VERSION"))
|
||||
// The web server does not expose federation Jam yet.
|
||||
.without_protocol(JAM_ID)
|
||||
.with_protocol("audio", AUDIO_PROTOCOL_VERSION)
|
||||
}
|
||||
|
||||
pub async fn serve(mut acceptor: StreamAcceptor) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = serve_one(stream).await {
|
||||
tracing::debug!("capability stream failed: {error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(mut stream: music_dht::ByteStream) -> Result<()> {
|
||||
let response = match read_message(&mut stream).await? {
|
||||
CapabilityMessage::Get {
|
||||
version: CAPABILITIES_PROTOCOL_VERSION,
|
||||
} => CapabilityMessage::Manifest {
|
||||
manifest: local_manifest(),
|
||||
},
|
||||
CapabilityMessage::Get { version } => CapabilityMessage::Error {
|
||||
message: format!("unsupported capability protocol {version}"),
|
||||
},
|
||||
_ => CapabilityMessage::Error {
|
||||
message: "expected capability request".to_string(),
|
||||
},
|
||||
};
|
||||
write_message(&mut stream, &response).await?;
|
||||
stream.send.finish()?;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manifest_describes_only_supported_player_protocols() {
|
||||
let manifest = local_manifest();
|
||||
assert_eq!(manifest.application, "furumusic");
|
||||
assert_eq!(
|
||||
manifest.protocols.get("audio"),
|
||||
Some(&AUDIO_PROTOCOL_VERSION)
|
||||
);
|
||||
assert!(!manifest.protocols.contains_key(JAM_ID));
|
||||
manifest.validate().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ struct Identity {
|
||||
#[derive(Debug, Clone)]
|
||||
struct StoredDevice {
|
||||
device_id: String,
|
||||
endpoint_id: String,
|
||||
endpoint_ticket: String,
|
||||
}
|
||||
|
||||
@@ -1573,8 +1574,7 @@ async fn sync_device(
|
||||
user_id: i64,
|
||||
device: &StoredDevice,
|
||||
) -> Result<()> {
|
||||
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
||||
let peer = service.connect(ticket).await?;
|
||||
let peer = resolve_device_peer(&service, device).await?;
|
||||
let own_ticket = service.ticket().await?.to_string();
|
||||
let identity = ensure_identity(pool, user_id, "").await?;
|
||||
let profile = own_profile(pool, user_id, "", &own_ticket).await?;
|
||||
@@ -3420,12 +3420,16 @@ async fn local_playback_snapshot(
|
||||
user_id: i64,
|
||||
identity: &Identity,
|
||||
) -> Option<PlaybackSnapshot> {
|
||||
let state = hub.current_playback_state_json(user_id)?;
|
||||
// Keep publishing an inactive snapshot after a handoff. Omitting the
|
||||
// snapshot left the last `active: true` value alive on trusted peers until
|
||||
// its TTL elapsed, allowing the always-on web peer to reclaim playback.
|
||||
let active = hub.federation_playback_is_local(user_id);
|
||||
let state = hub.playback_state_json_for_commands(user_id)?;
|
||||
let wire = playback_state_from_browser_json(pool, state).await.ok()?;
|
||||
Some(PlaybackSnapshot {
|
||||
device_id: identity.device_id.clone(),
|
||||
device_name: identity.name.clone(),
|
||||
active: true,
|
||||
active,
|
||||
updated_at_ms: now_ms(),
|
||||
state: wire,
|
||||
})
|
||||
@@ -4118,7 +4122,7 @@ pub async fn playlist_content_ids_for_removal(
|
||||
async fn active_remote_devices(pool: &sqlx::PgPool, user_id: i64) -> Result<Vec<StoredDevice>> {
|
||||
let identity = ensure_identity(pool, user_id, "").await?;
|
||||
let rows = sqlx::query(
|
||||
"SELECT device_id, endpoint_ticket
|
||||
"SELECT device_id, endpoint_id, endpoint_ticket
|
||||
FROM furumusic__fed_device
|
||||
WHERE user_id = $1
|
||||
AND trusted_at_ms IS NOT NULL
|
||||
@@ -4134,11 +4138,31 @@ async fn active_remote_devices(pool: &sqlx::PgPool, user_id: i64) -> Result<Vec<
|
||||
.into_iter()
|
||||
.map(|row| StoredDevice {
|
||||
device_id: row.get("device_id"),
|
||||
endpoint_id: row.get("endpoint_id"),
|
||||
endpoint_ticket: row.get("endpoint_ticket"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn resolve_device_peer(
|
||||
service: &MusicDhtService,
|
||||
device: &StoredDevice,
|
||||
) -> Result<music_dht::EndpointId> {
|
||||
if let Ok(peer) = device.endpoint_id.parse::<music_dht::EndpointId>()
|
||||
&& (service.connected_peers().contains(&peer)
|
||||
|| service
|
||||
.known_peers()
|
||||
.iter()
|
||||
.any(|contact| contact.peer_id == peer))
|
||||
{
|
||||
// Prefer the live/current-schema DHT contact over a persisted ticket
|
||||
// that may have been issued before a schema upgrade.
|
||||
return Ok(peer);
|
||||
}
|
||||
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
||||
service.connect(ticket).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn active_device_count(pool: &sqlx::PgPool, user_id: i64) -> Result<i64> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM furumusic__fed_device
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly — saving the settings
|
||||
//! starts, stops or re-joins the node without a server restart.
|
||||
|
||||
mod capabilities;
|
||||
pub mod client;
|
||||
pub mod devices;
|
||||
mod receive;
|
||||
@@ -24,6 +25,7 @@ use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::capabilities::CAPABILITIES_ALPN;
|
||||
use music_dht::{
|
||||
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
||||
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||
@@ -380,6 +382,7 @@ impl Federation {
|
||||
.stream_protocol(AUDIO_ALPN)
|
||||
.stream_protocol(CATALOG_ALPN)
|
||||
.stream_protocol(devices::SYNC_ALPN)
|
||||
.schema_independent_stream_protocol(CAPABILITIES_ALPN)
|
||||
.build()
|
||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||
let (service, mut events) =
|
||||
@@ -447,6 +450,10 @@ impl Federation {
|
||||
device_hub,
|
||||
Arc::clone(&self.transport_stats),
|
||||
));
|
||||
let capabilities_acceptor = service
|
||||
.stream_acceptor(CAPABILITIES_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the capabilities acceptor: {err}"))?;
|
||||
let capabilities_task = tokio::spawn(capabilities::serve(capabilities_acceptor));
|
||||
|
||||
*guard = Some(Running {
|
||||
service,
|
||||
@@ -458,6 +465,7 @@ impl Federation {
|
||||
catalog_task,
|
||||
device_task,
|
||||
device_sync_task,
|
||||
capabilities_task,
|
||||
],
|
||||
});
|
||||
self.set_error(None);
|
||||
|
||||
@@ -21,6 +21,8 @@ use super::{TransportStats, record_stream_transport};
|
||||
|
||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||
/// Version of the peer-to-peer audio streaming protocol.
|
||||
pub const AUDIO_PROTOCOL_VERSION: u16 = 1;
|
||||
|
||||
/// Maximum size of a JSON protocol line (request or response header).
|
||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||
|
||||
+176
-12
@@ -54,6 +54,7 @@ struct LocalUploadResponse {
|
||||
}
|
||||
|
||||
const PLAYER_DEVICE_TTL_MS: i64 = 30_000;
|
||||
const PLAYER_DEVICE_RETURN_TAKEOVER_MS: i64 = 30 * 60 * 1_000;
|
||||
const PLAYER_DEVICE_COMMAND_TTL_MS: i64 = 20_000;
|
||||
const PLAYER_DEVICE_MAX_COMMANDS: usize = 32;
|
||||
const PLAYER_JAM_IDLE_TTL_MS: i64 = 4 * 60 * 60 * 1000;
|
||||
@@ -104,6 +105,7 @@ struct PlayerJamSession {
|
||||
#[derive(Debug, Default)]
|
||||
struct PlayerDeviceHubState {
|
||||
devices_by_user: HashMap<i64, HashMap<String, PlayerDevice>>,
|
||||
device_last_seen_ms: HashMap<(i64, String), i64>,
|
||||
active_device_by_user: HashMap<i64, String>,
|
||||
commands_by_device: HashMap<(i64, String), VecDeque<PendingPlayerDeviceCommand>>,
|
||||
playback_state_by_user: HashMap<i64, PlayerDevicePlaybackStateDto>,
|
||||
@@ -153,19 +155,12 @@ impl PlayerDeviceHub {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn current_playback_state_json(&self, user_id: i64) -> Option<serde_json::Value> {
|
||||
pub(crate) fn federation_playback_is_local(&self, user_id: i64) -> bool {
|
||||
let state = self.state.lock().expect("player device hub lock");
|
||||
if state
|
||||
!state
|
||||
.active_device_by_user
|
||||
.get(&user_id)
|
||||
.is_some_and(|id| is_fed_virtual_device_id(id))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
state
|
||||
.playback_state_by_user
|
||||
.get(&user_id)
|
||||
.and_then(|playback| serde_json::to_value(playback).ok())
|
||||
}
|
||||
|
||||
pub(crate) fn playback_state_json_for_commands(
|
||||
@@ -264,14 +259,58 @@ impl PlayerDeviceHub {
|
||||
user_agent: Option<&str>,
|
||||
current_jam_id: Option<&str>,
|
||||
playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||
) -> PlayerDevicesResponse {
|
||||
) -> (PlayerDevicesResponse, Option<String>) {
|
||||
let now = current_millis();
|
||||
let mut state = self.state.lock().expect("player device hub lock");
|
||||
self.prune_locked(&mut state, now);
|
||||
let is_new_or_returning = state
|
||||
.device_last_seen_ms
|
||||
.get(&(user_id, device_id.to_string()))
|
||||
.is_none_or(|last_seen| {
|
||||
now.saturating_sub(*last_seen) >= PLAYER_DEVICE_RETURN_TAKEOVER_MS
|
||||
});
|
||||
let previous_active_id = state.active_device_by_user.get(&user_id).cloned();
|
||||
self.touch_locked(&mut state, user_id, device_id, user_agent, now);
|
||||
let active_is_playing = state
|
||||
.playback_state_by_user
|
||||
.get(&user_id)
|
||||
.is_some_and(|playback| playback.track.is_some() && !playback.paused);
|
||||
let should_claim_idle_playback = is_new_or_returning
|
||||
&& previous_active_id.as_deref() != Some(device_id)
|
||||
&& !active_is_playing;
|
||||
if should_claim_idle_playback {
|
||||
let transfer_state = state
|
||||
.playback_state_by_user
|
||||
.get(&user_id)
|
||||
.cloned()
|
||||
.map(|playback| playback_state_at(playback, now));
|
||||
state
|
||||
.active_device_by_user
|
||||
.insert(user_id, device_id.to_string());
|
||||
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,
|
||||
device_id,
|
||||
"transfer_state",
|
||||
payload,
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now);
|
||||
self.touch_jam_locked(&mut state, user_id, device_id, current_jam_id, now);
|
||||
self.snapshot_locked(&state, user_id, device_id, current_jam_id, now)
|
||||
(
|
||||
self.snapshot_locked(&state, user_id, device_id, current_jam_id, now),
|
||||
should_claim_idle_playback
|
||||
.then_some(previous_active_id)
|
||||
.flatten(),
|
||||
)
|
||||
}
|
||||
|
||||
fn poll(
|
||||
@@ -448,6 +487,9 @@ impl PlayerDeviceHub {
|
||||
last_seen_ms: now,
|
||||
};
|
||||
devices.insert(device_id.to_string(), device);
|
||||
state
|
||||
.device_last_seen_ms
|
||||
.insert((user_id, device_id.to_string()), now);
|
||||
|
||||
let active_online = state
|
||||
.active_device_by_user
|
||||
@@ -868,6 +910,9 @@ impl PlayerDeviceHub {
|
||||
}
|
||||
|
||||
fn prune_locked(&self, state: &mut PlayerDeviceHubState, now: i64) {
|
||||
state.device_last_seen_ms.retain(|_, last_seen| {
|
||||
now.saturating_sub(*last_seen) <= PLAYER_DEVICE_RETURN_TAKEOVER_MS
|
||||
});
|
||||
state
|
||||
.jams_by_id
|
||||
.retain(|_, jam| now.saturating_sub(jam.host_last_seen_ms) <= PLAYER_JAM_IDLE_TTL_MS);
|
||||
@@ -1185,6 +1230,109 @@ mod device_tests {
|
||||
.is_some_and(|devices| devices.contains_key("fed:remote"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_browser_claims_an_idle_federated_player() {
|
||||
let hub = PlayerDeviceHub::default();
|
||||
let user_id = 8;
|
||||
hub.apply_fed_playback_state_json(
|
||||
user_id,
|
||||
"remote",
|
||||
"Remote",
|
||||
true,
|
||||
serde_json::json!({
|
||||
"track": {"id": 2},
|
||||
"tracks": [],
|
||||
"index": 0,
|
||||
"position_seconds": 12.0,
|
||||
"duration_seconds": 100.0,
|
||||
"paused": true,
|
||||
"shuffle": false,
|
||||
"repeat_mode": "off",
|
||||
"volume": 0.7
|
||||
}),
|
||||
)
|
||||
.expect("valid snapshot");
|
||||
|
||||
let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None);
|
||||
|
||||
assert_eq!(response.active_device_id.as_deref(), Some("browser"));
|
||||
assert_eq!(previous.as_deref(), Some("fed:remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_browser_does_not_claim_a_playing_federated_player() {
|
||||
let hub = PlayerDeviceHub::default();
|
||||
let user_id = 9;
|
||||
hub.apply_fed_playback_state_json(
|
||||
user_id,
|
||||
"remote",
|
||||
"Remote",
|
||||
true,
|
||||
serde_json::json!({
|
||||
"track": {"id": 2},
|
||||
"tracks": [],
|
||||
"index": 0,
|
||||
"position_seconds": 12.0,
|
||||
"duration_seconds": 100.0,
|
||||
"paused": false,
|
||||
"shuffle": false,
|
||||
"repeat_mode": "off",
|
||||
"volume": 0.7
|
||||
}),
|
||||
)
|
||||
.expect("valid snapshot");
|
||||
|
||||
let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None);
|
||||
|
||||
assert_eq!(response.active_device_id.as_deref(), Some("fed:remote"));
|
||||
assert_eq!(previous, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshed_control_browser_keeps_its_control_role() {
|
||||
let hub = PlayerDeviceHub::default();
|
||||
let user_id = 10;
|
||||
hub.apply_fed_playback_state_json(
|
||||
user_id,
|
||||
"remote",
|
||||
"Remote",
|
||||
true,
|
||||
serde_json::json!({
|
||||
"track": {"id": 2},
|
||||
"tracks": [],
|
||||
"index": 0,
|
||||
"position_seconds": 12.0,
|
||||
"duration_seconds": 100.0,
|
||||
"paused": true,
|
||||
"shuffle": false,
|
||||
"repeat_mode": "off",
|
||||
"volume": 0.7
|
||||
}),
|
||||
)
|
||||
.expect("valid snapshot");
|
||||
{
|
||||
let mut state = hub.state.lock().expect("device hub");
|
||||
let now = current_millis();
|
||||
state.devices_by_user.entry(user_id).or_default().insert(
|
||||
"browser".to_string(),
|
||||
PlayerDevice {
|
||||
id: "browser".to_string(),
|
||||
name: "Browser".to_string(),
|
||||
kind: "computer".to_string(),
|
||||
last_seen_ms: now,
|
||||
},
|
||||
);
|
||||
state
|
||||
.device_last_seen_ms
|
||||
.insert((user_id, "browser".to_string()), now);
|
||||
}
|
||||
|
||||
let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None);
|
||||
|
||||
assert_eq!(response.active_device_id.as_deref(), Some("fed:remote"));
|
||||
assert_eq!(previous, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
@@ -4846,7 +4994,7 @@ async fn devices_heartbeat_handler(
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id"));
|
||||
};
|
||||
|
||||
let response = hub.heartbeat(
|
||||
let (response, previous_active_id) = hub.heartbeat(
|
||||
user.id,
|
||||
&device_id,
|
||||
dto.user_agent.as_deref(),
|
||||
@@ -4856,6 +5004,22 @@ async fn devices_heartbeat_handler(
|
||||
.as_deref(),
|
||||
dto.playback_state,
|
||||
);
|
||||
if let Some(previous_fed_device_id) = previous_active_id
|
||||
.as_deref()
|
||||
.and_then(fed_device_id_from_virtual)
|
||||
&& let Some(playback_state) = response.playback_state.clone()
|
||||
{
|
||||
let state = match serde_json::to_value(playback_state) {
|
||||
Ok(state) => state,
|
||||
Err(err) => return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
|
||||
};
|
||||
if let Err(err) = crate::federation::handle()
|
||||
.fed_device_web_active_takeover(user.id, previous_fed_device_id, state)
|
||||
.await
|
||||
{
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}")));
|
||||
}
|
||||
}
|
||||
Json(response).into_response()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111827'/%3E%3Cpath d='M27 15v31.5a9 9 0 1 1-5-8.1V22l27-6v24.5a9 9 0 1 1-5-8.1V15.9L27 20.5' fill='%2367e8f9'/%3E%3C/svg%3E">
|
||||
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
|
||||
{% block head_extra %}{% endblock head_extra %}
|
||||
</head>
|
||||
|
||||
@@ -735,6 +735,7 @@ document.addEventListener('alpine:init', () => {
|
||||
_remoteStateReceivedAt: 0,
|
||||
_remoteStateTimer: null,
|
||||
_prefetchedQueueKey: null,
|
||||
_baseDocumentTitle: '',
|
||||
|
||||
_hasInitialShareLink() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -744,6 +745,10 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
init() {
|
||||
audio.volume = this.volume;
|
||||
this._baseDocumentTitle = document.title.replace(/^▶\s*/, '');
|
||||
Alpine.effect(() => {
|
||||
document.title = `${this.isPlaying ? '▶ ' : ''}${this._baseDocumentTitle}`;
|
||||
});
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
this.currentTime = audio.currentTime;
|
||||
|
||||
@@ -757,8 +757,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
|
||||
<section class="artist-release-group">
|
||||
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
|
||||
<section class="artist-release-group" x-data="{ expanded: false }">
|
||||
<div class="artist-release-group-heading">
|
||||
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
|
||||
<button class="artist-top-tracks-toggle"
|
||||
type="button"
|
||||
x-show="$store.library.currentArtist.top_tracks.length > 5"
|
||||
@click="expanded = !expanded"
|
||||
:aria-expanded="expanded"
|
||||
x-cloak>
|
||||
<span x-text="expanded ? '{{ t.player_collapse }}' : '{{ t.player_expand_all }}'"></span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
:class="{ expanded: expanded }">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="track-list-header">
|
||||
<span>#</span>
|
||||
<span>{{ t.player_title }}</span>
|
||||
@@ -766,7 +780,9 @@
|
||||
<span></span>
|
||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.currentArtist.top_tracks" :key="track.id">
|
||||
<template x-for="(track, idx) in (expanded
|
||||
? $store.library.currentArtist.top_tracks
|
||||
: $store.library.currentArtist.top_tracks.slice(0, 5))" :key="track.id">
|
||||
<div class="track-row artist-appearance-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
|
||||
|
||||
@@ -676,6 +676,42 @@ button.user-stat:hover {
|
||||
margin-bottom: 14px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.artist-release-group-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.artist-release-group-heading .artist-release-group-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.artist-top-tracks-toggle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-subdued);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.artist-top-tracks-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.artist-top-tracks-toggle svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.artist-top-tracks-toggle svg.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Release detail header */
|
||||
.release-header {
|
||||
|
||||
Reference in New Issue
Block a user