This commit is contained in:
Generated
+1
-1
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ pub(super) struct TrackItem {
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) artists: Vec<ArtistRef>,
|
||||
pub(super) featured_artists: Vec<ArtistRef>,
|
||||
pub(super) release_year: Option<i32>,
|
||||
pub(super) cover_url: Option<String>,
|
||||
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<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) artists: Vec<ArtistRef>,
|
||||
pub(super) featured_artists: Vec<ArtistRef>,
|
||||
|
||||
+14
-2
@@ -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,
|
||||
|
||||
@@ -37,6 +37,7 @@ pub(super) struct TrackRow {
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) release_year: Option<i32>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
@@ -102,6 +103,7 @@ pub(super) struct PlaylistTrackRow {
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) release_year: Option<i32>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
@@ -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<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
@@ -155,6 +158,7 @@ pub(super) struct SearchTrackRow {
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) release_year: Option<i32>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
|
||||
+416
-16
@@ -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 {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="$store.mobile.libraryOpen">
|
||||
<div class="mobile-library-backdrop" @click.self="$store.mobile.closeLibrary()" x-cloak>
|
||||
<aside class="mobile-library-drawer">
|
||||
<div class="mobile-drawer-head">
|
||||
<div>
|
||||
<div class="mobile-drawer-title">Library</div>
|
||||
<div class="playlist-count">Playlists and followed artists</div>
|
||||
</div>
|
||||
<button class="mobile-list-action" @click="$store.mobile.closeLibrary()" title="Close">
|
||||
<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>
|
||||
</div>
|
||||
<div class="mobile-drawer-body">
|
||||
<div class="mobile-drawer-section">
|
||||
<div class="sidebar-nav-item"
|
||||
:class="{ active: $store.library.view === 'artists' }"
|
||||
@click="$store.library.goArtists(); $store.mobile.closeLibrary()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
Artists
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-drawer-section">
|
||||
<div class="sidebar-section-title">
|
||||
Following
|
||||
<span x-show="$store.follows.artists.length > 0"
|
||||
x-text="'(' + $store.follows.artists.length + ')'"></span>
|
||||
</div>
|
||||
<template x-if="$store.follows.artists.length === 0">
|
||||
<div class="following-empty">No followed artists</div>
|
||||
</template>
|
||||
<div class="following-list" x-show="$store.follows.artists.length > 0" x-cloak>
|
||||
<template x-for="artist in $store.follows.artists" :key="'mobile-follow-' + artist.id">
|
||||
<div class="mobile-list-row">
|
||||
<div class="following-artist"
|
||||
:class="{ active: $store.library.currentArtist && $store.library.currentArtist.id === artist.id }"
|
||||
@click="$store.library.openArtist(artist.id); $store.mobile.closeLibrary()">
|
||||
<div class="following-avatar">
|
||||
<template x-if="artist.image_url">
|
||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!artist.image_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div class="following-name" x-text="artist.name"></div>
|
||||
</div>
|
||||
<button class="mobile-list-action"
|
||||
@click.stop="$store.follows.toggle(artist.id)"
|
||||
title="Unfollow artist">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<line x1="17" y1="11" x2="23" y2="11"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-drawer-section">
|
||||
<div class="sidebar-section-title">Playlists</div>
|
||||
<template x-for="pl in $store.playlists.regularList()" :key="'mobile-playlist-' + pl.id">
|
||||
<div class="playlist-item-row">
|
||||
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id); $store.mobile.closeLibrary()">
|
||||
<template x-if="pl.kind === 'likes'">
|
||||
<span style="display:flex;align-items:center;gap:6px">
|
||||
<svg viewBox="0 0 24 24" fill="var(--accent)" stroke="none" width="14" height="14"><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>
|
||||
<span x-text="pl.title"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="pl.kind !== 'likes'">
|
||||
<span x-text="pl.title"></span>
|
||||
</template>
|
||||
<span class="playlist-count" x-text="pl.track_count + ' tracks'"></span>
|
||||
</div>
|
||||
<template x-if="pl.is_own && pl.kind === 'user'">
|
||||
<div class="playlist-item-actions">
|
||||
<button class="playlist-action-btn" @click.stop="$store.mobile.closeLibrary(); $store.playlists.startRename(pl)" title="Rename">
|
||||
<svg 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>
|
||||
</button>
|
||||
<button class="playlist-action-btn" @click.stop="$store.playlists.deletePlaylist(pl.id)" title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<button class="sidebar-create-btn" @click="$store.mobile.closeLibrary(); $store.playlists.showCreate()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Playlist
|
||||
</button>
|
||||
<template x-if="$store.playlists.publishedList().length > 0">
|
||||
<div class="playlist-public-section">
|
||||
<div class="sidebar-section-title playlist-subtitle">Published Playlists</div>
|
||||
<template x-for="pl in $store.playlists.publishedList()" :key="'mobile-published-' + pl.id">
|
||||
<div class="playlist-item-row">
|
||||
<div class="playlist-item playlist-item-public" @click="$store.library.openPlaylist(pl.id); $store.mobile.closeLibrary()">
|
||||
<div class="playlist-title-line">
|
||||
<span class="playlist-title-text" x-text="pl.title"></span>
|
||||
<span class="playlist-public-badge">Public</span>
|
||||
</div>
|
||||
<div class="playlist-meta-line">
|
||||
<span class="playlist-owner" x-show="pl.owner_name" x-text="'by ' + pl.owner_name"></span>
|
||||
<span x-show="pl.owner_name">·</span>
|
||||
<span x-text="pl.track_count + ' tracks'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Center Content -->
|
||||
<div class="center-content" id="center-scroll">
|
||||
<!-- Search / account bar -->
|
||||
<div class="content-topbar" @click.outside="$store.user.menuOpen = false">
|
||||
<button class="mobile-library-btn"
|
||||
@click="$store.user.menuOpen = false; $store.mobile.toggleLibrary()"
|
||||
title="Library">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20"/>
|
||||
<path d="M4 4.5A2.5 2.5 0 016.5 2H20v20H6.5A2.5 2.5 0 014 19.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="search-bar">
|
||||
<span class="search-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||
<input id="search-input" type="text" placeholder="Search artists, releases, tracks..."
|
||||
@@ -2491,7 +2820,7 @@ button.user-stat:hover {
|
||||
<button class="mobile-account-chip"
|
||||
x-show="$store.user.profile"
|
||||
x-cloak
|
||||
@click="$store.user.menuOpen = !$store.user.menuOpen"
|
||||
@click="$store.mobile.closeLibrary(); $store.user.menuOpen = !$store.user.menuOpen"
|
||||
:title="$store.user.profile?.name || 'Account'">
|
||||
<span class="user-avatar" x-text="$store.user.initials()"></span>
|
||||
<span class="mobile-account-name" x-text="$store.user.profile?.name || ''"></span>
|
||||
@@ -2587,7 +2916,7 @@ button.user-stat:hover {
|
||||
<template x-if="!release.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 class="card-info-btn" @click.stop :title="$store.library.releaseInfo(release)" aria-label="Release info">
|
||||
<button class="card-info-btn" @click.stop="$store.info.open('Release info', $store.library.releaseInfo(release))" :title="$store.library.releaseInfo(release)" aria-label="Release info">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2630,7 +2959,7 @@ button.user-stat:hover {
|
||||
</div>
|
||||
<span></span>
|
||||
<div class="track-actions">
|
||||
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||
<button class="track-action-btn info-btn" @click.stop="$store.info.open('Track info', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn play-btn" @click.stop="$store.library.playSearchTrack(idx)" title="Play">
|
||||
@@ -2752,7 +3081,7 @@ button.user-stat:hover {
|
||||
<template x-if="!release.cover_url">
|
||||
<span class="placeholder-icon"><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></span>
|
||||
</template>
|
||||
<button class="card-info-btn" @click.stop :title="$store.library.releaseInfo(release)" aria-label="Release info">
|
||||
<button class="card-info-btn" @click.stop="$store.info.open('Release info', $store.library.releaseInfo(release))" :title="$store.library.releaseInfo(release)" aria-label="Release info">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
</button>
|
||||
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
|
||||
@@ -2804,7 +3133,7 @@ button.user-stat:hover {
|
||||
</div>
|
||||
<span></span>
|
||||
<div class="track-actions">
|
||||
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||
<button class="track-action-btn info-btn" @click.stop="$store.info.open('Track info', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="Play">
|
||||
@@ -2866,7 +3195,7 @@ button.user-stat:hover {
|
||||
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
||||
<div class="release-actions">
|
||||
<button class="release-action-btn secondary"
|
||||
@click.stop
|
||||
@click.stop="$store.info.open('Release info', $store.library.releaseInfo($store.library.currentRelease))"
|
||||
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
||||
aria-label="Release info">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
@@ -2919,7 +3248,7 @@ button.user-stat:hover {
|
||||
</div>
|
||||
<span></span>
|
||||
<div class="track-actions">
|
||||
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||
<button class="track-action-btn info-btn" @click.stop="$store.info.open('Track info', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentRelease.tracks, idx)" title="Play">
|
||||
@@ -2989,7 +3318,7 @@ button.user-stat:hover {
|
||||
</div>
|
||||
<span></span>
|
||||
<div class="track-actions">
|
||||
<button class="track-action-btn info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||
<button class="track-action-btn info-btn" @click.stop="$store.info.open('Track info', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)" title="Play">
|
||||
@@ -3061,7 +3390,7 @@ button.user-stat:hover {
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue-track-actions">
|
||||
<button class="queue-track-remove info-btn" @click.stop :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||
<button class="queue-track-remove info-btn" @click.stop="$store.info.open('Track info', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="Track info">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
</button>
|
||||
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="Remove">
|
||||
@@ -3096,6 +3425,9 @@ button.user-stat:hover {
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="$store.player.currentTrack.release_year">
|
||||
<span class="player-release-year" x-text="' · ' + $store.player.currentTrack.release_year"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3154,8 +3486,12 @@ button.user-stat:hover {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
|
||||
</template>
|
||||
</button>
|
||||
<div class="volume-slider" @click="$store.player.setVolumeFromClick($event)">
|
||||
<div class="volume-slider-fill" :style="'width:' + ($store.player.volume * 100) + '%'"></div>
|
||||
<div class="volume-slider"
|
||||
@pointerdown.prevent="$store.player.startVolumeDrag($event)"
|
||||
aria-label="Volume">
|
||||
<div class="volume-slider-fill" :style="'width:' + ($store.player.volume * 100) + '%'">
|
||||
<div class="volume-slider-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="queue-toggle-btn" :class="{ active: $store.queue.visible }" @click="$store.queue.visible = !$store.queue.visible" title="Queue">
|
||||
@@ -3164,6 +3500,24 @@ button.user-stat:hover {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Modal -->
|
||||
<template x-if="$store.info.modal">
|
||||
<div class="modal-overlay" @click.self="$store.info.close()">
|
||||
<div class="modal-box info-modal">
|
||||
<div class="info-modal-head">
|
||||
<h3 x-text="$store.info.modal.title"></h3>
|
||||
<button class="mobile-list-action" @click="$store.info.close()" title="Close">
|
||||
<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>
|
||||
</div>
|
||||
<pre class="info-modal-body" x-text="$store.info.modal.body"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Create / Rename Playlist Modal -->
|
||||
<template x-if="$store.playlists.modal">
|
||||
<div class="modal-overlay" @click.self="$store.playlists.modal = null">
|
||||
@@ -3358,6 +3712,30 @@ document.addEventListener('alpine:init', () => {
|
||||
const audio = new Audio();
|
||||
audio.preload = 'auto';
|
||||
|
||||
Alpine.store('mobile', {
|
||||
libraryOpen: false,
|
||||
toggleLibrary() {
|
||||
this.libraryOpen = !this.libraryOpen;
|
||||
if (this.libraryOpen) Alpine.store('user').menuOpen = false;
|
||||
},
|
||||
closeLibrary() {
|
||||
this.libraryOpen = false;
|
||||
},
|
||||
});
|
||||
|
||||
Alpine.store('info', {
|
||||
modal: null,
|
||||
open(title, body) {
|
||||
this.modal = {
|
||||
title: title || 'Info',
|
||||
body: body || 'No details available.',
|
||||
};
|
||||
},
|
||||
close() {
|
||||
this.modal = null;
|
||||
},
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// User store
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -3617,13 +3995,34 @@ document.addEventListener('alpine:init', () => {
|
||||
audio.volume = this.volume;
|
||||
},
|
||||
|
||||
setVolumeFromClick(event) {
|
||||
const bar = event.currentTarget;
|
||||
_setVolumeFromClientX(clientX, bar) {
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const pct = (event.clientX - rect.left) / rect.width;
|
||||
const pct = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
|
||||
this.setVolume(pct);
|
||||
},
|
||||
|
||||
setVolumeFromClick(event) {
|
||||
this._setVolumeFromClientX(event.clientX, event.currentTarget);
|
||||
},
|
||||
|
||||
startVolumeDrag(event) {
|
||||
const bar = event.currentTarget;
|
||||
this._setVolumeFromClientX(event.clientX, bar);
|
||||
bar.setPointerCapture?.(event.pointerId);
|
||||
|
||||
const move = (e) => {
|
||||
this._setVolumeFromClientX(e.clientX, bar);
|
||||
};
|
||||
const stop = () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
window.removeEventListener('pointercancel', stop);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', stop);
|
||||
window.addEventListener('pointercancel', stop);
|
||||
},
|
||||
|
||||
toggleMute() {
|
||||
if (this.volume > 0) {
|
||||
this._prevVolume = this.volume;
|
||||
@@ -4033,6 +4432,7 @@ document.addEventListener('alpine:init', () => {
|
||||
const lines = [
|
||||
track.title || 'Unknown track',
|
||||
`Artists: ${artists}`,
|
||||
`Release year: ${track.release_year || 'unknown'}`,
|
||||
`Duration: ${formatTime(track.duration_seconds)}`,
|
||||
`Audio: ${audio}`,
|
||||
`Size: ${this.bytes(track.file_size_bytes)}`,
|
||||
|
||||
Reference in New Issue
Block a user