Compare commits

...

6 Commits

Author SHA1 Message Date
Ultradesu 0c120c0868 Added support of TUI player
Build and Publish / Build and Publish Docker Image (push) Successful in 2m38s
2026-06-10 23:20:18 +01:00
Ultradesu d9d0fbb7d1 Added cli client SSO login support
Build and Publish / Build and Publish Docker Image (push) Successful in 3m11s
2026-06-10 13:34:38 +01:00
Ultradesu 71d6556ba8 Improved artist page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m37s
2026-06-09 17:18:55 +01:00
Ultradesu 0ac59eb0ca Improved release editor
Build and Publish / Build and Publish Docker Image (push) Successful in 2m35s
2026-06-09 15:06:01 +01:00
Ultradesu 652c6a470d Fix queue display
Build and Publish / Build and Publish Docker Image (push) Successful in 2m33s
2026-06-08 17:59:56 +01:00
Ultradesu 1c54782dd7 Fix queue display 2026-06-08 17:59:46 +01:00
11 changed files with 1015 additions and 113 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.4.1" version = "0.4.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.4.2" version = "0.4.7"
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"
+26
View File
@@ -555,6 +555,32 @@ impl App for AdminApp {
}, },
"admin_v2_library_item_detail", "admin_v2_library_item_detail",
), ),
Route::with_handler_and_name(
"/v2/api/library/tracks/search",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session,
db: Database,
query: UrlQuery<v2::TrackSearchQuery>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::track_search(session, db, pg_pool, query.0).await
}
})
},
"admin_v2_library_tracks_search",
),
Route::with_handler_and_name( Route::with_handler_and_name(
"/v2/api/library/item/image", "/v2/api/library/item/image",
{ {
+320 -24
View File
@@ -110,6 +110,7 @@ pub(super) struct UpdateLibraryItemRequest {
#[serde(default, deserialize_with = "deserialize_optional_stringish")] #[serde(default, deserialize_with = "deserialize_optional_stringish")]
disc_number: Option<String>, disc_number: Option<String>,
artist_ids: Option<Vec<i64>>, artist_ids: Option<Vec<i64>>,
release_tracks: Option<Vec<ReleaseTrackUpdateRequest>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -118,6 +119,21 @@ pub(super) struct LibraryItemDetailQuery {
id: i64, id: i64,
} }
#[derive(Debug, Deserialize)]
pub(super) struct TrackSearchQuery {
search: Option<String>,
limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct ReleaseTrackUpdateRequest {
id: i64,
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
track_number: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
disc_number: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(super) struct SetLibraryImageRequest { pub(super) struct SetLibraryImageRequest {
kind: String, kind: String,
@@ -538,6 +554,7 @@ struct LibraryItemDetailDto {
selected_artist_ids: Vec<i64>, selected_artist_ids: Vec<i64>,
artists: Vec<ArtistOptionDto>, artists: Vec<ArtistOptionDto>,
releases: Vec<ReleaseOptionDto>, releases: Vec<ReleaseOptionDto>,
release_tracks: Vec<ReleaseTrackDto>,
available_covers: Vec<AvailableCoverDto>, available_covers: Vec<AvailableCoverDto>,
metadata_tags: Vec<MetadataTagDto>, metadata_tags: Vec<MetadataTagDto>,
} }
@@ -555,6 +572,19 @@ struct ReleaseOptionDto {
subtitle: String, subtitle: String,
} }
#[derive(Debug, Serialize, JsonSchema)]
struct ReleaseTrackDto {
id: i64,
title: String,
artists: String,
release_id: Option<i64>,
release_title: Option<String>,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: f64,
is_hidden: bool,
}
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
struct AvailableCoverDto { struct AvailableCoverDto {
media_file_id: i64, media_file_id: i64,
@@ -651,6 +681,19 @@ struct LibraryItemRow {
updated_at: Option<String>, updated_at: Option<String>,
} }
#[derive(Debug, sqlx::FromRow)]
struct ReleaseTrackRow {
id: i64,
title: String,
artists: String,
release_id: Option<i64>,
release_title: Option<String>,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: f64,
is_hidden: bool,
}
pub async fn page(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> { pub async fn page(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
let template = AdminV2Template { let template = AdminV2Template {
t: i18n.t, t: i18n.t,
@@ -1289,6 +1332,21 @@ pub async fn library_item_detail(
return Ok(response); return Ok(response);
} }
let kind = normalize_library_kind(Some(query.kind.as_str())); let kind = normalize_library_kind(Some(query.kind.as_str()));
if kind == "releases" && query.id == 0 {
let item = LibraryItemDto {
id: 0,
kind: kind.clone(),
title: String::new(),
subtitle: String::new(),
is_hidden: Some(false),
tags: Vec::new(),
updated_at: None,
};
let detail = load_library_item_detail(pool, &kind, item)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
return Json(detail).into_response();
}
let Some(item) = fetch_library_item(pool, &kind, query.id) let Some(item) = fetch_library_item(pool, &kind, query.id)
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))? .map_err(|e| cot::Error::internal(e.to_string()))?
@@ -1301,6 +1359,25 @@ pub async fn library_item_detail(
Json(detail).into_response() Json(detail).into_response()
} }
pub async fn track_search(
session: Session,
db: Database,
pool: &PgPool,
query: TrackSearchQuery,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let Some(search) = clean_search(query.search.as_deref()) else {
return Json(Vec::<ReleaseTrackDto>::new()).into_response();
};
let tracks = search_tracks(pool, &search, query.limit.unwrap_or(16).clamp(1, 40))
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Json(tracks).into_response()
}
pub async fn update_library_item( pub async fn update_library_item(
session: Session, session: Session,
db: Database, db: Database,
@@ -1318,6 +1395,19 @@ pub async fn update_library_item(
} }
let now = now_string(); let now = now_string();
if kind == "releases" && body.id == 0 {
let release_id = create_release_library_item(pool, &body, title, &now)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let Some(item) = fetch_library_item(pool, &kind, release_id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
else {
return Ok(json_error(StatusCode::NOT_FOUND, "library item not found"));
};
return Json(item).into_response();
}
let affected = match kind.as_str() { let affected = match kind.as_str() {
"artists" => { "artists" => {
sqlx::query( sqlx::query(
@@ -1421,22 +1511,9 @@ pub async fn update_library_item(
let mut seen_artist_ids = HashSet::new(); let mut seen_artist_ids = HashSet::new();
artist_ids.retain(|id| *id > 0 && seen_artist_ids.insert(*id)); artist_ids.retain(|id| *id > 0 && seen_artist_ids.insert(*id));
if kind == "releases" { if kind == "releases" {
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1") set_release_artists(pool, body.id, &artist_ids)
.bind(body.id)
.execute(pool)
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .map_err(|e| cot::Error::internal(e.to_string()))?;
for (position, artist_id) in artist_ids.iter().enumerate() {
sqlx::query(
"INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)",
)
.bind(body.id)
.bind(*artist_id)
.bind(position as i32)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
} else { } else {
sqlx::query( sqlx::query(
"DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'", "DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'",
@@ -1460,6 +1537,14 @@ pub async fn update_library_item(
} }
} }
if kind == "releases" {
if let Some(release_tracks) = body.release_tracks.as_deref() {
update_release_tracks(pool, body.id, release_tracks, &now)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
}
let Some(item) = fetch_library_item(pool, &kind, body.id) let Some(item) = fetch_library_item(pool, &kind, body.id)
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))? .map_err(|e| cot::Error::internal(e.to_string()))?
@@ -2647,13 +2732,13 @@ async fn fetch_library_item(
"tracks" => { "tracks" => {
sqlx::query_as::<_, LibraryItemRow>( sqlx::query_as::<_, LibraryItemRow>(
"SELECT t.id, t.title::text AS title, \ "SELECT t.id, t.title::text AS title, \
CONCAT(r.title::text, COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \ CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \ t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \ COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \ COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
t.updated_at::text AS updated_at \ t.updated_at::text AS updated_at \
FROM furumusic__track t \ FROM furumusic__track t \
JOIN furumusic__release r ON r.id = t.release_id \ LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \ LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
LEFT JOIN furumusic__playlist_track pt ON pt.track_id = t.id \ LEFT JOIN furumusic__playlist_track pt ON pt.track_id = t.id \
@@ -2704,6 +2789,7 @@ async fn load_library_item_detail(
selected_artist_ids: Vec::new(), selected_artist_ids: Vec::new(),
artists: Vec::new(), artists: Vec::new(),
releases: Vec::new(), releases: Vec::new(),
release_tracks: Vec::new(),
available_covers: Vec::new(), available_covers: Vec::new(),
metadata_tags: load_metadata_tags(pool, kind, item.id).await?, metadata_tags: load_metadata_tags(pool, kind, item.id).await?,
item, item,
@@ -2744,16 +2830,22 @@ async fn load_library_item_detail(
.map(|row| row.id) .map(|row| row.id)
.collect(); .collect();
detail.artists = load_artist_options(pool).await?; detail.artists = load_artist_options(pool).await?;
if detail.item.id > 0 {
detail.release_tracks = load_release_tracks(pool, detail.item.id).await?;
}
} }
"tracks" => { "tracks" => {
let row: Option<(i64, Option<i32>, Option<i32>, Option<i32>)> = sqlx::query_as( let row: Option<(Option<i64>, Option<i32>, Option<i32>, Option<i32>)> = sqlx::query_as(
"SELECT release_id, track_number, disc_number, year FROM furumusic__track WHERE id = $1", "SELECT r.id AS release_id, t.track_number, t.disc_number, t.year \
FROM furumusic__track t \
LEFT JOIN furumusic__release r ON r.id = t.release_id \
WHERE t.id = $1",
) )
.bind(detail.item.id) .bind(detail.item.id)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
if let Some((release_id, track_number, disc_number, year)) = row { if let Some((release_id, track_number, disc_number, year)) = row {
detail.release_id = Some(release_id); detail.release_id = release_id;
detail.track_number = track_number; detail.track_number = track_number;
detail.disc_number = disc_number; detail.disc_number = disc_number;
detail.year = year; detail.year = year;
@@ -2901,6 +2993,210 @@ async fn load_release_options(pool: &PgPool) -> anyhow::Result<Vec<ReleaseOption
.collect()) .collect())
} }
async fn create_release_library_item(
pool: &PgPool,
body: &UpdateLibraryItemRequest,
title: &str,
now: &str,
) -> anyhow::Result<i64> {
let release_type = body
.release_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("album");
let year = body
.year
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|value| value.parse::<i32>().ok());
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO furumusic__release \
(title, title_sort, release_type, year, cover_file_id, total_tracks, total_discs, is_hidden, model_name, created_at, updated_at) \
VALUES ($1, $2, $3, $4, NULL, NULL, NULL, $5, NULL, $6, $6) \
RETURNING id",
)
.bind(title)
.bind(normalize_name(title))
.bind(release_type)
.bind(year)
.bind(body.hidden)
.bind(now)
.fetch_one(pool)
.await?;
if let Some(artist_ids) = body.artist_ids.as_deref() {
set_release_artists(pool, release_id, artist_ids).await?;
}
if let Some(release_tracks) = body.release_tracks.as_deref() {
update_release_tracks(pool, release_id, release_tracks, now).await?;
}
Ok(release_id)
}
async fn set_release_artists(
pool: &PgPool,
release_id: i64,
artist_ids: &[i64],
) -> anyhow::Result<()> {
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
.bind(release_id)
.execute(pool)
.await?;
let mut seen_artist_ids = HashSet::new();
let unique_artist_ids = artist_ids
.iter()
.copied()
.filter(|id| *id > 0 && seen_artist_ids.insert(*id))
.collect::<Vec<_>>();
for (position, artist_id) in unique_artist_ids.iter().enumerate() {
sqlx::query(
"INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)",
)
.bind(release_id)
.bind(*artist_id)
.bind(position as i32)
.execute(pool)
.await?;
}
Ok(())
}
async fn load_release_tracks(
pool: &PgPool,
release_id: i64,
) -> anyhow::Result<Vec<ReleaseTrackDto>> {
let rows = sqlx::query_as::<_, ReleaseTrackRow>(
"SELECT t.id, t.title::text AS title, \
COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') AS artists, \
NULLIF(t.release_id, 0) AS release_id, r.title::text AS release_title, \
t.track_number, t.disc_number, t.duration_seconds, t.is_hidden \
FROM furumusic__track t \
LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main' \
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
WHERE t.release_id = $1 \
GROUP BY t.id, r.id, r.title \
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title ASC, t.id ASC",
)
.bind(release_id)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(release_track_dto).collect())
}
async fn search_tracks(
pool: &PgPool,
search: &str,
limit: i64,
) -> anyhow::Result<Vec<ReleaseTrackDto>> {
let pattern = format!("%{search}%");
let starts_with = format!("{search}%");
let rows = sqlx::query_as::<_, ReleaseTrackRow>(
"SELECT t.id, t.title::text AS title, \
COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') AS artists, \
NULLIF(t.release_id, 0) AS release_id, r.title::text AS release_title, \
t.track_number, t.disc_number, t.duration_seconds, t.is_hidden \
FROM furumusic__track t \
LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main' \
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
WHERE t.title ILIKE $1 OR COALESCE(r.title::text, '') ILIKE $1 OR COALESCE(a.name::text, '') ILIKE $1 \
GROUP BY t.id, r.id, r.title \
ORDER BY CASE \
WHEN LOWER(t.title::text) = LOWER($2) THEN 0 \
WHEN t.title ILIKE $3 THEN 1 \
ELSE 2 \
END, \
t.title_sort ASC, t.id ASC \
LIMIT $4",
)
.bind(pattern)
.bind(search)
.bind(starts_with)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(release_track_dto).collect())
}
async fn update_release_tracks(
pool: &PgPool,
release_id: i64,
tracks: &[ReleaseTrackUpdateRequest],
now: &str,
) -> anyhow::Result<()> {
let mut seen_ids = HashSet::new();
let selected = tracks
.iter()
.filter(|track| track.id > 0 && seen_ids.insert(track.id))
.collect::<Vec<_>>();
let selected_ids = selected.iter().map(|track| track.id).collect::<Vec<_>>();
let mut tx = pool.begin().await?;
if selected_ids.is_empty() {
sqlx::query(
"UPDATE furumusic__track \
SET release_id = 0, updated_at = $2 \
WHERE release_id = $1",
)
.bind(release_id)
.bind(now)
.execute(&mut *tx)
.await?;
} else {
sqlx::query(
"UPDATE furumusic__track \
SET release_id = 0, updated_at = $2 \
WHERE release_id = $1 AND id <> ALL($3)",
)
.bind(release_id)
.bind(now)
.bind(&selected_ids)
.execute(&mut *tx)
.await?;
}
for track in selected {
let track_number = parse_optional_admin_i32(track.track_number.as_deref(), 1, 9999);
let disc_number = parse_optional_admin_i32(track.disc_number.as_deref(), 1, 999);
sqlx::query(
"UPDATE furumusic__track \
SET release_id = $1, track_number = $2, disc_number = $3, updated_at = $4 \
WHERE id = $5",
)
.bind(release_id)
.bind(track_number)
.bind(disc_number)
.bind(now)
.bind(track.id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
fn release_track_dto(row: ReleaseTrackRow) -> ReleaseTrackDto {
ReleaseTrackDto {
id: row.id,
title: row.title,
artists: row.artists,
release_id: row.release_id,
release_title: row.release_title,
track_number: row.track_number,
disc_number: row.disc_number,
duration_seconds: row.duration_seconds,
is_hidden: row.is_hidden,
}
}
async fn artist_available_covers( async fn artist_available_covers(
pool: &PgPool, pool: &PgPool,
artist_id: i64, artist_id: i64,
@@ -2943,7 +3239,7 @@ async fn library_ids_by_filter(
"tracks" => QueryBuilder::<Postgres>::new( "tracks" => QueryBuilder::<Postgres>::new(
"SELECT DISTINCT t.id \ "SELECT DISTINCT t.id \
FROM furumusic__track t \ FROM furumusic__track t \
JOIN furumusic__release r ON r.id = t.release_id \ LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1", LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
), ),
@@ -3182,7 +3478,7 @@ async fn count_library(
"tracks" => QueryBuilder::<Postgres>::new( "tracks" => QueryBuilder::<Postgres>::new(
"SELECT COUNT(DISTINCT t.id) AS count \ "SELECT COUNT(DISTINCT t.id) AS count \
FROM furumusic__track t \ FROM furumusic__track t \
JOIN furumusic__release r ON r.id = t.release_id \ LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1", LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
), ),
@@ -3319,13 +3615,13 @@ async fn load_track_items(
) -> anyhow::Result<Vec<LibraryItemRow>> { ) -> anyhow::Result<Vec<LibraryItemRow>> {
let mut qb = QueryBuilder::<Postgres>::new( let mut qb = QueryBuilder::<Postgres>::new(
"SELECT t.id, t.title::text AS title, \ "SELECT t.id, t.title::text AS title, \
CONCAT(r.title::text, COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \ CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \ t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \ COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \ COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
t.updated_at::text AS updated_at \ t.updated_at::text AS updated_at \
FROM furumusic__track t \ FROM furumusic__track t \
JOIN furumusic__release r ON r.id = t.release_id \ LEFT JOIN furumusic__release r ON r.id = t.release_id \
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \ LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \ LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \ LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
@@ -3341,7 +3637,7 @@ async fn load_track_items(
qb.push_bind(pattern); qb.push_bind(pattern);
qb.push(")"); qb.push(")");
} }
qb.push(" GROUP BY t.id, r.title ORDER BY r.title ASC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title ASC LIMIT "); qb.push(" GROUP BY t.id, r.title ORDER BY COALESCE(r.title::text, '') ASC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title ASC LIMIT ");
qb.push_bind(limit); qb.push_bind(limit);
qb.push(" OFFSET "); qb.push(" OFFSET ");
qb.push_bind(offset); qb.push_bind(offset);
+1
View File
@@ -373,6 +373,7 @@ translations! {
player_repeat: "Repeat" , "Повтор"; player_repeat: "Repeat" , "Повтор";
player_volume: "Volume" , "Громкость"; player_volume: "Volume" , "Громкость";
player_appears_on: "Appears on" , "Участвует в"; player_appears_on: "Appears on" , "Участвует в";
player_top_tracks: "Popular tracks" , "Популярные треки";
player_albums: "Albums" , "Альбомы"; player_albums: "Albums" , "Альбомы";
player_eps: "EPs" , "EP"; player_eps: "EPs" , "EP";
player_singles: "Singles" , "Синглы"; player_singles: "Singles" , "Синглы";
+74 -5
View File
@@ -977,27 +977,54 @@ fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") { if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
return Some(value.to_owned()); return Some(value.to_owned());
} }
if is_loopback_http_redirect(&lower) {
return Some(value.to_owned());
}
None None
} }
/// RFC 8252 §7.3: native apps without a custom URL scheme (the CLI client)
/// receive the callback on a loopback listener with an ephemeral port.
fn is_loopback_http_redirect(lower: &str) -> bool {
let Some(rest) = lower.strip_prefix("http://") else {
return false;
};
let host_port = rest.split(['/', '?', '#']).next().unwrap_or("");
let Some((host, port)) = host_port.rsplit_once(':') else {
return false;
};
matches!(host, "127.0.0.1" | "localhost" | "[::1]")
&& !port.is_empty()
&& port.len() <= 5
&& port.bytes().all(|b| b.is_ascii_digit())
}
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response { fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
let deep_link = append_query_param(app_redirect_uri, "code", code); let deep_link = append_query_param(app_redirect_uri, "code", code);
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
return auth::redirect(&deep_link);
}
mobile_deep_link_page( mobile_deep_link_page(
"success", "success",
"Sign-in complete", "Sign-in complete",
"Furumi should open automatically. You can close this window after the app opens.", "Furumi should open automatically. If it doesn't, use the button or copy the code below.",
None, None,
Some(code),
&deep_link, &deep_link,
) )
} }
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response { fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
let deep_link = append_query_param(app_redirect_uri, "error", error); let deep_link = append_query_param(app_redirect_uri, "error", error);
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
return auth::redirect(&deep_link);
}
mobile_deep_link_page( mobile_deep_link_page(
"error", "error",
"Sign-in failed", "Sign-in failed",
"Furumi should open automatically and show the sign-in error. You can close this window after the app opens.", "Furumi should open automatically and show the sign-in error.",
Some(error), Some(error),
None,
&deep_link, &deep_link,
) )
} }
@@ -1007,6 +1034,7 @@ fn mobile_deep_link_page(
title: &str, title: &str,
message: &str, message: &str,
detail: Option<&str>, detail: Option<&str>,
code: Option<&str>,
deep_link: &str, deep_link: &str,
) -> cot::response::Response { ) -> cot::response::Response {
let state_class = html_escape(state); let state_class = html_escape(state);
@@ -1015,6 +1043,15 @@ fn mobile_deep_link_page(
let detail_html = detail let detail_html = detail
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value))) .map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
.unwrap_or_default(); .unwrap_or_default();
let code_html = code
.map(|value| {
format!(
r#"<p class="hint">Signing in from a terminal? Paste this code there:</p>
<input class="code" readonly value="{}" onclick="this.select()">"#,
html_escape(value)
)
})
.unwrap_or_default();
let deep_link_html = html_escape(deep_link); let deep_link_html = html_escape(deep_link);
let deep_link_js = let deep_link_js =
serde_json::to_string(deep_link).expect("serializing URL string cannot fail"); serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
@@ -1095,6 +1132,19 @@ fn mobile_deep_link_page(
font-size: 13px; font-size: 13px;
color: #89847c; color: #89847c;
}} }}
.code {{
width: 100%;
margin-top: 8px;
padding: 10px 12px;
box-sizing: border-box;
border: 1px solid #3a3c42;
border-radius: 8px;
background: #1a1c20;
color: #e8d8a8;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
text-align: center;
}}
</style> </style>
</head> </head>
<body> <body>
@@ -1105,15 +1155,13 @@ fn mobile_deep_link_page(
{detail_html} {detail_html}
<a href="{deep_link_html}">Open Furumi</a> <a href="{deep_link_html}">Open Furumi</a>
<p class="hint">If nothing happens, use the button above.</p> <p class="hint">If nothing happens, use the button above.</p>
{code_html}
</main> </main>
<script> <script>
const deepLink = {deep_link_js}; const deepLink = {deep_link_js};
window.setTimeout(() => {{ window.setTimeout(() => {{
window.location.href = deepLink; window.location.href = deepLink;
}}, 100); }}, 100);
window.setTimeout(() => {{
window.close();
}}, 1800);
</script> </script>
</body> </body>
</html>"#, </html>"#,
@@ -1230,4 +1278,25 @@ mod tests {
); );
assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none()); assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none());
} }
#[test]
fn mobile_oidc_redirect_uri_allows_loopback_http() {
assert_eq!(
safe_mobile_redirect_uri(Some("http://127.0.0.1:8753/callback")).as_deref(),
Some("http://127.0.0.1:8753/callback")
);
assert_eq!(
safe_mobile_redirect_uri(Some("http://localhost:1234/callback")).as_deref(),
Some("http://localhost:1234/callback")
);
assert_eq!(
safe_mobile_redirect_uri(Some("http://[::1]:1234/callback")).as_deref(),
Some("http://[::1]:1234/callback")
);
// Non-loopback hosts, missing ports and https stay rejected.
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("http://evil.com:80/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("https://127.0.0.1:80/callback")).is_none());
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1:notaport/x")).is_none());
}
} }
+63 -34
View File
@@ -861,6 +861,12 @@ fn native_device_name_from_user_agent(user_agent: Option<&str>) -> Option<String
None => "Furumi MacOS".to_string(), None => "Furumi MacOS".to_string(),
}); });
} }
if product.eq_ignore_ascii_case("FurumiTUI") || product.eq_ignore_ascii_case("furumi-tui") {
return Some(match version.as_deref() {
Some(v) => format!("Furumi TUI {v}"),
None => "Furumi TUI".to_string(),
});
}
} }
None None
} }
@@ -890,6 +896,9 @@ fn device_kind_from_user_agent(user_agent: Option<&str>) -> &'static str {
if ua.contains("furumimac") { if ua.contains("furumimac") {
return "computer"; return "computer";
} }
if ua.contains("furumitui/") || ua.contains("furumi-tui/") {
return "computer";
}
if ua.contains("iphone") || (ua.contains("android") && ua.contains("mobile")) { if ua.contains("iphone") || (ua.contains("android") && ua.contains("mobile")) {
"phone" "phone"
} else if ua.contains("ipad") || ua.contains("tablet") || ua.contains("android") { } else if ua.contains("ipad") || ua.contains("tablet") || ua.contains("android") {
@@ -914,6 +923,22 @@ mod device_tests {
assert_eq!(device_kind_from_user_agent(user_agent), "phone"); assert_eq!(device_kind_from_user_agent(user_agent), "phone");
} }
#[test]
fn detects_furumi_tui_native_client() {
let user_agent = Some("FurumiTUI/0.1.0 macos");
assert_eq!(device_name_from_user_agent(user_agent), "Furumi TUI 0.1.0");
assert_eq!(device_kind_from_user_agent(user_agent), "computer");
}
#[test]
fn detects_furumi_tui_http_user_agent_token() {
let user_agent = Some("furumi-tui/0.1.0 (macos)");
assert_eq!(device_name_from_user_agent(user_agent), "Furumi TUI 0.1.0");
assert_eq!(device_kind_from_user_agent(user_agent), "computer");
}
#[test] #[test]
fn keeps_browser_fallback_for_generic_android_user_agents() { fn keeps_browser_fallback_for_generic_android_user_agents() {
let user_agent = Some("Mozilla/5.0 Android Mobile"); let user_agent = Some("Mozilla/5.0 Android Mobile");
@@ -3201,40 +3226,44 @@ async fn artist_detail_handler(
.collect(); .collect();
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>( let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number, r#"SELECT * FROM (
t.duration_seconds, t.cover_file_id, SELECT DISTINCT ON (lower(t.title::text))
r.cover_file_id as release_cover_file_id, t.id, t.title::text as title, t.track_number, t.disc_number,
r.id as release_id, t.duration_seconds, t.cover_file_id,
r.title::text as release_title, r.cover_file_id as release_cover_file_id,
r.year as release_year, r.id as release_id,
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, r.title::text as release_title,
mf.audio_format, r.year as release_year,
mf.audio_bitrate, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
mf.audio_sample_rate, mf.audio_format,
mf.audio_bit_depth, mf.audio_bitrate,
mf.file_size_bytes, mf.audio_sample_rate,
t.lastfm_listeners, mf.audio_bit_depth,
t.lastfm_playcount, mf.file_size_bytes,
t.lastfm_rating, t.lastfm_listeners,
t.lastfm_updated_at t.lastfm_playcount,
FROM furumusic__track t t.lastfm_rating,
JOIN furumusic__release r ON r.id = t.release_id t.lastfm_updated_at
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id FROM furumusic__track t
WHERE t.is_hidden = false JOIN furumusic__release r ON r.id = t.release_id
AND r.is_hidden = false LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
AND EXISTS ( WHERE t.is_hidden = false
SELECT 1 AND r.is_hidden = false
FROM furumusic__track_artist ta AND EXISTS (
WHERE ta.track_id = t.id SELECT 1
AND ta.artist_id = $1 FROM furumusic__track_artist ta
AND ta.role <> 'featuring' WHERE ta.track_id = t.id
) AND ta.artist_id = $1
ORDER BY COALESCE(t.lastfm_rating, 0) DESC, AND ta.role <> 'featuring'
COALESCE(t.lastfm_playcount, 0) DESC, )
COALESCE(t.lastfm_listeners, 0) DESC, ORDER BY lower(t.title::text), COALESCE(t.lastfm_rating, 0) DESC
r.year DESC NULLS LAST, ) deduped
t.track_number NULLS LAST, ORDER BY COALESCE(lastfm_rating, 0) DESC,
t.id COALESCE(lastfm_playcount, 0) DESC,
COALESCE(lastfm_listeners, 0) DESC,
release_year DESC NULLS LAST,
track_number NULLS LAST,
id
LIMIT 50"#, LIMIT 50"#,
) )
.bind(artist_id) .bind(artist_id)
+323 -13
View File
@@ -1193,6 +1193,77 @@ tbody tr:hover {
gap: 12px; gap: 12px;
} }
.release-track-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
margin-bottom: 10px;
}
.release-track-list {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
overflow: hidden;
}
.release-track-head,
.release-track-row {
display: grid;
grid-template-columns: 72px 82px minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, .9fr) 70px 36px;
gap: 8px;
align-items: center;
padding: 8px;
}
.release-track-head {
min-height: 34px;
border-bottom: 1px solid var(--border-color);
color: var(--text-subdued);
font-size: 10px;
font-weight: 850;
text-transform: uppercase;
}
.release-track-row {
min-height: 48px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
}
.release-track-row:last-child {
border-bottom: 0;
}
.release-track-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.release-track-row input {
width: 100%;
height: 30px;
min-height: 30px;
padding: 0 8px;
}
.release-track-title,
.release-track-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.release-track-title {
color: var(--text-primary);
font-size: 12px;
font-weight: 750;
}
.release-track-meta {
color: var(--text-subdued);
font-size: 11px;
}
.image-actions { .image-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1836,6 +1907,10 @@ tbody tr:hover {
<i data-lucide="square-pen"></i> <i data-lucide="square-pen"></i>
Edit Edit
</button> </button>
<button class="btn primary" x-show="libraryKind === 'releases'" @click="openReleaseCreator()">
<i data-lucide="plus"></i>
New release
</button>
<button class="btn warn" @click="mockAction('Merge wizard will open from this action slot')"> <button class="btn warn" @click="mockAction('Merge wizard will open from this action slot')">
<i data-lucide="git-merge"></i> <i data-lucide="git-merge"></i>
Merge Merge
@@ -2419,8 +2494,8 @@ tbody tr:hover {
<section class="modal"> <section class="modal">
<div class="modal-head"> <div class="modal-head">
<div class="panel-title"> <div class="panel-title">
<strong x-text="activeLibraryItem?.title || 'Editor'"></strong> <strong x-text="editorTitle()"></strong>
<span x-text="activeLibraryItem?.kind || 'Library entity'"></span> <span x-text="editorSubtitle()"></span>
</div> </div>
<button class="icon-btn" @click="editorOpen = false"> <button class="icon-btn" @click="editorOpen = false">
<i data-lucide="x"></i> <i data-lucide="x"></i>
@@ -2454,6 +2529,54 @@ tbody tr:hover {
</div> </div>
</div> </div>
<div class="field" x-show="isReleaseEditor()">
<label>Release tracks</label>
<div class="release-track-search-row">
<div class="artist-picker">
<input class="search" placeholder="Search track" x-model="releaseTrackSearch" @input.debounce.300ms="searchReleaseTracks()" @keydown.enter.prevent="addBestReleaseTrack()" @keydown.escape="clearReleaseTrackSearch()" />
<div class="artist-results" x-show="releaseTrackSearchOpen()" x-transition>
<template x-for="track in availableReleaseTrackResults()" :key="track.id">
<button class="artist-result" type="button" @click="addReleaseTrack(track)">
<span x-text="track.title"></span>
<small x-text="releaseTrackSearchMeta(track)"></small>
</button>
</template>
<div class="artist-result muted" x-show="releaseTrackSearchLoading">Searching...</div>
<div class="artist-result muted" x-show="!releaseTrackSearchLoading && availableReleaseTrackResults().length === 0">No matching tracks</div>
</div>
</div>
<button class="btn" type="button" @click="addBestReleaseTrack()" :disabled="!releaseTrackSearch.trim()">
<i data-lucide="plus"></i>
Add
</button>
</div>
<div class="release-track-list" x-show="releaseTracks().length">
<div class="release-track-head">
<span>Disc</span>
<span>Track #</span>
<span>Title</span>
<span>Artists</span>
<span>Current release</span>
<span>Time</span>
<span></span>
</div>
<template x-for="track in releaseTracks()" :key="track.id">
<div class="release-track-row">
<input type="number" min="1" max="999" x-model="track.disc_number" />
<input type="number" min="1" max="9999" x-model="track.track_number" />
<div class="release-track-title" x-text="track.title"></div>
<div class="release-track-meta" x-text="track.artists || 'Unknown artist'"></div>
<div class="release-track-meta" x-text="releaseTrackOrigin(track)"></div>
<div class="release-track-meta" x-text="trackDuration(track.duration_seconds)"></div>
<button class="icon-btn" type="button" @click="removeReleaseTrack(track.id)" title="Remove from release">
<i data-lucide="x"></i>
</button>
</div>
</template>
</div>
<div class="empty" x-show="!releaseTracks().length">No tracks attached</div>
</div>
<div class="editor-grid" x-show="isTrackEditor()"> <div class="editor-grid" x-show="isTrackEditor()">
<div class="field"> <div class="field">
<label>Track #</label> <label>Track #</label>
@@ -2585,9 +2708,9 @@ tbody tr:hover {
<div class="toolbar"> <div class="toolbar">
<button class="btn primary" @click="saveLibraryItem()" :disabled="!editorCanSave()"> <button class="btn primary" @click="saveLibraryItem()" :disabled="!editorCanSave()">
<i :data-lucide="editorSaving ? 'loader-circle' : 'save'"></i> <i :data-lucide="editorSaving ? 'loader-circle' : 'save'"></i>
<span x-text="editorSaving ? 'Saving...' : 'Save'"></span> <span x-text="editorSaving ? 'Saving...' : (editorIsNewRelease() ? 'Create' : 'Save')"></span>
</button> </button>
<button class="btn danger" @click="deleteLibraryItem(activeLibraryItem)" :disabled="editorSaving || editorImageUploading"> <button class="btn danger" x-show="!editorIsNewRelease()" @click="deleteLibraryItem(activeLibraryItem)" :disabled="editorSaving || editorImageUploading">
<i data-lucide="trash-2"></i> <i data-lucide="trash-2"></i>
Delete Delete
</button> </button>
@@ -2682,8 +2805,12 @@ function adminV2() {
editorImageFile: null, editorImageFile: null,
editorArtistToAdd: '', editorArtistToAdd: '',
editorReleaseToAdd: '', editorReleaseToAdd: '',
releaseTrackSearch: '',
releaseTrackSearchResults: [],
releaseTrackSearchLoading: false,
releaseTrackSearchToken: 0,
editorDetail: null, editorDetail: null,
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [] }, editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [], release_tracks: [] },
settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false }, settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
settingsDraft: { settingsDraft: {
auth_password_enabled: false, auth_password_enabled: false,
@@ -3396,16 +3523,30 @@ function adminV2() {
release_id: null, release_id: null,
track_number: '', track_number: '',
disc_number: '', disc_number: '',
artist_ids: [] artist_ids: [],
release_tracks: []
}; };
this.editorDetail = null; this.editorDetail = null;
this.editorImageFile = null; this.editorImageFile = null;
this.editorArtistToAdd = ''; this.editorArtistToAdd = '';
this.editorReleaseToAdd = ''; this.editorReleaseToAdd = '';
this.clearReleaseTrackSearch();
this.editorOpen = true; this.editorOpen = true;
this.loadEditorDetail(item); this.loadEditorDetail(item);
}, },
openReleaseCreator() {
this.libraryKind = 'releases';
this.openEditor({
id: 0,
kind: 'releases',
title: '',
subtitle: 'New release',
is_hidden: false,
tags: []
});
},
async loadEditorDetail(item) { async loadEditorDetail(item) {
const key = `${item.kind}:${item.id}`; const key = `${item.kind}:${item.id}`;
this.editorLoading = true; this.editorLoading = true;
@@ -3422,11 +3563,13 @@ function adminV2() {
release_id: detail.release_id || null, release_id: detail.release_id || null,
track_number: detail.track_number || '', track_number: detail.track_number || '',
disc_number: detail.disc_number || '', disc_number: detail.disc_number || '',
artist_ids: Array.isArray(detail.selected_artist_ids) ? detail.selected_artist_ids.slice() : [] artist_ids: Array.isArray(detail.selected_artist_ids) ? detail.selected_artist_ids.slice() : [],
release_tracks: Array.isArray(detail.release_tracks) ? detail.release_tracks.map(track => this.normalizeReleaseTrack(track)) : []
}; };
this.editorImageFile = null; this.editorImageFile = null;
this.editorArtistToAdd = ''; this.editorArtistToAdd = '';
this.editorReleaseToAdd = ''; this.editorReleaseToAdd = '';
this.clearReleaseTrackSearch();
} catch (error) { } catch (error) {
this.showToast(error.message); this.showToast(error.message);
} finally { } finally {
@@ -3449,8 +3592,22 @@ function adminV2() {
return this.activeLibraryItem && this.activeLibraryItem.kind === 'tracks'; return this.activeLibraryItem && this.activeLibraryItem.kind === 'tracks';
}, },
editorIsNewRelease() {
return this.isReleaseEditor() && Number(this.activeLibraryItem.id || 0) === 0;
},
editorTitle() {
if (this.editorIsNewRelease()) return 'New release';
return this.activeLibraryItem?.title || 'Editor';
},
editorSubtitle() {
if (this.editorIsNewRelease()) return 'Create release and attach tracks';
return this.activeLibraryItem?.kind || 'Library entity';
},
canEditLibraryImage() { canEditLibraryImage() {
return this.isArtistEditor() || this.isReleaseEditor(); return this.isArtistEditor() || (this.isReleaseEditor() && !this.editorIsNewRelease());
}, },
canShowMetadataTags() { canShowMetadataTags() {
@@ -3497,6 +3654,7 @@ function adminV2() {
editorCanSave() { editorCanSave() {
if (!this.activeLibraryItem || !this.editorDetail || this.editorLoading || this.editorSaving) return false; if (!this.activeLibraryItem || !this.editorDetail || this.editorLoading || this.editorSaving) return false;
if (!String(this.editorDraft.title || '').trim()) return false;
if (this.isTrackEditor() && !this.editorDraft.release_id) return false; if (this.isTrackEditor() && !this.editorDraft.release_id) return false;
return true; return true;
}, },
@@ -3605,6 +3763,146 @@ function adminV2() {
return true; return true;
}, },
normalizeReleaseTrack(track = {}) {
const trackNumber = track.track_number;
const discNumber = track.disc_number;
return {
id: Number(track.id),
title: track.title || `Track #${track.id}`,
artists: track.artists || '',
release_id: track.release_id == null ? null : Number(track.release_id),
release_title: track.release_title || '',
track_number: trackNumber == null ? '' : String(trackNumber),
disc_number: discNumber == null ? '' : String(discNumber),
duration_seconds: Number(track.duration_seconds || 0),
is_hidden: Boolean(track.is_hidden)
};
},
releaseTracks() {
if (!Array.isArray(this.editorDraft.release_tracks)) {
this.editorDraft.release_tracks = [];
}
return this.editorDraft.release_tracks;
},
releaseTrackPayload() {
return this.releaseTracks().map(track => ({
id: Number(track.id),
track_number: track.track_number || '',
disc_number: track.disc_number || ''
}));
},
releaseTrackIds() {
return new Set(this.releaseTracks().map(track => Number(track.id)));
},
releaseTrackSearchOpen() {
return this.isReleaseEditor() && String(this.releaseTrackSearch || '').trim().length > 0;
},
availableReleaseTrackResults() {
const selected = this.releaseTrackIds();
return (this.releaseTrackSearchResults || []).filter(track => !selected.has(Number(track.id)));
},
clearReleaseTrackSearch() {
this.releaseTrackSearch = '';
this.releaseTrackSearchResults = [];
this.releaseTrackSearchLoading = false;
this.releaseTrackSearchToken += 1;
},
async searchReleaseTracks() {
const query = String(this.releaseTrackSearch || '').trim();
if (!query) {
this.releaseTrackSearchResults = [];
this.releaseTrackSearchLoading = false;
return;
}
const token = this.releaseTrackSearchToken + 1;
this.releaseTrackSearchToken = token;
this.releaseTrackSearchLoading = true;
try {
const params = new URLSearchParams({ search: query, limit: '16' });
const rows = await this.request(`${this.apiBase}/library/tracks/search?${params.toString()}`);
if (this.releaseTrackSearchToken !== token) return;
this.releaseTrackSearchResults = Array.isArray(rows) ? rows.map(track => this.normalizeReleaseTrack(track)) : [];
} catch (error) {
if (this.releaseTrackSearchToken === token) this.showToast(error.message);
} finally {
if (this.releaseTrackSearchToken === token) {
this.releaseTrackSearchLoading = false;
this.icons();
}
}
},
async addBestReleaseTrack() {
if (!String(this.releaseTrackSearch || '').trim()) return;
if (!this.availableReleaseTrackResults().length && !this.releaseTrackSearchLoading) {
await this.searchReleaseTracks();
}
const track = this.availableReleaseTrackResults()[0];
if (!track) {
this.showToast('Choose a track from search results');
return;
}
this.addReleaseTrack(track);
},
addReleaseTrack(track) {
if (!track) return;
const normalized = this.normalizeReleaseTrack(track);
if (this.releaseTrackIds().has(Number(normalized.id))) {
this.showToast('Track already in release');
return;
}
this.editorDraft.release_tracks = this.releaseTracks().concat([normalized]);
this.clearReleaseTrackSearch();
this.$nextTick(() => this.icons());
},
removeReleaseTrack(id) {
this.editorDraft.release_tracks = this.releaseTracks().filter(track => Number(track.id) !== Number(id));
},
releaseTrackOrigin(track) {
const releaseId = Number(track && track.release_id ? track.release_id : 0);
const currentId = Number(this.activeLibraryItem && this.activeLibraryItem.id ? this.activeLibraryItem.id : 0);
if (releaseId && releaseId === currentId) return this.editorDraft.title || track.release_title || 'This release';
if (track && track.release_title) return track.release_title;
if (releaseId) return `Missing release #${releaseId}`;
return 'No release';
},
releaseTrackSearchMeta(track) {
const parts = [];
if (track.artists) parts.push(track.artists);
parts.push(this.releaseTrackOrigin(track));
const number = this.releaseTrackNumberLabel(track);
if (number) parts.push(number);
return parts.join(' / ');
},
releaseTrackNumberLabel(track) {
const disc = String((track && track.disc_number) || '').trim();
const number = String((track && track.track_number) || '').trim();
if (disc && number) return `D${disc} #${number}`;
if (number) return `#${number}`;
if (disc) return `D${disc}`;
return '';
},
trackDuration(seconds) {
const total = Math.round(Number(seconds || 0));
if (!total) return '-';
const minutes = Math.floor(total / 60);
const rest = String(total % 60).padStart(2, '0');
return `${minutes}:${rest}`;
},
setEditorImageFile(event) { setEditorImageFile(event) {
this.editorImageFile = event.target.files && event.target.files.length ? event.target.files[0] : null; this.editorImageFile = event.target.files && event.target.files.length ? event.target.files[0] : null;
}, },
@@ -3729,6 +4027,7 @@ function adminV2() {
if (!this.editorCanSave()) return; if (!this.editorCanSave()) return;
this.editorSaving = true; this.editorSaving = true;
try { try {
const wasNewRelease = this.editorIsNewRelease();
const updated = await this.request(`${this.apiBase}/library/item`, { const updated = await this.request(`${this.apiBase}/library/item`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@@ -3741,13 +4040,15 @@ function adminV2() {
release_id: this.editorDraft.release_id ? Number(this.editorDraft.release_id) : null, release_id: this.editorDraft.release_id ? Number(this.editorDraft.release_id) : null,
track_number: this.editorDraft.track_number || '', track_number: this.editorDraft.track_number || '',
disc_number: this.editorDraft.disc_number || '', disc_number: this.editorDraft.disc_number || '',
artist_ids: this.editorDraft.artist_ids || [] artist_ids: this.editorDraft.artist_ids || [],
release_tracks: this.isReleaseEditor() ? this.releaseTrackPayload() : null
}) })
}); });
this.replaceLibraryItem(updated); this.replaceLibraryItem(updated);
this.activeLibraryItem = updated; this.activeLibraryItem = updated;
if (this.editorDetail) this.editorDetail.item = updated; if (this.editorDetail) this.editorDetail.item = updated;
this.showToast('Saved'); if (this.isReleaseEditor()) await this.loadEditorDetail(updated);
this.showToast(wasNewRelease ? 'Release created' : 'Saved');
await this.refreshCountsOnly(); await this.refreshCountsOnly();
} catch (error) { } catch (error) {
this.showToast(error.message); this.showToast(error.message);
@@ -3768,9 +4069,18 @@ function adminV2() {
}, },
replaceLibraryItem(updated) { replaceLibraryItem(updated) {
this.library.items = this.library.items.map(item => let replaced = false;
item.kind === updated.kind && item.id === updated.id ? updated : item this.library.items = this.library.items.map(item => {
); if (item.kind === updated.kind && Number(item.id) === Number(updated.id)) {
replaced = true;
return updated;
}
return item;
});
if (!replaced && updated.kind === this.libraryKind) {
this.library.items = [updated].concat(this.library.items || []);
this.library.total = Number(this.library.total || 0) + 1;
}
}, },
async refreshCountsOnly() { async refreshCountsOnly() {
+54
View File
@@ -822,6 +822,7 @@ document.addEventListener('alpine:init', () => {
_playLocal(track, options = {}) { _playLocal(track, options = {}) {
this.currentTrack = track; this.currentTrack = track;
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
this._localSourceTrackId = track.id; this._localSourceTrackId = track.id;
this._historyRecorded = false; this._historyRecorded = false;
this._resetPlaybackTracking(); this._resetPlaybackTracking();
@@ -1138,6 +1139,7 @@ document.addEventListener('alpine:init', () => {
_mirrorRemoteTrack(track, playing, positionSeconds = null) { _mirrorRemoteTrack(track, playing, positionSeconds = null) {
if (!track) return; if (!track) return;
this.currentTrack = track; this.currentTrack = track;
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
this.isPlaying = !!playing; this.isPlaying = !!playing;
if (positionSeconds !== null) this.currentTime = Math.max(0, Number(positionSeconds || 0)); if (positionSeconds !== null) this.currentTime = Math.max(0, Number(positionSeconds || 0));
this.duration = Number(track.duration_seconds || this.duration || 0); this.duration = Number(track.duration_seconds || this.duration || 0);
@@ -1158,6 +1160,7 @@ document.addEventListener('alpine:init', () => {
const track = state.track || queue?.tracks?.[queue.currentIndex] || null; const track = state.track || queue?.tracks?.[queue.currentIndex] || null;
if (track) { if (track) {
this.currentTrack = track; this.currentTrack = track;
queue?.syncCurrentIndexToTrack(track);
} }
this.shuffle = !!state.shuffle; this.shuffle = !!state.shuffle;
this.repeatMode = state.repeat_mode || 'off'; this.repeatMode = state.repeat_mode || 'off';
@@ -1359,6 +1362,7 @@ document.addEventListener('alpine:init', () => {
: tracks[idx]; : tracks[idx];
if (currentTrack) { if (currentTrack) {
this.currentTrack = currentTrack; this.currentTrack = currentTrack;
queue.syncCurrentIndexToTrack(currentTrack);
this._localSourceTrackId = currentTrack.id; this._localSourceTrackId = currentTrack.id;
this._historyRecorded = false; this._historyRecorded = false;
this._resetPlaybackTracking(); this._resetPlaybackTracking();
@@ -1976,6 +1980,56 @@ document.addEventListener('alpine:init', () => {
return this.tracks.slice(start, start + limit); return this.tracks.slice(start, start + limit);
}, },
effectiveCurrentIndex() {
const currentTrack = Alpine.store('player')?.currentTrack || null;
if (currentTrack?.id) {
return this.tracks.findIndex(track => Number(track?.id) === Number(currentTrack.id));
}
if (!this.tracks.length) return -1;
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
},
queueItemState(index) {
const current = this.effectiveCurrentIndex();
if (current < 0) return 'upcoming';
if (index < current) return 'played';
if (index === current) return 'current';
return 'upcoming';
},
displayItems() {
const current = this.effectiveCurrentIndex();
const playerTrack = Alpine.store('player')?.currentTrack || null;
const items = this.tracks.map((track, index) => ({
track,
index,
key: `${index}-${track?.id || 'track'}`,
state: current >= 0
? (index < current ? 'played' : (index === current ? 'current' : 'upcoming'))
: 'upcoming',
synthetic: false,
}));
if (playerTrack?.id && current < 0) {
items.unshift({
track: playerTrack,
index: -1,
key: `current-${playerTrack.id}`,
state: 'current',
synthetic: true,
});
}
return items;
},
syncCurrentIndexToTrack(track) {
if (!track?.id || !this.tracks.length) return -1;
const index = this.tracks.findIndex(item => Number(item?.id) === Number(track.id));
if (index >= 0) this.currentIndex = index;
return index;
},
addToEnd(tracks) { addToEnd(tracks) {
const items = this._tracksForQueueAdd(tracks); const items = this._tracksForQueueAdd(tracks);
if (!items.length) return; if (!items.length) return;
+111 -33
View File
@@ -620,6 +620,83 @@
</div> </div>
</div> </div>
</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>
<div class="track-list-header">
<span>#</span>
<span>{{ t.player_title }}</span>
<span></span>
<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">
<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)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<button class="artist-appearance-cover"
type="button"
@click.stop="$store.library.openRelease(track.release_id)"
:title="track.release_title"
aria-label="{{ t.player_release }}">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
</template>
<template x-if="!track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
</template>
</button>
<div class="artist-appearance-copy">
<div class="track-title">
<span x-text="track.title"></span>
<span style="color:var(--text-subdued)"> · </span>
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
</div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn popularity-info-btn"
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</section>
</template>
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type"> <template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
<section class="artist-release-group"> <section class="artist-release-group">
<h2 class="artist-release-group-title" x-text="group.label"></h2> <h2 class="artist-release-group-title" x-text="group.label"></h2>
@@ -959,42 +1036,43 @@
</div> </div>
</div> </div>
<div class="queue-tracks"> <div class="queue-tracks">
<template x-if="$store.queue.tracks.length === 0"> <template x-if="$store.queue.displayItems().length === 0">
<div class="empty-state"> <div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<p>{{ t.player_queue_empty }}</p> <p>{{ t.player_queue_empty }}</p>
</div> </div>
</template> </template>
<template x-for="(track, idx) in $store.queue.tracks" :key="idx + '-' + track.id"> <template x-for="item in $store.queue.displayItems()" :key="item.key">
<div class="queue-track" <div class="queue-track"
:data-queue-index="idx" :data-queue-index="item.index"
:class="{ active: idx === $store.queue.currentIndex, dragging: $store.queue._dragIdx === idx, 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }" :class="{ active: item.state === 'current', current: item.state === 'current', played: item.state === 'played', dragging: $store.queue._dragIdx === item.index, synthetic: item.synthetic, 'foreign-jam-track': $store.queue.isForeignJamTrack(item.track) }"
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''" :style="$store.queue.isForeignJamTrack(item.track) ? $store.queue.contributorStyle(item.track) : ''"
@click="$store.queue.playFromIndex(idx)" @click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)"
draggable="true" :draggable="!item.synthetic"
@dragstart="$store.queue._dragIdx = idx; $event.dataTransfer.effectAllowed = 'move'" @dragstart="if (item.synthetic) { $event.preventDefault(); } else { $store.queue._dragIdx = item.index; $event.dataTransfer.effectAllowed = 'move'; }"
@dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))" @dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))"
@dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')" @dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')"
@dragleave="$event.currentTarget.classList.remove('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')"
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if ($store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, idx); $store.queue._dragIdx = null; }"> @drop.prevent="$event.currentTarget.classList.remove('drag-over'); if (!item.synthetic && $store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, item.index); $store.queue._dragIdx = null; }">
<div class="queue-drag-handle" <div class="queue-drag-handle"
x-show="!item.synthetic"
@mousedown.stop @mousedown.stop
@click.stop @click.stop
@pointerdown.stop="$store.queue.startPointerReorder($event, idx)"> @pointerdown.stop="$store.queue.startPointerReorder($event, item.index)">
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
</div> </div>
<div class="queue-track-cover"> <div class="queue-track-cover">
<template x-if="track.cover_url"> <template x-if="item.track.cover_url">
<img :src="track.cover_url" :alt="track.title" loading="lazy"> <img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
</template> </template>
<template x-if="!track.cover_url"> <template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template> </template>
</div> </div>
<div class="queue-track-info"> <div class="queue-track-info">
<div class="queue-track-title" x-text="track.title"></div> <div class="queue-track-title" x-text="item.track.title"></div>
<div class="queue-track-artist"> <div class="queue-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx"> <template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span> <span>
<template x-if="artistIdx > 0"><span>, </span></template> <template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a> <a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
@@ -1004,15 +1082,15 @@
</div> </div>
<div class="queue-track-actions"> <div class="queue-track-actions">
<button class="queue-track-remove info-btn popularity-info-btn" <button class="queue-track-remove info-btn popularity-info-btn"
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }" :class="{ 'has-popularity': $store.library.hasPopularity(item.track), 'no-popularity': !$store.library.hasPopularity(item.track) }"
:style="$store.library.popularityStyle(track)" :style="$store.library.popularityStyle(item.track)"
@click.stop="$store.library.openTrackInfo(track)" @click.stop="$store.library.openTrackInfo(item.track)"
:title="$store.library.trackInfoTitle(track)" :title="$store.library.trackInfoTitle(item.track)"
aria-label="{{ t.player_track_info }}"> aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span> <span x-show="$store.library.hasPopularity(item.track)" x-text="$store.library.popularityLabel(item.track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span> <span x-show="!$store.library.hasPopularity(item.track)" class="info-letter">i</span>
</button> </button>
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="{{ t.player_remove }}"> <button class="queue-track-remove" x-show="!item.synthetic" @click.stop="$store.queue.remove(item.index)" title="{{ t.player_remove }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button> </button>
</div> </div>
@@ -1292,27 +1370,27 @@
</div> </div>
<div class="mobile-expanded-queue"> <div class="mobile-expanded-queue">
<div class="mobile-expanded-queue-title">{{ t.player_queue }}</div> <div class="mobile-expanded-queue-title">{{ t.player_queue }}</div>
<template x-if="$store.queue.upcoming().length === 0"> <template x-if="$store.queue.displayItems().length === 0">
<div class="mobile-expanded-queue-empty">{{ t.player_queue_empty }}</div> <div class="mobile-expanded-queue-empty">{{ t.player_queue_empty }}</div>
</template> </template>
<template x-for="(track, idx) in $store.queue.upcoming()" :key="'mobile-expanded-queue-' + track.id + '-' + idx"> <template x-for="item in $store.queue.displayItems()" :key="'mobile-expanded-queue-' + item.key">
<button class="mobile-expanded-queue-row" <button class="mobile-expanded-queue-row"
:class="{ 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }" :class="{ current: item.state === 'current', played: item.state === 'played', 'foreign-jam-track': $store.queue.isForeignJamTrack(item.track) }"
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''" :style="$store.queue.isForeignJamTrack(item.track) ? $store.queue.contributorStyle(item.track) : ''"
type="button" type="button"
@click="$store.queue.playFromIndex($store.queue.currentIndex + idx + 1)"> @click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
<div class="mobile-expanded-queue-cover"> <div class="mobile-expanded-queue-cover">
<template x-if="track.cover_url"> <template x-if="item.track.cover_url">
<img :src="track.cover_url" :alt="track.title" loading="lazy"> <img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
</template> </template>
<template x-if="!track.cover_url"> <template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template> </template>
</div> </div>
<div class="mobile-expanded-queue-info"> <div class="mobile-expanded-queue-info">
<div class="mobile-expanded-queue-name" x-text="track.title"></div> <div class="mobile-expanded-queue-name" x-text="item.track.title"></div>
<div class="mobile-expanded-queue-artist"> <div class="mobile-expanded-queue-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx"> <template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span> <span>
<template x-if="artistIdx > 0"><span>, </span></template> <template x-if="artistIdx > 0"><span>, </span></template>
<span x-text="artist.label"></span> <span x-text="artist.label"></span>
@@ -1320,7 +1398,7 @@
</template> </template>
</div> </div>
</div> </div>
<span class="mobile-expanded-queue-time" x-text="formatTime(track.duration_seconds)"></span> <span class="mobile-expanded-queue-time" x-text="formatTime(item.track.duration_seconds)"></span>
</button> </button>
</template> </template>
</div> </div>
+41 -2
View File
@@ -1224,7 +1224,22 @@ button.user-stat:hover {
} }
.queue-track:hover { background: var(--bg-hover); } .queue-track:hover { background: var(--bg-hover); }
.queue-track.active { background: var(--bg-active); } .queue-track.active,
.queue-track.current { background: var(--bg-active); }
.queue-track.played {
color: var(--text-subdued);
opacity: 0.58;
}
.queue-track.played:hover {
opacity: 0.78;
}
.queue-track.played .queue-track-cover {
filter: grayscale(1);
opacity: 0.72;
}
.queue-track.synthetic .queue-drag-handle {
display: none;
}
.queue-track.foreign-jam-track { .queue-track.foreign-jam-track {
background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 78%); background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 78%);
} }
@@ -1259,7 +1274,9 @@ button.user-stat:hover {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.queue-track.active .queue-track-title { color: var(--accent); } .queue-track.active .queue-track-title,
.queue-track.current .queue-track-title { color: var(--accent); }
.queue-track.played .queue-track-title { color: var(--text-subdued); }
.queue-track-artist { .queue-track-artist {
font-size: 11px; font-size: 11px;
@@ -4925,6 +4942,28 @@ button.user-stat:hover {
background: var(--bg-hover); background: var(--bg-hover);
} }
.mobile-expanded-queue-row.current {
background: var(--bg-active);
}
.mobile-expanded-queue-row.current .mobile-expanded-queue-name {
color: var(--accent);
}
.mobile-expanded-queue-row.played {
color: var(--text-subdued);
opacity: 0.56;
}
.mobile-expanded-queue-row.played:active {
opacity: 0.74;
}
.mobile-expanded-queue-row.played .mobile-expanded-queue-cover {
filter: grayscale(1);
opacity: 0.72;
}
.mobile-expanded-queue-row.foreign-jam-track { .mobile-expanded-queue-row.foreign-jam-track {
background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 82%); background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 82%);
} }