e3823ce470
Add an end-to-end and unit test suite for the SvelteKit frontend:
- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.
Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
setup so storage behaves identically across Node versions (Node 26 ships a
native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
so the dev shell and CI run the same toolchain versions.
Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
(cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
existing file id) instead of the stale 409 expectation.
Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
1138 lines
33 KiB
Svelte
1138 lines
33 KiB
Svelte
<script lang="ts">
|
|
// Route-scoped styles: kept off the global critical path (Vite code-splits
|
|
// this into the /music route chunk, loaded only when this page renders).
|
|
import '$lib/styles/ported/music.css';
|
|
import { useSelection } from '$lib/composables/useSelection.svelte';
|
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
|
import { onMount } from 'svelte';
|
|
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
|
import {
|
|
addTracks,
|
|
createPlaylist,
|
|
deletePlaylist,
|
|
listPlaylists,
|
|
listShares,
|
|
listTracks,
|
|
removeShare,
|
|
removeTrack,
|
|
renamePlaylist,
|
|
reorderTracks,
|
|
sharePlaylist,
|
|
updatePlaylist,
|
|
uploadCoverImage,
|
|
type MusicShare,
|
|
type Playlist,
|
|
type PlaylistItem
|
|
} from '$lib/api/endpoints/music';
|
|
import { searchFiles } from '$lib/api/endpoints/search';
|
|
import type { FileItem } from '$lib/api/types';
|
|
import Icon from '$lib/icons/Icon.svelte';
|
|
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
|
import { t } from '$lib/i18n/index.svelte';
|
|
import { ui } from '$lib/stores/ui.svelte';
|
|
|
|
let playlists = $state<Playlist[]>([]);
|
|
let current = $state<Playlist | null>(null);
|
|
let tracks = $state<PlaylistItem[]>([]);
|
|
let loading = $state(false);
|
|
let error = $state<string | null>(null);
|
|
|
|
let dragIndex = $state<number | null>(null);
|
|
|
|
// ── Player (independent global queue) ──────────────────────────────────────
|
|
let audio = $state<HTMLAudioElement | null>(null);
|
|
/** Playback queue — independent of the visible `tracks` list. */
|
|
let queue = $state<PlaylistItem[]>([]);
|
|
let currentIndex = $state(-1);
|
|
let playing = $state(false);
|
|
let currentTime = $state(0);
|
|
let duration = $state(0);
|
|
let volume = $state(0.7);
|
|
let muted = $state(false);
|
|
let shuffle = $state(false);
|
|
let repeat = $state<'none' | 'all' | 'one'>('none');
|
|
let queueOpen = $state(false);
|
|
|
|
const currentTrack = $derived(currentIndex >= 0 ? (queue[currentIndex] ?? null) : null);
|
|
|
|
function fmtTime(s: number | null | undefined): string {
|
|
if (s == null || !Number.isFinite(s)) return '0:00';
|
|
const m = Math.floor(s / 60);
|
|
const sec = Math.floor(s % 60);
|
|
return `${m}:${sec.toString().padStart(2, '0')}`;
|
|
}
|
|
function fmtDuration(s: number | null | undefined): string {
|
|
return s ? fmtTime(s) : '-';
|
|
}
|
|
|
|
async function loadPlaylists() {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
playlists = await listPlaylists();
|
|
if (!current && playlists.length > 0) await select(playlists[0]);
|
|
} catch (e) {
|
|
error = errorMessage(e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function select(p: Playlist) {
|
|
current = p;
|
|
// NOTE: do NOT touch the player here — browsing a playlist must not stop playback.
|
|
try {
|
|
tracks = await listTracks(p.id);
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
async function onCreate() {
|
|
const name = await promptDialog({
|
|
title: t('music.new_playlist', 'New playlist'),
|
|
confirmText: t('common.create', 'Create')
|
|
});
|
|
if (!name) return;
|
|
try {
|
|
const p = await createPlaylist(name);
|
|
playlists = [p, ...playlists];
|
|
await select(p);
|
|
ui.notify(t('music.created', { name: p.name }, 'Created “{{name}}”.'), 'success');
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
async function onRenamePlaylist() {
|
|
if (!current) return;
|
|
const name = await promptDialog({
|
|
title: t('music.rename_playlist', 'Rename playlist'),
|
|
defaultValue: current.name,
|
|
confirmText: t('common.save', 'Save')
|
|
});
|
|
if (!name || name === current.name) return;
|
|
try {
|
|
await renamePlaylist(current.id, name);
|
|
current.name = name;
|
|
playlists = playlists.map((p) => (p.id === current!.id ? { ...p, name } : p));
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
async function onEditDescription() {
|
|
if (!current) return;
|
|
const desc = await promptDialog({
|
|
title: t('music.edit_description', 'Edit description'),
|
|
defaultValue: current.description ?? '',
|
|
confirmText: t('common.save', 'Save')
|
|
});
|
|
if (desc === null) return;
|
|
try {
|
|
await updatePlaylist(current.id, { description: desc || null });
|
|
current.description = desc || null;
|
|
playlists = playlists.map((p) =>
|
|
p.id === current!.id ? { ...p, description: desc || null } : p
|
|
);
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
async function onDelete(p: Playlist) {
|
|
const ok = await confirmDialog({
|
|
title: t('music.delete_playlist', 'Delete playlist'),
|
|
message: t('music.confirm_delete', { name: p.name }, 'Delete playlist "{{name}}"?'),
|
|
confirmText: t('common.delete', 'Delete'),
|
|
danger: true
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await deletePlaylist(p.id);
|
|
playlists = playlists.filter((x) => x.id !== p.id);
|
|
if (current?.id === p.id) {
|
|
current = playlists[0] ?? null;
|
|
tracks = current ? await listTracks(current.id) : [];
|
|
}
|
|
ui.notify(t('music.deleted', { name: p.name }, 'Deleted “{{name}}”.'), 'success');
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
async function onRemoveTrack(track: PlaylistItem) {
|
|
if (!current) return;
|
|
try {
|
|
await removeTrack(current.id, track.file_id);
|
|
tracks = tracks.filter((x) => x.id !== track.id);
|
|
ui.notify(t('music.track_removed', 'Track removed.'), 'success');
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
async function onTogglePublic() {
|
|
if (!current) return;
|
|
const next = !current.is_public;
|
|
try {
|
|
await updatePlaylist(current.id, { is_public: next });
|
|
current.is_public = next;
|
|
playlists = playlists.map((p) => (p.id === current!.id ? { ...p, is_public: next } : p));
|
|
ui.notify(
|
|
next
|
|
? t('music.now_public', 'Playlist is now public.')
|
|
: t('music.now_private', 'Playlist is now private.'),
|
|
'success'
|
|
);
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
function onDragStart(i: number) {
|
|
dragIndex = i;
|
|
}
|
|
function onDragOver(e: DragEvent, i: number) {
|
|
e.preventDefault();
|
|
if (dragIndex === null || dragIndex === i) return;
|
|
const next = [...tracks];
|
|
const [moved] = next.splice(dragIndex, 1);
|
|
next.splice(i, 0, moved);
|
|
dragIndex = i;
|
|
tracks = next;
|
|
}
|
|
async function onDrop() {
|
|
dragIndex = null;
|
|
if (!current) return;
|
|
try {
|
|
await reorderTracks(
|
|
current.id,
|
|
tracks.map((tr) => tr.id)
|
|
);
|
|
ui.notify(t('music.reordered', 'Playlist reordered.'), 'success');
|
|
} catch (e) {
|
|
errorToast(e);
|
|
await select(current);
|
|
}
|
|
}
|
|
|
|
function trackLabel(tr: PlaylistItem): string {
|
|
return tr.title || tr.file_name || tr.file_id;
|
|
}
|
|
|
|
// ── Transport (operates on the independent queue) ──────────────────────────
|
|
/** Replace the queue (e.g. when starting playback of the visible playlist). */
|
|
function setQueue(list: PlaylistItem[]) {
|
|
queue = [...list];
|
|
}
|
|
|
|
function playIndex(i: number) {
|
|
if (i < 0 || i >= queue.length) return;
|
|
currentIndex = i;
|
|
// $effect swaps the src; ensure playback starts.
|
|
queueMicrotask(() => audio?.play().catch(() => {}));
|
|
}
|
|
|
|
/** Play the visible playlist from a given row, seeding the queue from it. */
|
|
function playFromTracks(i: number) {
|
|
if (i < 0 || i >= tracks.length) return;
|
|
setQueue(tracks);
|
|
playIndex(i);
|
|
}
|
|
|
|
function playAll() {
|
|
if (tracks.length) playFromTracks(0);
|
|
}
|
|
|
|
function shufflePlay() {
|
|
if (!tracks.length) return;
|
|
const shuffled = [...tracks];
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
}
|
|
setQueue(shuffled);
|
|
playIndex(0);
|
|
}
|
|
|
|
function togglePlay() {
|
|
if (!audio) return;
|
|
if (currentIndex < 0 && queue.length) {
|
|
playIndex(0);
|
|
return;
|
|
}
|
|
if (playing) audio.pause();
|
|
else audio.play().catch(() => {});
|
|
}
|
|
function next() {
|
|
if (!queue.length) return;
|
|
if (shuffle) {
|
|
playIndex(Math.floor(Math.random() * queue.length));
|
|
return;
|
|
}
|
|
if (currentIndex + 1 < queue.length) playIndex(currentIndex + 1);
|
|
else if (repeat === 'all') playIndex(0);
|
|
}
|
|
function prev() {
|
|
if (!queue.length) return;
|
|
if (currentTime > 3 && audio) {
|
|
audio.currentTime = 0;
|
|
return;
|
|
}
|
|
// Wrap to the last track when at the start (OLD behavior).
|
|
playIndex(currentIndex > 0 ? currentIndex - 1 : queue.length - 1);
|
|
}
|
|
function onEnded() {
|
|
if (repeat === 'one') {
|
|
if (audio) audio.currentTime = 0;
|
|
audio?.play().catch(() => {});
|
|
return;
|
|
}
|
|
next();
|
|
}
|
|
function seek(e: Event) {
|
|
const v = Number((e.target as HTMLInputElement).value);
|
|
if (audio) audio.currentTime = v;
|
|
}
|
|
function applyVolume() {
|
|
if (audio) {
|
|
audio.volume = volume;
|
|
audio.muted = muted;
|
|
}
|
|
}
|
|
function setVolume(e: Event) {
|
|
volume = Number((e.target as HTMLInputElement).value);
|
|
muted = volume === 0;
|
|
applyVolume();
|
|
}
|
|
function toggleMute() {
|
|
muted = !muted;
|
|
applyVolume();
|
|
}
|
|
function cycleRepeat() {
|
|
repeat = repeat === 'none' ? 'all' : repeat === 'all' ? 'one' : 'none';
|
|
}
|
|
|
|
const volumeIcon = $derived(muted || volume === 0 ? 'volume' : 'volume-up');
|
|
|
|
function jumpQueue(i: number) {
|
|
playIndex(i);
|
|
}
|
|
function removeFromQueue(i: number) {
|
|
const next = [...queue];
|
|
next.splice(i, 1);
|
|
if (i === currentIndex) {
|
|
queue = next;
|
|
if (next.length === 0) {
|
|
audio?.pause();
|
|
currentIndex = -1;
|
|
} else {
|
|
playIndex(i >= next.length ? 0 : i);
|
|
}
|
|
} else {
|
|
if (i < currentIndex) currentIndex -= 1;
|
|
queue = next;
|
|
}
|
|
}
|
|
|
|
/** Live duration backfill: write the real duration into rows/queue lacking it. */
|
|
function onLoadedMetadata() {
|
|
duration = audio?.duration ?? 0;
|
|
const tr = currentTrack;
|
|
if (tr && audio?.duration && !tr.duration_secs) {
|
|
const secs = Math.round(audio.duration);
|
|
tr.duration_secs = secs;
|
|
queue = queue.map((q) => (q.id === tr.id ? { ...q, duration_secs: secs } : q));
|
|
tracks = tracks.map((q) => (q.id === tr.id ? { ...q, duration_secs: secs } : q));
|
|
}
|
|
}
|
|
|
|
function onAudioError() {
|
|
if (!currentTrack) return;
|
|
ui.notify(
|
|
t('music.playback_error', { name: trackLabel(currentTrack) }, 'Playback error: {{name}}'),
|
|
'error'
|
|
);
|
|
playing = false;
|
|
}
|
|
|
|
// Keep the <audio> src in sync with the current queued track.
|
|
$effect(() => {
|
|
if (audio && currentTrack) {
|
|
const url = fileInlineUrl(currentTrack.file_id);
|
|
if (audio.getAttribute('src') !== url) audio.src = url;
|
|
}
|
|
});
|
|
|
|
// ── Cover art picker ───────────────────────────────────────────────────────
|
|
let coverInput = $state<HTMLInputElement | null>(null);
|
|
|
|
function pickCover() {
|
|
coverInput?.click();
|
|
}
|
|
async function onCoverChosen(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
input.value = '';
|
|
if (!file || !current) return;
|
|
try {
|
|
const fileId = await uploadCoverImage(file);
|
|
await updatePlaylist(current.id, { cover_file_id: fileId });
|
|
current.cover_file_id = fileId;
|
|
playlists = playlists.map((p) =>
|
|
p.id === current!.id ? { ...p, cover_file_id: fileId } : p
|
|
);
|
|
ui.notify(t('music.cover_updated', 'Cover updated.'), 'success');
|
|
} catch (err) {
|
|
errorToast(err);
|
|
}
|
|
}
|
|
|
|
function coverUrl(p: Playlist | null): string | null {
|
|
return p?.cover_file_id ? `/api/files/${encodeURIComponent(p.cover_file_id)}` : null;
|
|
}
|
|
|
|
// ── Share dialog ───────────────────────────────────────────────────────────
|
|
let sharesOpen = $state(false);
|
|
let shares = $state<MusicShare[]>([]);
|
|
let shareUser = $state('');
|
|
let shareCanWrite = $state(false);
|
|
let sharesLoading = $state(false);
|
|
|
|
async function openShares() {
|
|
if (!current) return;
|
|
sharesOpen = true;
|
|
await loadShares();
|
|
}
|
|
async function loadShares() {
|
|
if (!current) return;
|
|
sharesLoading = true;
|
|
try {
|
|
shares = await listShares(current.id);
|
|
} catch (e) {
|
|
errorToast(e);
|
|
} finally {
|
|
sharesLoading = false;
|
|
}
|
|
}
|
|
async function onAddShare() {
|
|
if (!current || !shareUser.trim()) return;
|
|
try {
|
|
await sharePlaylist(current.id, shareUser.trim(), shareCanWrite);
|
|
shareUser = '';
|
|
shareCanWrite = false;
|
|
await loadShares();
|
|
ui.notify(t('music.share_added', 'Shared.'), 'success');
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
async function onRemoveShare(userId: string) {
|
|
if (!current) return;
|
|
try {
|
|
await removeShare(current.id, userId);
|
|
await loadShares();
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
// ── Add tracks dialog ──────────────────────────────────────────────────────
|
|
const AUDIO_TYPES = ['mp3', 'ogg', 'flac', 'wav', 'aac', 'm4a', 'wma', 'opus', 'webm'];
|
|
let addOpen = $state(false);
|
|
let addQuery = $state('');
|
|
let addResults = $state<FileItem[]>([]);
|
|
const addSelected = useSelection();
|
|
let addSearching = $state(false);
|
|
let addDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
async function runAddSearch(query = '') {
|
|
addSearching = true;
|
|
try {
|
|
const res = await searchFiles(query.trim(), {
|
|
recursive: true,
|
|
fileTypes: AUDIO_TYPES,
|
|
limit: 200
|
|
});
|
|
// Belt-and-braces: keep only audio mime types.
|
|
addResults = res.files.filter(
|
|
(f) =>
|
|
(f.mime_type ?? '').startsWith('audio/') ||
|
|
AUDIO_TYPES.some((e) => f.name.toLowerCase().endsWith(`.${e}`))
|
|
);
|
|
} catch (e) {
|
|
errorToast(e);
|
|
addResults = [];
|
|
} finally {
|
|
addSearching = false;
|
|
}
|
|
}
|
|
function onAddQueryInput() {
|
|
if (addDebounce) clearTimeout(addDebounce);
|
|
addDebounce = setTimeout(() => runAddSearch(addQuery), 300);
|
|
}
|
|
function openAdd() {
|
|
addOpen = true;
|
|
addQuery = '';
|
|
addResults = [];
|
|
addSelected.clear();
|
|
void runAddSearch(''); // show all audio files immediately
|
|
}
|
|
async function confirmAdd() {
|
|
if (!current || addSelected.size === 0) return;
|
|
const count = addSelected.size;
|
|
try {
|
|
await addTracks(current.id, addSelected.values());
|
|
addOpen = false;
|
|
tracks = await listTracks(current.id);
|
|
ui.notify(t('music.tracks_added', { n: count }, 'Added {{n}} track(s).'), 'success');
|
|
} catch (e) {
|
|
errorToast(e);
|
|
}
|
|
}
|
|
|
|
onMount(loadPlaylists);
|
|
</script>
|
|
|
|
<svelte:head><title>{t('nav.music', 'Music')} · OxiCloud</title></svelte:head>
|
|
|
|
<div class="music-container active">
|
|
{#if error}
|
|
<div class="music-error">
|
|
<Icon name="exclamation-circle" />
|
|
<p>{error}</p>
|
|
</div>
|
|
{:else if loading && playlists.length === 0}
|
|
<div class="music-loading"><Icon name="spinner" /></div>
|
|
{:else if playlists.length === 0}
|
|
<div class="music-empty-state">
|
|
<div class="music-empty-state-icon"><Icon name="music" /></div>
|
|
<h3 class="music-empty-state-title">{t('music.no_playlists', 'No playlists yet.')}</h3>
|
|
<p class="music-empty-state-desc">
|
|
{t('music.empty_hint', 'Create a playlist to start collecting your tracks.')}
|
|
</p>
|
|
<button
|
|
class="btn btn-primary"
|
|
data-testid="music-create-playlist-empty-btn"
|
|
onclick={onCreate}
|
|
>
|
|
<Icon name="plus" />
|
|
<span>{t('music.create_playlist', 'Create playlist')}</span>
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="music-content">
|
|
<div class="music-sidebar">
|
|
<div class="music-sidebar-header">
|
|
<h3>{t('music.playlists', 'Playlists')}</h3>
|
|
<button
|
|
class="music-sidebar-add-btn"
|
|
title={t('music.create_playlist', 'Create playlist')}
|
|
aria-label={t('music.create_playlist', 'Create playlist')}
|
|
data-testid="music-create-playlist-btn"
|
|
onclick={onCreate}
|
|
>
|
|
<Icon name="plus" />
|
|
</button>
|
|
</div>
|
|
<div class="music-playlist-list">
|
|
{#each playlists as p (p.id)}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="music-playlist-item"
|
|
class:active={current?.id === p.id}
|
|
data-testid={p.name}
|
|
onclick={() => select(p)}
|
|
>
|
|
<div class="music-playlist-icon"><Icon name="music" /></div>
|
|
<div class="music-playlist-item-info">
|
|
<span class="music-playlist-item-name">{p.name}</span>
|
|
<span class="music-playlist-item-count"
|
|
>{p.track_count}
|
|
{t('music.tracks', 'tracks')}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="music-main">
|
|
{#if current}
|
|
<div class="music-playlist-detail">
|
|
<div class="music-playlist-header">
|
|
<button
|
|
class="music-playlist-cover"
|
|
title={t('music.set_cover', 'Set cover')}
|
|
aria-label={t('music.set_cover', 'Set cover')}
|
|
data-testid="music-set-cover-btn"
|
|
onclick={pickCover}
|
|
>
|
|
{#if coverUrl(current)}
|
|
<img class="music-cover-img" src={coverUrl(current)} alt="" />
|
|
{:else}
|
|
<Icon name="music" />
|
|
{/if}
|
|
<div class="music-cover-overlay"><Icon name="camera" /></div>
|
|
</button>
|
|
<div class="music-playlist-info">
|
|
<h2>{current.name}</h2>
|
|
<p>
|
|
{t('music.track_count', { n: current.track_count }, '{{n}} tracks')}
|
|
{#if current.description}· {current.description}{/if}
|
|
</p>
|
|
{#if current.is_public}
|
|
<span class="music-public-badge">
|
|
<Icon name="globe" /> <span>{t('music.public', 'Public')}</span>
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="music-playlist-actions">
|
|
<button
|
|
class="btn btn-secondary"
|
|
data-testid="music-play-all-btn"
|
|
onclick={playAll}
|
|
disabled={tracks.length === 0}
|
|
>
|
|
<Icon name="play" />
|
|
<span>{t('music.play_all', 'Play all')}</span>
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
data-testid="music-shuffle-play-btn"
|
|
onclick={shufflePlay}
|
|
disabled={tracks.length === 0}
|
|
title={t('music.shuffle', 'Shuffle')}
|
|
aria-label={t('music.shuffle', 'Shuffle')}
|
|
>
|
|
<Icon name="shuffle" />
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
data-testid="music-add-tracks-btn"
|
|
onclick={openAdd}
|
|
>
|
|
<Icon name="plus" />
|
|
<span>{t('music.add_tracks', 'Add tracks')}</span>
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
data-testid="music-rename-playlist-btn"
|
|
onclick={onRenamePlaylist}
|
|
title={t('common.rename', 'Rename')}
|
|
aria-label={t('common.rename', 'Rename')}
|
|
>
|
|
<Icon name="pen" />
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
data-testid="music-edit-description-btn"
|
|
onclick={onEditDescription}
|
|
title={t('music.edit_description', 'Edit description')}
|
|
aria-label={t('music.edit_description', 'Edit description')}
|
|
>
|
|
<Icon name="pencil-alt" />
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
data-testid="music-manage-shares-btn"
|
|
onclick={openShares}
|
|
title={t('music.manage_shares', 'Manage shares')}
|
|
aria-label={t('music.manage_shares', 'Manage shares')}
|
|
>
|
|
<Icon name="users" />
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
class:active={current.is_public}
|
|
data-testid="music-toggle-public-btn"
|
|
onclick={onTogglePublic}
|
|
title={current.is_public
|
|
? t('music.make_private', 'Make private')
|
|
: t('music.make_public', 'Make public')}
|
|
aria-label={current.is_public
|
|
? t('music.make_private', 'Make private')
|
|
: t('music.make_public', 'Make public')}
|
|
>
|
|
<Icon name="globe" />
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
data-testid="music-delete-playlist-btn"
|
|
onclick={() => onDelete(current!)}
|
|
title={t('common.delete', 'Delete')}
|
|
aria-label={t('common.delete', 'Delete')}
|
|
>
|
|
<Icon name="trash" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="music-track-list">
|
|
{#if tracks.length === 0}
|
|
<div class="music-empty">
|
|
<Icon name="music" />
|
|
<p>{t('music.empty_playlist', 'This playlist has no tracks yet.')}</p>
|
|
</div>
|
|
{:else}
|
|
<div class="music-track-header">
|
|
<span class="music-track-col music-track-drag"></span>
|
|
<span class="music-track-col music-track-num">#</span>
|
|
<span class="music-track-col music-track-title">{t('music.title', 'Title')}</span>
|
|
<span class="music-track-col music-track-artist"
|
|
>{t('music.artist', 'Artist')}</span
|
|
>
|
|
<span class="music-track-col music-track-album">{t('music.album', 'Album')}</span>
|
|
<span class="music-track-col music-track-duration"><Icon name="clock" /></span>
|
|
<span class="music-track-col music-track-actions"></span>
|
|
</div>
|
|
{#each tracks as track, i (track.id)}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="music-track"
|
|
class:playing={currentTrack?.id === track.id && playing}
|
|
data-testid={trackLabel(track)}
|
|
draggable="true"
|
|
ondblclick={() => playFromTracks(i)}
|
|
ondragstart={() => onDragStart(i)}
|
|
ondragover={(e) => onDragOver(e, i)}
|
|
ondrop={onDrop}
|
|
ondragend={() => (dragIndex = null)}
|
|
>
|
|
<span class="music-track-col music-track-drag" aria-hidden="true">
|
|
<Icon name="grip-vertical" />
|
|
</span>
|
|
<span class="music-track-col music-track-num">
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<span
|
|
data-testid={`music-track-play-${track.id}`}
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
if (currentTrack?.id === track.id) togglePlay();
|
|
else playFromTracks(i);
|
|
}}
|
|
>
|
|
{#if currentTrack?.id === track.id}
|
|
<Icon name={playing ? 'pause' : 'play'} />
|
|
{:else}
|
|
<span class="track-num-text">{i + 1}</span>
|
|
{/if}
|
|
</span>
|
|
</span>
|
|
<span class="music-track-col music-track-title">
|
|
<Icon name="music" class="music-track-icon" />
|
|
<span class="music-track-name">{trackLabel(track)}</span>
|
|
</span>
|
|
<span class="music-track-col music-track-artist">{track.artist || '—'}</span>
|
|
<span class="music-track-col music-track-album">{track.album || '-'}</span>
|
|
<span class="music-track-col music-track-duration"
|
|
>{fmtDuration(track.duration_secs)}</span
|
|
>
|
|
<span class="music-track-col music-track-actions">
|
|
<button
|
|
class="music-track-remove-btn"
|
|
title={t('common.remove', 'Remove')}
|
|
aria-label={t('common.remove', 'Remove')}
|
|
data-testid={`music-track-remove-${track.id}`}
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
onRemoveTrack(track);
|
|
}}
|
|
>
|
|
<Icon name="times" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="music-welcome">
|
|
<Icon name="music" />
|
|
<h3>{t('music.select_playlist', 'Select a playlist.')}</h3>
|
|
<p>{t('music.select_hint', 'Pick a playlist to view its tracks.')}</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Now-playing bar (persists while browsing) -->
|
|
{#if currentTrack}
|
|
<div class="music-player has-track">
|
|
<div class="player-track-info">
|
|
<div class="player-album-art">
|
|
{#if coverUrl(current)}
|
|
<img src={coverUrl(current)} alt="" />
|
|
{:else}
|
|
<Icon name="music" />
|
|
{/if}
|
|
</div>
|
|
<div class="player-track-details">
|
|
<span class="player-track-name">{trackLabel(currentTrack)}</span>
|
|
<span class="player-track-artist">{currentTrack.artist ?? ''}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="player-controls">
|
|
<div class="player-buttons">
|
|
<button
|
|
class="player-btn"
|
|
class:active={shuffle}
|
|
title={t('music.shuffle', 'Shuffle')}
|
|
aria-label={t('music.shuffle', 'Shuffle')}
|
|
data-testid="music-player-shuffle-btn"
|
|
onclick={() => (shuffle = !shuffle)}
|
|
>
|
|
<Icon name="shuffle" />
|
|
</button>
|
|
<button
|
|
class="player-btn"
|
|
title={t('music.prev', 'Previous')}
|
|
aria-label={t('music.prev', 'Previous')}
|
|
data-testid="music-player-prev-btn"
|
|
onclick={prev}
|
|
>
|
|
<Icon name="backward" />
|
|
</button>
|
|
<button
|
|
class="player-btn player-btn-main"
|
|
title={t('music.play', 'Play')}
|
|
aria-label={t('music.play', 'Play')}
|
|
data-testid="music-player-play-btn"
|
|
onclick={togglePlay}
|
|
>
|
|
<Icon name={playing ? 'pause' : 'play'} />
|
|
</button>
|
|
<button
|
|
class="player-btn"
|
|
title={t('music.next', 'Next')}
|
|
aria-label={t('music.next', 'Next')}
|
|
data-testid="music-player-next-btn"
|
|
onclick={next}
|
|
>
|
|
<Icon name="forward" />
|
|
</button>
|
|
<button
|
|
class="player-btn"
|
|
class:active={repeat !== 'none'}
|
|
class:repeat-one={repeat === 'one'}
|
|
title={t('music.repeat', 'Repeat')}
|
|
aria-label={t('music.repeat', 'Repeat')}
|
|
data-testid="music-player-repeat-btn"
|
|
onclick={cycleRepeat}
|
|
>
|
|
<Icon name="repeat" />
|
|
{#if repeat === 'one'}<span class="repeat-one-badge">1</span>{/if}
|
|
</button>
|
|
</div>
|
|
<div class="player-progress">
|
|
<span class="player-time player-time-current">{fmtTime(currentTime)}</span>
|
|
<input
|
|
class="player-progress-range"
|
|
type="range"
|
|
min="0"
|
|
max={duration || 0}
|
|
value={currentTime}
|
|
oninput={seek}
|
|
aria-label={t('music.seek', 'Seek')}
|
|
data-testid="music-player-seek-input"
|
|
/>
|
|
<span class="player-time player-time-total">{fmtTime(duration)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="player-extra">
|
|
<button
|
|
class="player-btn player-btn-small"
|
|
class:active={queueOpen}
|
|
title={t('music.queue', 'Queue')}
|
|
aria-label={t('music.queue', 'Queue')}
|
|
data-testid="music-player-queue-toggle-btn"
|
|
onclick={() => (queueOpen = !queueOpen)}
|
|
>
|
|
<Icon name="list" />
|
|
</button>
|
|
<button
|
|
class="player-btn player-btn-small"
|
|
title={t('music.mute', 'Mute')}
|
|
aria-label={t('music.mute', 'Mute')}
|
|
data-testid="music-player-mute-btn"
|
|
onclick={toggleMute}
|
|
>
|
|
<Icon name={volumeIcon} />
|
|
</button>
|
|
<div class="player-volume-slider">
|
|
<input
|
|
id="player-volume-input"
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={muted ? 0 : volume}
|
|
oninput={setVolume}
|
|
aria-label={t('music.volume', 'Volume')}
|
|
data-testid="music-player-volume-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if queueOpen}
|
|
<div class="player-queue" data-testid="music-queue-panel">
|
|
<div class="player-queue-header">
|
|
<h3>{t('music.queue', 'Queue')}</h3>
|
|
<button
|
|
class="player-btn player-btn-small"
|
|
data-testid="music-queue-close-btn"
|
|
onclick={() => (queueOpen = false)}
|
|
aria-label={t('common.close', 'Close')}
|
|
>
|
|
<Icon name="times" />
|
|
</button>
|
|
</div>
|
|
<div class="player-queue-list">
|
|
{#if queue.length === 0}
|
|
<div class="player-queue-empty">
|
|
<Icon name="music" />
|
|
<p>{t('music.queue_empty', 'Queue is empty.')}</p>
|
|
</div>
|
|
{:else}
|
|
{#each queue as qt, i (qt.id)}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="player-queue-item"
|
|
class:active={i === currentIndex}
|
|
data-testid={trackLabel(qt)}
|
|
onclick={() => jumpQueue(i)}
|
|
>
|
|
<span class="queue-item-num">{i + 1}</span>
|
|
<span class="queue-item-info">
|
|
<span class="queue-item-name">{trackLabel(qt)}</span>
|
|
<span class="queue-item-artist">{qt.artist ?? ''}</span>
|
|
</span>
|
|
<span class="queue-item-duration">{fmtDuration(qt.duration_secs)}</span>
|
|
<button
|
|
class="queue-item-remove"
|
|
aria-label={t('common.remove', 'Remove')}
|
|
data-testid={`music-queue-remove-${qt.id}`}
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
removeFromQueue(i);
|
|
}}
|
|
>
|
|
<Icon name="times" />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<audio
|
|
bind:this={audio}
|
|
onplay={() => (playing = true)}
|
|
onpause={() => (playing = false)}
|
|
ontimeupdate={() => (currentTime = audio?.currentTime ?? 0)}
|
|
onloadedmetadata={onLoadedMetadata}
|
|
onended={onEnded}
|
|
onerror={onAudioError}
|
|
></audio>
|
|
|
|
<input
|
|
bind:this={coverInput}
|
|
type="file"
|
|
accept="image/*"
|
|
class="hidden-input"
|
|
data-testid="music-cover-input"
|
|
onchange={onCoverChosen}
|
|
/>
|
|
|
|
{#if addOpen}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="music-picker-overlay active"
|
|
data-testid="music-add-tracks-dialog"
|
|
onclick={(e) => {
|
|
if (e.target === e.currentTarget) addOpen = false;
|
|
}}
|
|
>
|
|
<div class="music-picker-modal">
|
|
<div class="music-picker-header">
|
|
<h3><Icon name="music" /> {t('music.add_tracks', 'Add tracks')}</h3>
|
|
<button
|
|
class="music-picker-close"
|
|
aria-label={t('common.close', 'Close')}
|
|
data-testid="music-add-tracks-close-btn"
|
|
onclick={() => (addOpen = false)}>×</button
|
|
>
|
|
</div>
|
|
<div class="music-picker-search">
|
|
<Icon name="search" />
|
|
<!-- svelte-ignore a11y_autofocus -->
|
|
<input
|
|
type="text"
|
|
placeholder={t('music.search_audio', 'Search audio files…')}
|
|
bind:value={addQuery}
|
|
oninput={onAddQueryInput}
|
|
autocomplete="off"
|
|
autofocus
|
|
data-testid="music-add-tracks-search-input"
|
|
/>
|
|
</div>
|
|
<div class="music-picker-list">
|
|
{#if addSearching}
|
|
<div class="music-picker-loading">
|
|
<Icon name="spinner" />
|
|
{t('common.loading', 'Loading…')}
|
|
</div>
|
|
{:else if addResults.length === 0}
|
|
<div class="music-picker-empty">
|
|
<Icon name="folder-open" />
|
|
{t('music.no_audio', 'No audio files found.')}
|
|
</div>
|
|
{:else}
|
|
{#each addResults as f (f.id)}
|
|
<label class="music-picker-item" class:selected={addSelected.has(f.id)}>
|
|
<input
|
|
type="checkbox"
|
|
checked={addSelected.has(f.id)}
|
|
onchange={() => addSelected.toggle(f.id)}
|
|
data-testid={f.name}
|
|
/>
|
|
<Icon name="file-audio" />
|
|
<span class="music-picker-name" title={f.name}>{f.name}</span>
|
|
</label>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
<div class="music-picker-footer">
|
|
<span class="music-picker-selected-count">
|
|
{t('music.selected_count', { n: addSelected.size }, '{{n}} selected')}
|
|
</span>
|
|
<div class="music-picker-actions">
|
|
<button
|
|
class="btn btn-secondary"
|
|
data-testid="music-add-tracks-cancel-btn"
|
|
onclick={() => (addOpen = false)}
|
|
>
|
|
{t('common.cancel', 'Cancel')}
|
|
</button>
|
|
<button
|
|
class="btn btn-primary"
|
|
disabled={addSelected.size === 0}
|
|
data-testid="music-add-tracks-confirm-btn"
|
|
onclick={confirmAdd}
|
|
>
|
|
<Icon name="plus" />
|
|
{t('music.add_selected', 'Add selected')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if sharesOpen}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="music-shares-overlay"
|
|
data-testid="music-shares-dialog"
|
|
onclick={(e) => {
|
|
if (e.target === e.currentTarget) sharesOpen = false;
|
|
}}
|
|
>
|
|
<div class="music-shares-panel">
|
|
<div class="music-shares-header">
|
|
<h3><Icon name="users" /> {t('music.manage_shares', 'Manage shares')}</h3>
|
|
<button
|
|
class="music-shares-close-btn"
|
|
aria-label={t('common.close', 'Close')}
|
|
data-testid="music-shares-close-btn"
|
|
onclick={() => (sharesOpen = false)}
|
|
>
|
|
<Icon name="times" />
|
|
</button>
|
|
</div>
|
|
<div class="music-shares-body">
|
|
{#if sharesLoading}
|
|
<div class="music-shares-loading"><Icon name="spinner" /></div>
|
|
{:else if shares.length === 0}
|
|
<p class="music-shares-empty">{t('music.no_shares', 'Not shared with anyone yet.')}</p>
|
|
{:else}
|
|
{#each shares as s (s.user_id)}
|
|
<div class="music-share-item">
|
|
<span class="music-share-user"><Icon name="user" /> {s.user_id}</span>
|
|
<span class="music-share-perm">
|
|
{s.can_write ? t('music.can_write', 'Can edit') : t('music.read_only', 'Read only')}
|
|
</span>
|
|
<button
|
|
class="music-share-remove-btn"
|
|
title={t('music.remove_share', 'Remove')}
|
|
aria-label={t('music.remove_share', 'Remove')}
|
|
data-testid={`music-share-remove-${s.user_id}`}
|
|
onclick={() => onRemoveShare(s.user_id)}
|
|
>
|
|
<Icon name="times" />
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
<div class="music-shares-add">
|
|
<input
|
|
type="text"
|
|
class="music-shares-input"
|
|
placeholder={t('music.share_with_user', 'User ID or email')}
|
|
bind:value={shareUser}
|
|
autocomplete="off"
|
|
data-testid="music-share-user-input"
|
|
/>
|
|
<label class="music-shares-write-label">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={shareCanWrite}
|
|
data-testid="music-share-can-write-checkbox"
|
|
/>
|
|
{t('music.can_write', 'Can edit')}
|
|
</label>
|
|
<button
|
|
class="btn btn-primary btn-sm"
|
|
disabled={!shareUser.trim()}
|
|
data-testid="music-share-add-btn"
|
|
onclick={onAddShare}
|
|
>
|
|
<Icon name="plus" />
|
|
{t('music.share', 'Share')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
/* Almost all visuals come from the ported music.css (global, token-based).
|
|
Only the off-screen file input and player spacing live here. */
|
|
.hidden-input {
|
|
display: none;
|
|
}
|
|
|
|
/* Leave room above the OS chrome for the fixed player bar so the last
|
|
tracks/sidebar content isn't hidden behind it while playing. */
|
|
:global(body:has(.music-player.has-track)) .music-container {
|
|
padding-bottom: 90px;
|
|
}
|
|
</style>
|