From 4170ce269d039baa55534534fde34eb1a6bdbf18 Mon Sep 17 00:00:00 2001 From: AB Date: Tue, 26 May 2026 11:15:27 +0300 Subject: [PATCH] Fixed mobile UI --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/player/dto.rs | 2 + src/player/mod.rs | 16 +- src/player/rows.rs | 4 + templates/player.html | 432 ++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 438 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13d5ff7..944ef01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.9" +version = "0.1.10" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index b8a6e9a..f8e7556 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.9" +version = "0.1.10" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/player/dto.rs b/src/player/dto.rs index 0202245..fa4bf63 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -55,6 +55,7 @@ pub(super) struct TrackItem { pub(super) duration_seconds: f64, pub(super) artists: Vec, pub(super) featured_artists: Vec, + pub(super) release_year: Option, pub(super) cover_url: Option, pub(super) stream_url: String, pub(super) uploader_name: String, @@ -71,6 +72,7 @@ pub(super) struct ArtistAppearanceTrack { pub(super) title: String, pub(super) release_id: i64, pub(super) release_title: String, + pub(super) release_year: Option, pub(super) duration_seconds: f64, pub(super) artists: Vec, pub(super) featured_artists: Vec, diff --git a/src/player/mod.rs b/src/player/mod.rs index 78f5c6d..c5dd1de 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -265,6 +265,7 @@ async fn artist_detail_handler( t.title::text AS title, r.id AS release_id, r.title::text AS release_title, + r.year AS release_year, t.duration_seconds, t.cover_file_id, r.cover_file_id AS release_cover_file_id, @@ -338,6 +339,7 @@ async fn artist_detail_handler( title: t.title, release_id: t.release_id, release_title: t.release_title, + release_year: t.release_year, duration_seconds: t.duration_seconds, artists: featured_main_artists.remove(&tid).unwrap_or_default(), featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(), @@ -412,6 +414,7 @@ async fn release_detail_handler( r#"SELECT 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.year as release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, mf.audio_bitrate, @@ -484,6 +487,7 @@ async fn release_detail_handler( duration_seconds: t.duration_seconds, artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), + release_year: t.release_year, cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), stream_url: format!("/api/player/stream/{tid}"), uploader_name: t.uploader_name, @@ -640,6 +644,7 @@ async fn playlist_detail_handler( r#"SELECT 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.year as release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, mf.audio_bitrate, @@ -732,6 +737,7 @@ async fn build_track_items( duration_seconds: t.duration_seconds, artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), + release_year: t.release_year, cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), stream_url: format!("/api/player/stream/{tid}"), uploader_name: t.uploader_name, @@ -754,6 +760,7 @@ async fn likes_playlist_handler( r#"SELECT 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.year as release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, mf.audio_bitrate, @@ -1221,6 +1228,7 @@ async fn search_handler( r#"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id AS release_cover_file_id, + rel.year AS release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, mf.audio_bitrate, @@ -1290,11 +1298,12 @@ async fn search_handler( let t = sqlx::query_as::<_, SearchTrackRow>( r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id, - release_cover_file_id, uploader_name, audio_format, audio_bitrate, + release_cover_file_id, release_year, uploader_name, audio_format, audio_bitrate, audio_sample_rate, audio_bit_depth, file_size_bytes FROM ( SELECT t.id, t.title::text AS title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id AS release_cover_file_id, + rel.year AS release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, mf.audio_bitrate, @@ -1313,7 +1322,7 @@ async fn search_handler( ) t JOIN furumusic__release rel ON rel.id = t.release_id LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id - GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id, + GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id, rel.year, mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes ORDER BY similarity DESC LIMIT $2 @@ -1409,6 +1418,7 @@ async fn search_handler( duration_seconds: t.duration_seconds, artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), + release_year: t.release_year, cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), stream_url: format!("/api/player/stream/{tid}"), uploader_name: t.uploader_name, @@ -1975,6 +1985,7 @@ async fn tracks_by_ids_handler( r#"SELECT 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.year as release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, mf.audio_bitrate, @@ -2046,6 +2057,7 @@ async fn tracks_by_ids_handler( duration_seconds: t.duration_seconds, artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), + release_year: t.release_year, cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), stream_url: format!("/api/player/stream/{tid}"), uploader_name: t.uploader_name, diff --git a/src/player/rows.rs b/src/player/rows.rs index 5a680ff..fdb8082 100644 --- a/src/player/rows.rs +++ b/src/player/rows.rs @@ -37,6 +37,7 @@ pub(super) struct TrackRow { pub(super) duration_seconds: f64, pub(super) cover_file_id: Option, pub(super) release_cover_file_id: Option, + pub(super) release_year: Option, pub(super) uploader_name: String, pub(super) audio_format: Option, pub(super) audio_bitrate: Option, @@ -102,6 +103,7 @@ pub(super) struct PlaylistTrackRow { pub(super) duration_seconds: f64, pub(super) cover_file_id: Option, pub(super) release_cover_file_id: Option, + pub(super) release_year: Option, pub(super) uploader_name: String, pub(super) audio_format: Option, pub(super) audio_bitrate: Option, @@ -116,6 +118,7 @@ pub(super) struct AppearanceTrackRow { pub(super) title: String, pub(super) release_id: i64, pub(super) release_title: String, + pub(super) release_year: Option, pub(super) duration_seconds: f64, pub(super) cover_file_id: Option, pub(super) release_cover_file_id: Option, @@ -155,6 +158,7 @@ pub(super) struct SearchTrackRow { pub(super) duration_seconds: f64, pub(super) cover_file_id: Option, pub(super) release_cover_file_id: Option, + pub(super) release_year: Option, pub(super) uploader_name: String, pub(super) audio_format: Option, pub(super) audio_bitrate: Option, diff --git a/templates/player.html b/templates/player.html index 5c12cdf..845f73e 100644 --- a/templates/player.html +++ b/templates/player.html @@ -692,7 +692,7 @@ button.user-stat:hover { border: 1px solid var(--border-color); background: rgba(18,18,18,0.78); color: var(--text-primary); - cursor: help; + cursor: pointer; display: flex; align-items: center; justify-content: center; @@ -1134,16 +1134,36 @@ button.user-stat:hover { border-radius: 2px; cursor: pointer; position: relative; + touch-action: none; } .volume-slider-fill { height: 100%; background: var(--text-primary); border-radius: 2px; + position: relative; } .volume-slider:hover .volume-slider-fill { background: var(--accent); } +.volume-slider-thumb { + position: absolute; + right: -6px; + top: 50%; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--text-primary); + transform: translateY(-50%); + opacity: 0; + transition: opacity 0.15s; +} + +.volume-slider:hover .volume-slider-thumb, +.volume-slider:active .volume-slider-thumb { + opacity: 1; +} + .queue-toggle-btn { background: none; border: none; @@ -1224,8 +1244,30 @@ button.user-stat:hover { cursor: pointer; } +.mobile-library-btn { + display: none; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-elevated); + color: var(--text-secondary); + cursor: pointer; + flex: 0 0 auto; + transition: background 0.15s, color 0.15s; +} + +.mobile-library-btn:hover, .mobile-account-chip:hover { background: var(--bg-hover); + color: var(--text-primary); +} + +.mobile-library-btn svg { + width: 19px; + height: 19px; } .mobile-account-popover { @@ -1293,6 +1335,92 @@ button.user-stat:hover { text-align: left; } +.mobile-library-backdrop { + position: fixed; + inset: 0; + z-index: 70; + display: none; + background: rgba(0,0,0,0.58); +} + +.mobile-library-drawer { + width: min(360px, calc(100vw - 28px)); + height: calc(100dvh - var(--player-bar-space) - 20px); + margin: 10px 0 0 10px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-secondary); + box-shadow: 0 18px 60px rgba(0,0,0,0.55); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.mobile-drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 14px 12px; + border-bottom: 1px solid var(--border-color); +} + +.mobile-drawer-title { + font-size: 15px; + font-weight: 800; +} + +.mobile-drawer-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 8px; +} + +.mobile-drawer-section { + padding: 6px 0 10px; + border-top: 1px solid var(--border-color); +} + +.mobile-drawer-section:first-child { + border-top: 0; +} + +.mobile-list-row { + display: flex; + align-items: center; + gap: 6px; +} + +.mobile-list-row .following-artist, +.mobile-list-row .playlist-item { + flex: 1; + min-width: 0; +} + +.mobile-list-action { + flex: 0 0 auto; + width: 30px; + height: 30px; + border-radius: 6px; + background: transparent; + color: var(--text-subdued); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.mobile-list-action:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.mobile-list-action svg { + width: 15px; + height: 15px; +} + .search-bar input { width: 100%; padding: 10px 40px 10px 40px; @@ -1529,6 +1657,37 @@ button.user-stat:hover { .modal-playlist-item:hover { background: var(--bg-hover); color: var(--text-primary); } .modal-playlist-item svg { width: 16px; height: 16px; flex-shrink: 0; } +.info-modal { + max-width: min(520px, calc(100vw - 24px)); +} + +.info-modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.info-modal-head h3 { + margin: 0; +} + +.info-modal-body { + margin: 0; + padding: 14px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + overflow: auto; + max-height: min(58dvh, 520px); +} + .modal-btn { padding: 8px 16px; border-radius: 20px; @@ -1904,11 +2063,19 @@ button.user-stat:hover { flex: 1 1 auto; } + .mobile-library-btn { + display: flex; + } + .mobile-account-chip { display: flex; flex: 0 0 auto; } + .mobile-library-backdrop { + display: block; + } + .version-chip { display: none; } @@ -2053,7 +2220,21 @@ button.user-stat:hover { } .volume-control { - display: none; + display: flex; + } + + .volume-slider { + width: 74px; + height: 6px; + border-radius: 999px; + } + + .volume-slider-fill { + border-radius: 999px; + } + + .volume-slider-thumb { + opacity: 1; } .queue-toggle-btn { @@ -2102,6 +2283,12 @@ button.user-stat:hover { display: none; } + .mobile-library-drawer { + width: calc(100vw - 16px); + margin-left: 8px; + height: calc(100dvh - var(--player-bar-space) - 16px); + } + .mobile-account-popover { right: 8px; top: 64px; @@ -2296,6 +2483,18 @@ button.user-stat:hover { .player-track-artist { font-size: 10px; } .player-buttons { gap: 10px; } + .volume-control { + gap: 4px; + } + + .volume-btn { + padding: 5px; + } + + .volume-slider { + width: 58px; + } + .player-btn { min-width: 30px; min-height: 30px; @@ -2459,10 +2658,140 @@ button.user-stat:hover { + +
+ @@ -2630,7 +2959,7 @@ button.user-stat:hover {
-
-
-
-
-
@@ -3154,8 +3486,12 @@ button.user-stat:hover { -
-
+
+
+
+