From d1370c6a2833d26d83d37a8f50cf928e3c697294 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Thu, 23 Jul 2026 17:24:28 +0300 Subject: [PATCH] Improved UI. Added playlist manager --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/i18n/phrases.rs | 1 + src/player/dto.rs | 9 +- src/player/mod.rs | 212 +++++++++++++++++++++++++++++++-- src/player/queries.rs | 8 +- src/player/rows.rs | 1 + templates/player/scripts.html | 215 ++++++++++++++++++++++++++++++++++ templates/player/shell.html | 80 ++++++++++--- templates/player/styles.html | 136 +++++++++++++++++++++ 10 files changed, 635 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 176d20b..3945724 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1845,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.6.7-fd" +version = "0.7.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 55b1a2e..32b4a4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.7.0" +version = "0.7.1" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 1805e88..700fdbb 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -309,6 +309,7 @@ translations! { player_cancel: "Cancel" , "Отмена"; player_create: "Create" , "Создать"; player_save: "Save" , "Сохранить"; + player_done: "Done" , "Готово"; player_delete: "Delete" , "Удалить"; player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?"; player_rename: "Rename" , "Переименовать"; diff --git a/src/player/dto.rs b/src/player/dto.rs index 23d29a9..6225f42 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -74,6 +74,13 @@ pub(super) struct TrackItem { pub(super) lastfm_updated_at: Option, } +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct PlaylistTrackItem { + pub(super) playlist_track_id: Option, + #[serde(flatten)] + pub(super) track: TrackItem, +} + #[derive(Debug, Serialize, JsonSchema)] pub(super) struct ArtistAppearanceTrack { pub(super) id: i64, @@ -286,7 +293,7 @@ pub(super) struct PlaylistDetail { pub(super) is_public: bool, pub(super) is_saved: bool, pub(super) kind: String, - pub(super) tracks: Vec, + pub(super) tracks: Vec, } #[derive(Debug, Serialize, JsonSchema)] diff --git a/src/player/mod.rs b/src/player/mod.rs index 6b120e4..d54ed56 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -3414,6 +3414,7 @@ async fn artist_detail_handler( let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>( r#"SELECT * FROM ( 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.duration_seconds, t.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()))?; 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, r.cover_file_id as release_cover_file_id, r.id as release_id, @@ -3687,7 +3689,8 @@ async fn playlist_detail_handler( }; 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, r.cover_file_id as release_cover_file_id, r.id as release_id, @@ -3715,7 +3718,7 @@ async fn playlist_detail_handler( .await .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 { id: info.id, @@ -3813,13 +3816,34 @@ async fn build_track_items( .collect()) } +async fn build_playlist_track_items( + tracks: Vec, + pool: &sqlx::PgPool, +) -> cot::Result> { + let playlist_track_ids = tracks + .iter() + .map(|track| track.playlist_track_id) + .collect::>(); + 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> { if ids.is_empty() { return Ok(Vec::new()); } 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, r.cover_file_id as release_cover_file_id, r.id as release_id, @@ -3976,7 +4000,8 @@ async fn likes_playlist_handler( pool: &sqlx::PgPool, ) -> cot::Result { 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, r.cover_file_id as release_cover_file_id, r.id as release_id, @@ -4004,7 +4029,14 @@ async fn likes_playlist_handler( .await .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 { id: -1, @@ -5609,6 +5641,110 @@ async fn add_tracks_to_playlist_handler( 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, + Json(body): Json, +) -> cot::Result { + 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::>(); + let unique_requested = requested_ids.iter().copied().collect::>(); + 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::>(); + 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 // --------------------------------------------------------------------------- @@ -5638,12 +5774,29 @@ async fn remove_track_from_playlist_handler( return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist")); } - sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2") - .bind(playlist_id) - .bind(body.track_id) - .execute(pool) - .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + match (body.playlist_track_id, body.track_id) { + (Some(playlist_track_id), _) => { + sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND id = $2") + .bind(playlist_id) + .bind(playlist_track_id) + .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 sqlx::query( @@ -5660,6 +5813,14 @@ async fn remove_track_from_playlist_handler( .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() } @@ -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, + json: Json| { + 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({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); diff --git a/src/player/queries.rs b/src/player/queries.rs index 426360d..268e960 100644 --- a/src/player/queries.rs +++ b/src/player/queries.rs @@ -42,7 +42,13 @@ pub(super) struct AddTracksRequest { #[derive(Debug, Deserialize)] pub(super) struct RemoveTrackRequest { - pub(super) track_id: i64, + pub(super) track_id: Option, + pub(super) playlist_track_id: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ReorderPlaylistRequest { + pub(super) playlist_track_ids: Vec, } #[derive(Debug, Deserialize)] diff --git a/src/player/rows.rs b/src/player/rows.rs index 9bac3a4..4496b7c 100644 --- a/src/player/rows.rs +++ b/src/player/rows.rs @@ -95,6 +95,7 @@ pub(super) struct PlaylistInfoRow { #[derive(sqlx::FromRow)] pub(super) struct PlaylistTrackRow { + pub(super) playlist_track_id: Option, pub(super) id: i64, pub(super) title: String, pub(super) track_number: Option, diff --git a/templates/player/scripts.html b/templates/player/scripts.html index 53e22ba..b0ff358 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -2745,6 +2745,7 @@ document.addEventListener('alpine:init', () => { async openPlaylist(id, options = {}) { this._beginNavigation('#playlist/' + id, options); + Alpine.store('playlists')?.stopEdit?.(); this.view = 'playlist_detail'; this.currentPlaylist = null; try { @@ -2756,6 +2757,7 @@ document.addEventListener('alpine:init', () => { }, showSharedPlaylist(share, options = {}) { + Alpine.store('playlists')?.stopEdit?.(); this._saveScrollPosition(this._activeHash); this.searchQuery = ''; this.searchResults = null; @@ -4301,6 +4303,11 @@ document.addEventListener('alpine:init', () => { list: [], modal: null, // { mode: 'create'|'rename', title: '', id?: number } picker: null, // { trackIds: [1,2,3] } + editingPlaylistId: null, + _dragIdx: null, + _dragOverIdx: null, + _pointerDragMove: null, + _pointerDragEnd: null, init() { this.reload(); @@ -4333,6 +4340,214 @@ document.addEventListener('alpine:init', () => { 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() { this.modal = { mode: 'create', title: '' }; }, diff --git a/templates/player/shell.html b/templates/player/shell.html index a3b5d27..0f3f6a2 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -951,7 +951,23 @@ / -

+
+

+ +

-
+
+ # {{ t.player_title }} {{ t.player_duration }}
-