Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1370c6a28 |
Generated
+1
-1
@@ -1845,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.6.7-fd"
|
version = "0.7.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.7.0"
|
version = "0.7.1"
|
||||||
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"
|
||||||
|
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ translations! {
|
|||||||
player_cancel: "Cancel" , "Отмена";
|
player_cancel: "Cancel" , "Отмена";
|
||||||
player_create: "Create" , "Создать";
|
player_create: "Create" , "Создать";
|
||||||
player_save: "Save" , "Сохранить";
|
player_save: "Save" , "Сохранить";
|
||||||
|
player_done: "Done" , "Готово";
|
||||||
player_delete: "Delete" , "Удалить";
|
player_delete: "Delete" , "Удалить";
|
||||||
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||||
player_rename: "Rename" , "Переименовать";
|
player_rename: "Rename" , "Переименовать";
|
||||||
|
|||||||
+8
-1
@@ -74,6 +74,13 @@ pub(super) struct TrackItem {
|
|||||||
pub(super) lastfm_updated_at: Option<String>,
|
pub(super) lastfm_updated_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub(super) struct PlaylistTrackItem {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub(super) track: TrackItem,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct ArtistAppearanceTrack {
|
pub(super) struct ArtistAppearanceTrack {
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
@@ -286,7 +293,7 @@ pub(super) struct PlaylistDetail {
|
|||||||
pub(super) is_public: bool,
|
pub(super) is_public: bool,
|
||||||
pub(super) is_saved: bool,
|
pub(super) is_saved: bool,
|
||||||
pub(super) kind: String,
|
pub(super) kind: String,
|
||||||
pub(super) tracks: Vec<TrackItem>,
|
pub(super) tracks: Vec<PlaylistTrackItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
|||||||
+200
-12
@@ -3414,6 +3414,7 @@ async fn artist_detail_handler(
|
|||||||
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT * FROM (
|
r#"SELECT * FROM (
|
||||||
SELECT DISTINCT ON (lower(t.title::text))
|
SELECT DISTINCT ON (lower(t.title::text))
|
||||||
|
NULL::bigint AS playlist_track_id,
|
||||||
t.id, t.title::text as title, t.track_number, t.disc_number,
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
@@ -3515,7 +3516,8 @@ async fn release_detail_handler(
|
|||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -3687,7 +3689,8 @@ async fn playlist_detail_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT pt.id AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -3715,7 +3718,7 @@ async fn playlist_detail_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let track_items = build_track_items(tracks, pool).await?;
|
let track_items = build_playlist_track_items(tracks, pool).await?;
|
||||||
|
|
||||||
Json(PlaylistDetail {
|
Json(PlaylistDetail {
|
||||||
id: info.id,
|
id: info.id,
|
||||||
@@ -3813,13 +3816,34 @@ async fn build_track_items(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn build_playlist_track_items(
|
||||||
|
tracks: Vec<PlaylistTrackRow>,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> cot::Result<Vec<PlaylistTrackItem>> {
|
||||||
|
let playlist_track_ids = tracks
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.playlist_track_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let track_items = build_track_items(tracks, pool).await?;
|
||||||
|
|
||||||
|
Ok(track_items
|
||||||
|
.into_iter()
|
||||||
|
.zip(playlist_track_ids)
|
||||||
|
.map(|(track, playlist_track_id)| PlaylistTrackItem {
|
||||||
|
playlist_track_id,
|
||||||
|
track,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Result<Vec<TrackItem>> {
|
async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Result<Vec<TrackItem>> {
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -3976,7 +4000,8 @@ async fn likes_playlist_handler(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||||
|
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, t.cover_file_id,
|
t.duration_seconds, t.cover_file_id,
|
||||||
r.cover_file_id as release_cover_file_id,
|
r.cover_file_id as release_cover_file_id,
|
||||||
r.id as release_id,
|
r.id as release_id,
|
||||||
@@ -4004,7 +4029,14 @@ async fn likes_playlist_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
let track_items = build_track_items(tracks, pool).await?;
|
let track_items = build_track_items(tracks, pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|track| PlaylistTrackItem {
|
||||||
|
playlist_track_id: None,
|
||||||
|
track,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Json(PlaylistDetail {
|
Json(PlaylistDetail {
|
||||||
id: -1,
|
id: -1,
|
||||||
@@ -5609,6 +5641,110 @@ async fn add_tracks_to_playlist_handler(
|
|||||||
Json(serde_json::json!({"ok": true})).into_response()
|
Json(serde_json::json!({"ok": true})).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /api/player/playlists/{id}/tracks — reorder playlist tracks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn reorder_playlist_tracks_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
path: Path<PathId>,
|
||||||
|
Json(body): Json<ReorderPlaylistRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||||
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||||
|
};
|
||||||
|
let playlist_id = path.0.id;
|
||||||
|
let owner: Option<(i64,)> =
|
||||||
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
||||||
|
.bind(playlist_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let Some(owner) = owner else {
|
||||||
|
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
||||||
|
};
|
||||||
|
if owner.0 != user.id {
|
||||||
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested_ids = body
|
||||||
|
.playlist_track_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| *id > 0)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let unique_requested = requested_ids.iter().copied().collect::<HashSet<_>>();
|
||||||
|
if unique_requested.len() != requested_ids.len() {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"duplicate playlist track ids",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible_ids = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT pt.id
|
||||||
|
FROM furumusic__playlist_track pt
|
||||||
|
JOIN furumusic__track t ON t.id = pt.track_id
|
||||||
|
WHERE pt.playlist_id = $1 AND t.is_hidden = false
|
||||||
|
ORDER BY pt.position"#,
|
||||||
|
)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let visible_set = visible_ids.iter().copied().collect::<HashSet<_>>();
|
||||||
|
if visible_set != unique_requested {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"playlist track ids do not match this playlist",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hidden_ids = sqlx::query_scalar::<_, i64>(
|
||||||
|
r#"SELECT pt.id
|
||||||
|
FROM furumusic__playlist_track pt
|
||||||
|
JOIN furumusic__track t ON t.id = pt.track_id
|
||||||
|
WHERE pt.playlist_id = $1 AND t.is_hidden = true
|
||||||
|
ORDER BY pt.position"#,
|
||||||
|
)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut ordered_ids = requested_ids;
|
||||||
|
ordered_ids.extend(hidden_ids);
|
||||||
|
if !ordered_ids.is_empty() {
|
||||||
|
sqlx::query(
|
||||||
|
r#"WITH ordered AS (
|
||||||
|
SELECT id, ord::integer - 1 AS position
|
||||||
|
FROM unnest($1::bigint[]) WITH ORDINALITY AS u(id, ord)
|
||||||
|
)
|
||||||
|
UPDATE furumusic__playlist_track pt
|
||||||
|
SET position = ordered.position
|
||||||
|
FROM ordered
|
||||||
|
WHERE pt.id = ordered.id AND pt.playlist_id = $2"#,
|
||||||
|
)
|
||||||
|
.bind(&ordered_ids)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Json(serde_json::json!({"ok": true})).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DELETE /api/player/playlists/{id}/tracks — remove a track from playlist
|
// DELETE /api/player/playlists/{id}/tracks — remove a track from playlist
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -5638,12 +5774,29 @@ async fn remove_track_from_playlist_handler(
|
|||||||
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2")
|
match (body.playlist_track_id, body.track_id) {
|
||||||
.bind(playlist_id)
|
(Some(playlist_track_id), _) => {
|
||||||
.bind(body.track_id)
|
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND id = $2")
|
||||||
.execute(pool)
|
.bind(playlist_id)
|
||||||
.await
|
.bind(playlist_track_id)
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
(None, Some(track_id)) => {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2",
|
||||||
|
)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.bind(track_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
return Ok(json_error(StatusCode::BAD_REQUEST, "missing track id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-number positions
|
// Re-number positions
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -5660,6 +5813,14 @@ async fn remove_track_from_playlist_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(playlist_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
Json(serde_json::json!({"ok": true})).into_response()
|
Json(serde_json::json!({"ok": true})).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7618,6 +7779,33 @@ impl App for PlayerApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.put({
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
move |auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
path: Path<PathId>,
|
||||||
|
json: Json<ReorderPlaylistRequest>| {
|
||||||
|
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("player pool")
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
reorder_playlist_tracks_handler(
|
||||||
|
auth_ctx, session, db, pg_pool, path, json,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.delete({
|
.delete({
|
||||||
let pool = Arc::clone(&pool);
|
let pool = Arc::clone(&pool);
|
||||||
let pool_config = Arc::clone(&pool_config);
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ pub(super) struct AddTracksRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct RemoveTrackRequest {
|
pub(super) struct RemoveTrackRequest {
|
||||||
pub(super) track_id: i64,
|
pub(super) track_id: Option<i64>,
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct ReorderPlaylistRequest {
|
||||||
|
pub(super) playlist_track_ids: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ pub(super) struct PlaylistInfoRow {
|
|||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(super) struct PlaylistTrackRow {
|
pub(super) struct PlaylistTrackRow {
|
||||||
|
pub(super) playlist_track_id: Option<i64>,
|
||||||
pub(super) id: i64,
|
pub(super) id: i64,
|
||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_number: Option<i32>,
|
pub(super) track_number: Option<i32>,
|
||||||
|
|||||||
@@ -2745,6 +2745,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
async openPlaylist(id, options = {}) {
|
async openPlaylist(id, options = {}) {
|
||||||
this._beginNavigation('#playlist/' + id, options);
|
this._beginNavigation('#playlist/' + id, options);
|
||||||
|
Alpine.store('playlists')?.stopEdit?.();
|
||||||
this.view = 'playlist_detail';
|
this.view = 'playlist_detail';
|
||||||
this.currentPlaylist = null;
|
this.currentPlaylist = null;
|
||||||
try {
|
try {
|
||||||
@@ -2756,6 +2757,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showSharedPlaylist(share, options = {}) {
|
showSharedPlaylist(share, options = {}) {
|
||||||
|
Alpine.store('playlists')?.stopEdit?.();
|
||||||
this._saveScrollPosition(this._activeHash);
|
this._saveScrollPosition(this._activeHash);
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
this.searchResults = null;
|
this.searchResults = null;
|
||||||
@@ -4301,6 +4303,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
list: [],
|
list: [],
|
||||||
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
|
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
|
||||||
picker: null, // { trackIds: [1,2,3] }
|
picker: null, // { trackIds: [1,2,3] }
|
||||||
|
editingPlaylistId: null,
|
||||||
|
_dragIdx: null,
|
||||||
|
_dragOverIdx: null,
|
||||||
|
_pointerDragMove: null,
|
||||||
|
_pointerDragEnd: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.reload();
|
this.reload();
|
||||||
@@ -4333,6 +4340,214 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return pl?.kind === 'likes' ? T.likesPlaylist : (pl?.title || '');
|
return pl?.kind === 'likes' ? T.likesPlaylist : (pl?.title || '');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
currentPlaylist() {
|
||||||
|
return Alpine.store('library')?.currentPlaylist || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
canEditCurrent() {
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
return !!playlist && playlist.kind === 'user' && playlist.is_own && Number(playlist.id) > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
isEditingCurrent() {
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
return this.canEditCurrent() && Number(this.editingPlaylistId) === Number(playlist.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleEditCurrent() {
|
||||||
|
if (this.isEditingCurrent()) {
|
||||||
|
this.stopEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.canEditCurrent()) return;
|
||||||
|
this.editingPlaylistId = Number(this.currentPlaylist().id);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopEdit() {
|
||||||
|
this._endPointerReorder(false);
|
||||||
|
this.endDrag();
|
||||||
|
this.editingPlaylistId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateListTrackCount(playlistId, count) {
|
||||||
|
const id = Number(playlistId);
|
||||||
|
this.list = this.list.map(pl => (
|
||||||
|
Number(pl.id) === id ? { ...pl, track_count: count } : pl
|
||||||
|
));
|
||||||
|
},
|
||||||
|
|
||||||
|
_playlistTrackIds(tracks) {
|
||||||
|
return (tracks || []).map(track => Number(track?.playlist_track_id || 0)).filter(Boolean);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeCurrentTrack(track, idx) {
|
||||||
|
if (!this.isEditingCurrent() || !track) return;
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
const playlistId = Number(playlist.id);
|
||||||
|
const playlistTrackId = Number(track.playlist_track_id || 0);
|
||||||
|
const previous = (playlist.tracks || []).slice();
|
||||||
|
if (!playlistTrackId) return;
|
||||||
|
|
||||||
|
playlist.tracks = previous.filter(item => Number(item.playlist_track_id || 0) !== playlistTrackId);
|
||||||
|
this._updateListTrackCount(playlistId, playlist.tracks.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/playlists/${playlistId}/tracks`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playlist_track_id: playlistTrackId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('remove failed');
|
||||||
|
await this.reload();
|
||||||
|
} catch (err) {
|
||||||
|
if (Number(this.currentPlaylist()?.id) === playlistId) {
|
||||||
|
this.currentPlaylist().tracks = previous;
|
||||||
|
this._updateListTrackCount(playlistId, previous.length);
|
||||||
|
}
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startDrag(event, idx) {
|
||||||
|
const tracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (!this.isEditingCurrent() || idx < 0 || idx >= tracks.length || !tracks[idx]?.playlist_track_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._dragIdx = idx;
|
||||||
|
this._dragOverIdx = idx;
|
||||||
|
if (event?.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', String(tracks[idx].playlist_track_id));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
dragOver(event, idx) {
|
||||||
|
if (!this.isEditingCurrent() || this._dragIdx === null) return;
|
||||||
|
const tracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (idx < 0 || idx >= tracks.length) return;
|
||||||
|
this._dragOverIdx = idx;
|
||||||
|
if (event?.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
if (idx !== this._dragIdx) event.currentTarget.classList.add('drag-over');
|
||||||
|
},
|
||||||
|
|
||||||
|
dropOn(idx) {
|
||||||
|
const fromIdx = this._dragIdx;
|
||||||
|
this.endDrag();
|
||||||
|
if (!Number.isInteger(fromIdx) || fromIdx === idx) return;
|
||||||
|
this.moveCurrentTrack(fromIdx, idx);
|
||||||
|
},
|
||||||
|
|
||||||
|
endDrag() {
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
this._dragIdx = null;
|
||||||
|
this._dragOverIdx = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async moveCurrentTrack(fromIdx, toIdx) {
|
||||||
|
if (!this.isEditingCurrent()) return;
|
||||||
|
const playlist = this.currentPlaylist();
|
||||||
|
const playlistId = Number(playlist.id);
|
||||||
|
const previous = (playlist.tracks || []).slice();
|
||||||
|
if (fromIdx < 0 || fromIdx >= previous.length || toIdx < 0 || toIdx >= previous.length) return;
|
||||||
|
const next = previous.slice();
|
||||||
|
const [track] = next.splice(fromIdx, 1);
|
||||||
|
next.splice(toIdx, 0, track);
|
||||||
|
playlist.tracks = next;
|
||||||
|
|
||||||
|
const playlistTrackIds = this._playlistTrackIds(next);
|
||||||
|
if (playlistTrackIds.length !== next.length) {
|
||||||
|
playlist.tracks = previous;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/playlists/${playlistId}/tracks`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ playlist_track_ids: playlistTrackIds }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('reorder failed');
|
||||||
|
await this.reload();
|
||||||
|
} catch (err) {
|
||||||
|
if (Number(this.currentPlaylist()?.id) === playlistId) {
|
||||||
|
this.currentPlaylist().tracks = previous;
|
||||||
|
}
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startPointerReorder(event, idx) {
|
||||||
|
if (event.pointerType === 'mouse') return;
|
||||||
|
if (event.button && event.button !== 0) return;
|
||||||
|
const tracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (!this.isEditingCurrent() || idx < 0 || idx >= tracks.length || !tracks[idx]?.playlist_track_id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this._endPointerReorder(false);
|
||||||
|
this._dragIdx = idx;
|
||||||
|
this._dragOverIdx = idx;
|
||||||
|
const handle = event.currentTarget;
|
||||||
|
try {
|
||||||
|
handle?.setPointerCapture?.(event.pointerId);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
this._pointerDragMove = (moveEvent) => {
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
this._autoScrollDuringReorder(moveEvent.clientY);
|
||||||
|
const target = document
|
||||||
|
.elementFromPoint(moveEvent.clientX, moveEvent.clientY)
|
||||||
|
?.closest?.('.playlist-track-row[data-playlist-index]');
|
||||||
|
const targetIdx = Number(target?.dataset?.playlistIndex);
|
||||||
|
const currentTracks = this.currentPlaylist()?.tracks || [];
|
||||||
|
if (!Number.isInteger(targetIdx) || targetIdx < 0 || targetIdx >= currentTracks.length) return;
|
||||||
|
this._dragOverIdx = targetIdx;
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
if (targetIdx !== this._dragIdx) target.classList.add('drag-over');
|
||||||
|
};
|
||||||
|
|
||||||
|
this._pointerDragEnd = (endEvent) => {
|
||||||
|
try {
|
||||||
|
if (handle?.hasPointerCapture?.(endEvent.pointerId)) handle.releasePointerCapture(endEvent.pointerId);
|
||||||
|
} catch (_) {}
|
||||||
|
this._endPointerReorder(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', this._pointerDragMove, { passive: false });
|
||||||
|
window.addEventListener('pointerup', this._pointerDragEnd, { passive: false });
|
||||||
|
window.addEventListener('pointercancel', this._pointerDragEnd, { passive: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
_autoScrollDuringReorder(clientY) {
|
||||||
|
const scroller = document.getElementById('center-scroll');
|
||||||
|
if (!scroller) return;
|
||||||
|
const rect = scroller.getBoundingClientRect();
|
||||||
|
const edge = 52;
|
||||||
|
if (clientY < rect.top + edge) {
|
||||||
|
scroller.scrollTop -= Math.ceil((rect.top + edge - clientY) / 4);
|
||||||
|
} else if (clientY > rect.bottom - edge) {
|
||||||
|
scroller.scrollTop += Math.ceil((clientY - (rect.bottom - edge)) / 4);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_endPointerReorder(commit) {
|
||||||
|
if (this._pointerDragMove) window.removeEventListener('pointermove', this._pointerDragMove);
|
||||||
|
if (this._pointerDragEnd) {
|
||||||
|
window.removeEventListener('pointerup', this._pointerDragEnd);
|
||||||
|
window.removeEventListener('pointercancel', this._pointerDragEnd);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||||
|
const fromIdx = this._dragIdx;
|
||||||
|
const toIdx = this._dragOverIdx;
|
||||||
|
this._pointerDragMove = null;
|
||||||
|
this._pointerDragEnd = null;
|
||||||
|
this._dragIdx = null;
|
||||||
|
this._dragOverIdx = null;
|
||||||
|
if (commit && Number.isInteger(fromIdx) && Number.isInteger(toIdx) && fromIdx !== toIdx) {
|
||||||
|
this.moveCurrentTrack(fromIdx, toIdx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
showCreate() {
|
showCreate() {
|
||||||
this.modal = { mode: 'create', title: '' };
|
this.modal = { mode: 'create', title: '' };
|
||||||
},
|
},
|
||||||
|
|||||||
+65
-15
@@ -951,7 +951,23 @@
|
|||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
|
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
<div class="playlist-detail-heading">
|
||||||
|
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
||||||
|
<button class="release-action-btn secondary playlist-edit-toggle"
|
||||||
|
x-show="$store.playlists.canEditCurrent()"
|
||||||
|
x-cloak
|
||||||
|
@click="$store.playlists.toggleEditCurrent()"
|
||||||
|
:title="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'">
|
||||||
|
<svg x-show="!$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
||||||
|
<path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||||
|
</svg>
|
||||||
|
<svg x-show="$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 6L9 17l-5-5"/>
|
||||||
|
</svg>
|
||||||
|
<span x-text="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="playlist-detail-meta"
|
<div class="playlist-detail-meta"
|
||||||
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
|
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
|
||||||
<span x-show="$store.library.currentPlaylist.owner_name"
|
<span x-show="$store.library.currentPlaylist.owner_name"
|
||||||
@@ -963,27 +979,61 @@
|
|||||||
<template x-if="$store.library.currentPlaylist.description">
|
<template x-if="$store.library.currentPlaylist.description">
|
||||||
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
||||||
</template>
|
</template>
|
||||||
<div class="track-list-header">
|
<div class="track-list-header playlist-track-list-header"
|
||||||
|
:class="{ editing: $store.playlists.isEditingCurrent() }">
|
||||||
|
<span class="playlist-edit-cell"
|
||||||
|
x-show="$store.playlists.isEditingCurrent()"
|
||||||
|
x-cloak></span>
|
||||||
<span>#</span>
|
<span>#</span>
|
||||||
<span>{{ t.player_title }}</span>
|
<span>{{ t.player_title }}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
<span></span>
|
<span></span>
|
||||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.id">
|
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.playlist_track_id || (track.id + '-' + idx)">
|
||||||
<div class="track-row"
|
<div class="track-row playlist-track-row"
|
||||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
:data-playlist-index="idx"
|
||||||
@dblclick="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)">
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, editing: $store.playlists.isEditingCurrent(), dragging: $store.playlists._dragIdx === idx }"
|
||||||
|
:draggable="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||||
|
@dblclick="if (!$store.playlists.isEditingCurrent()) $store.queue.playRelease($store.library.currentPlaylist.tracks, idx)"
|
||||||
|
@dragstart="if (!$store.playlists.startDrag($event, idx)) $event.preventDefault()"
|
||||||
|
@dragend="$store.playlists.endDrag()"
|
||||||
|
@dragover.prevent="$store.playlists.dragOver($event, idx)"
|
||||||
|
@dragleave="$event.currentTarget.classList.remove('drag-over')"
|
||||||
|
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); $store.playlists.dropOn(idx)">
|
||||||
|
<button class="playlist-track-remove"
|
||||||
|
x-show="$store.playlists.isEditingCurrent()"
|
||||||
|
x-cloak
|
||||||
|
@click.stop="$store.playlists.removeCurrentTrack(track, idx)"
|
||||||
|
title="{{ t.player_remove }}"
|
||||||
|
aria-label="{{ t.player_remove }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<span class="track-num" x-text="idx + 1"></span>
|
<span class="track-num" x-text="idx + 1"></span>
|
||||||
<div class="track-info">
|
<div class="track-info playlist-track-info">
|
||||||
<div class="track-title" x-text="track.title"></div>
|
<button class="playlist-drag-handle"
|
||||||
<div class="track-artists-inline">
|
x-show="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
x-cloak
|
||||||
<span>
|
@mousedown.stop
|
||||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
@click.stop
|
||||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
@pointerdown.stop="$store.playlists.startPointerReorder($event, idx)"
|
||||||
</span>
|
title="{{ t.player_edit }}"
|
||||||
</template>
|
aria-label="{{ t.player_edit }}">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<div class="playlist-track-copy">
|
||||||
|
<div class="track-title" x-text="track.title"></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>
|
||||||
</div>
|
</div>
|
||||||
<span></span>
|
<span></span>
|
||||||
|
|||||||
@@ -489,6 +489,25 @@ button.user-stat:hover {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading .section-title {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-edit-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -735,6 +754,93 @@ button.user-stat:hover {
|
|||||||
.track-row:hover { background: var(--bg-hover); }
|
.track-row:hover { background: var(--bg-hover); }
|
||||||
.track-row.playing { color: var(--accent); }
|
.track-row.playing { color: var(--accent); }
|
||||||
.track-row.playing .track-num { color: var(--accent); }
|
.track-row.playing .track-num { color: var(--accent); }
|
||||||
|
.playlist-track-list-header.editing,
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 32px 40px minmax(0, 1fr) minmax(0, 1fr) 154px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.drag-over {
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-remove svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-copy {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle {
|
||||||
|
width: 24px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
cursor: grab;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
padding: 0;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-drag-handle svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.track-row.shared-target {
|
.track-row.shared-target {
|
||||||
background: rgba(29, 185, 84, 0.12);
|
background: rgba(29, 185, 84, 0.12);
|
||||||
box-shadow: inset 3px 0 0 var(--accent);
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
@@ -4233,6 +4339,16 @@ button.user-stat:hover {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-track-list-header.editing,
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 30px 32px minmax(0, 1fr) auto 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-list-header.editing span:nth-child(4),
|
||||||
|
.playlist-track-row.editing > span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.history-table-head,
|
.history-table-head,
|
||||||
.history-row.track-row {
|
.history-row.track-row {
|
||||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||||
@@ -5158,6 +5274,26 @@ button.user-stat:hover {
|
|||||||
padding: 10px 6px;
|
padding: 10px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-detail-heading {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-edit-toggle {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing {
|
||||||
|
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-track-row.editing .track-num,
|
||||||
|
.playlist-track-row.editing > span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.track-row > span:nth-child(3),
|
.track-row > span:nth-child(3),
|
||||||
.track-duration {
|
.track-duration {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user