Merge pull request 'Web UI: Most Played hover fix, narrower seek bar, Android-parity track kebab' (#99) from dev into main
This commit was merged in pull request #99.
This commit is contained in:
@@ -1,64 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
||||||
import { removeTrack } from './tracks';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function stubFetch(status: number, body: unknown, init: Partial<Response> = {}) {
|
|
||||||
const res = new Response(
|
|
||||||
body === null ? null : JSON.stringify(body),
|
|
||||||
{ status, headers: { 'Content-Type': 'application/json' }, ...init }
|
|
||||||
);
|
|
||||||
const spy = vi.fn().mockResolvedValue(res);
|
|
||||||
vi.stubGlobal('fetch', spy);
|
|
||||||
return spy;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('removeTrack', () => {
|
|
||||||
test('DELETEs /api/admin/tracks/:id with no query string when unmonitor unset', async () => {
|
|
||||||
const spy = stubFetch(200, {
|
|
||||||
deleted_track_id: 't-1',
|
|
||||||
deleted_album_id: 'al-1'
|
|
||||||
});
|
|
||||||
|
|
||||||
const r = await removeTrack('t-1');
|
|
||||||
expect(r.deleted_track_id).toBe('t-1');
|
|
||||||
expect(r.deleted_album_id).toBe('al-1');
|
|
||||||
expect(r.deleted_artist_id).toBeUndefined();
|
|
||||||
expect(r.lidarr_unmonitor_failed).toBeUndefined();
|
|
||||||
|
|
||||||
const call = spy.mock.calls[0];
|
|
||||||
expect(call[0]).toBe('/api/admin/tracks/t-1');
|
|
||||||
expect((call[1] as RequestInit).method).toBe('DELETE');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('appends ?unmonitor=true when option set', async () => {
|
|
||||||
const spy = stubFetch(200, {
|
|
||||||
deleted_track_id: 't-1',
|
|
||||||
lidarr_unmonitor_failed: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const r = await removeTrack('t-1', { unmonitor: true });
|
|
||||||
expect(r.lidarr_unmonitor_failed).toBe(true);
|
|
||||||
|
|
||||||
const call = spy.mock.calls[0];
|
|
||||||
expect(call[0]).toBe('/api/admin/tracks/t-1?unmonitor=true');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('omits the query string when unmonitor: false', async () => {
|
|
||||||
const spy = stubFetch(200, { deleted_track_id: 't-1' });
|
|
||||||
|
|
||||||
await removeTrack('t-1', { unmonitor: false });
|
|
||||||
const call = spy.mock.calls[0];
|
|
||||||
expect(call[0]).toBe('/api/admin/tracks/t-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('surfaces not_found as ApiError', async () => {
|
|
||||||
stubFetch(404, { error: 'not_found' });
|
|
||||||
|
|
||||||
await expect(removeTrack('t-1')).rejects.toMatchObject({
|
|
||||||
code: 'not_found'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { apiFetch } from '$lib/api/client';
|
|
||||||
|
|
||||||
export type RemoveTrackResult = {
|
|
||||||
deleted_track_id: string;
|
|
||||||
deleted_album_id?: string;
|
|
||||||
deleted_artist_id?: string;
|
|
||||||
/** Only present (always `true` when set) when the operator requested
|
|
||||||
* unmonitor=true AND the Lidarr unmonitor call failed. The destructive
|
|
||||||
* file + DB delete already succeeded — surface a follow-up toast so
|
|
||||||
* the operator knows to manually unmonitor in Lidarr. */
|
|
||||||
lidarr_unmonitor_failed?: true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RemoveTrackOptions = {
|
|
||||||
/** When true, after the file + DB delete the server calls
|
|
||||||
* Lidarr.UnmonitorTrack so Lidarr won't search for a replacement.
|
|
||||||
* When false / omitted, no Lidarr call — Lidarr's monitoring will
|
|
||||||
* re-import on next scan, which is the operator's "find a replacement"
|
|
||||||
* path. */
|
|
||||||
unmonitor?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/admin/tracks/{id} — admin-only. Deletes the file from
|
|
||||||
* disk and the DB row, cascading album/artist tidy-up. Optionally
|
|
||||||
* unmonitors the track in Lidarr.
|
|
||||||
*
|
|
||||||
* On success returns the deleted track id plus optional album/artist
|
|
||||||
* ids when their parent row was tidied up by the server-side cascade.
|
|
||||||
* Caller is responsible for invalidating any TanStack Query keys the
|
|
||||||
* deleted entities backed.
|
|
||||||
*/
|
|
||||||
export async function removeTrack(
|
|
||||||
id: string,
|
|
||||||
options: RemoveTrackOptions = {}
|
|
||||||
): Promise<RemoveTrackResult> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options.unmonitor) params.set('unmonitor', 'true');
|
|
||||||
const qs = params.toString();
|
|
||||||
const url = `/api/admin/tracks/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`;
|
|
||||||
return (await apiFetch(url, { method: 'DELETE' })) as RemoveTrackResult;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Plus } from 'lucide-svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
|
||||||
// Horizontal compact track row — cover thumb on the left, title +
|
// Horizontal compact track row — cover thumb on the left, title +
|
||||||
// artist on the right. Mirrors Android's CompactTrackTile so the
|
// artist on the right. Mirrors Android's CompactTrackTile so the
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`Play ${track.title}`}
|
aria-label={`Play ${track.title}`}
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
class="flex w-full items-center gap-2 rounded-md p-1 pr-12 text-left
|
class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left
|
||||||
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
||||||
@@ -59,14 +60,29 @@
|
|||||||
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
|
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<CardActionCluster
|
<!-- Single inline cluster, vertically centred on the right. The shared
|
||||||
likeEntityType="track"
|
CardActionCluster splits Like+Add (top) and the menu (bottom) into
|
||||||
likeEntityId={track.id}
|
opposite corners — correct for the tall square Album/Artist cards but
|
||||||
onAdd={onAddToQueue}
|
it makes the two groups collide on this short one-line row. A compact
|
||||||
addLabel={`Add ${track.title} to queue`}
|
row keeps all three controls in one horizontal group instead. -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute right-2 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5
|
||||||
|
opacity-0 transition-opacity duration-150
|
||||||
|
group-hover:opacity-100 focus-within:opacity-100"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{#snippet menu()}
|
<LikeButton entityType="track" entityId={track.id} size="sm" />
|
||||||
<TrackMenu {track} />
|
<button
|
||||||
{/snippet}
|
type="button"
|
||||||
</CardActionCluster>
|
aria-label={`Add ${track.title} to queue`}
|
||||||
|
onclick={onAddToQueue}
|
||||||
|
class="rounded-full bg-surface p-1 text-text-secondary hover:text-text-primary
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<Plus size={16} strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
<TrackMenu {track} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
|||||||
|
|
||||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||||
|
|
||||||
vi.mock('$lib/api/admin/tracks', () => ({
|
|
||||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
||||||
|
|
||||||
vi.mock('$lib/auth/store.svelte', () => ({
|
vi.mock('$lib/auth/store.svelte', () => ({
|
||||||
|
|||||||
@@ -272,8 +272,12 @@
|
|||||||
data-testid="player-bar-desktop"
|
data-testid="player-bar-desktop"
|
||||||
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
|
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
|
||||||
>
|
>
|
||||||
<!-- Left: cover + title + artist + like + menu -->
|
<!-- Left: cover + title + artist + like + menu.
|
||||||
<div class="flex w-72 min-w-0 items-center gap-3">
|
basis-72 grow max-w-md: holds its 288px floor at the md breakpoint
|
||||||
|
(where the seek column is the flex-1 that absorbs the remainder), but
|
||||||
|
on wider screens it grows up to 448px instead of leaving the title
|
||||||
|
truncated while the seek bar hogs the slack. -->
|
||||||
|
<div class="flex basis-72 grow max-w-md min-w-0 items-center gap-3">
|
||||||
<a
|
<a
|
||||||
href="/now-playing"
|
href="/now-playing"
|
||||||
aria-label="Open now playing"
|
aria-label="Open now playing"
|
||||||
@@ -318,7 +322,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-1 flex-col items-stretch gap-2">
|
<!-- max-w-xl + mx-auto caps the seek bar (and the transport row) at
|
||||||
|
576px and centres it, so on wide screens the extra width flows to
|
||||||
|
the title column on the left rather than stretching the scrubber. -->
|
||||||
|
<div class="flex flex-1 flex-col items-stretch gap-2 mx-auto w-full max-w-xl">
|
||||||
<!-- Transport row -->
|
<!-- Transport row -->
|
||||||
<div class="flex items-center justify-center gap-3">
|
<div class="flex items-center justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
|||||||
|
|
||||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||||
|
|
||||||
vi.mock('$lib/api/admin/tracks', () => ({
|
|
||||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' })
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('$lib/player/store.svelte', () => ({
|
||||||
playNext: vi.fn(),
|
playNext: vi.fn(),
|
||||||
enqueueTrack: vi.fn(),
|
enqueueTrack: vi.fn(),
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Trash2 } from 'lucide-svelte';
|
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
|
||||||
import { removeTrack } from '$lib/api/admin/tracks';
|
|
||||||
import { errMessage } from '$lib/api/errors';
|
|
||||||
import { qk } from '$lib/api/queries';
|
|
||||||
import type { TrackRef } from '$lib/api/types';
|
|
||||||
|
|
||||||
let {
|
|
||||||
track,
|
|
||||||
onClose
|
|
||||||
}: {
|
|
||||||
track: TrackRef;
|
|
||||||
onClose: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
let unmonitor = $state(false);
|
|
||||||
let busy = $state(false);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
|
|
||||||
async function confirm() {
|
|
||||||
busy = true;
|
|
||||||
error = null;
|
|
||||||
try {
|
|
||||||
const result = await removeTrack(track.id, { unmonitor });
|
|
||||||
// Invalidate caches for any vanished entity. The album/artist that
|
|
||||||
// contained the track is always touched; if the parent rows were
|
|
||||||
// tidied up by the server cascade, evict those too. Home payload
|
|
||||||
// surfaces albums/artists on the landing page so it always
|
|
||||||
// refreshes.
|
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.album(track.album_id) });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.artist(track.artist_id) });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.home() });
|
|
||||||
if (result.deleted_album_id) {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.album(result.deleted_album_id) });
|
|
||||||
}
|
|
||||||
if (result.deleted_artist_id) {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.artist(result.deleted_artist_id) });
|
|
||||||
}
|
|
||||||
// Note: result.lidarr_unmonitor_failed is true when unmonitor was
|
|
||||||
// requested AND Lidarr couldn't be reached. The destructive part
|
|
||||||
// already succeeded; we still close the popover. A future toast
|
|
||||||
// surface (if any) can pick up the flag.
|
|
||||||
onClose();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error = errMessage(e);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-modal="false"
|
|
||||||
aria-labelledby="remove-track-popover-title"
|
|
||||||
class="absolute right-0 z-30 mt-1 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose(); }}
|
|
||||||
>
|
|
||||||
<h4 id="remove-track-popover-title" class="text-sm font-medium text-text-primary">
|
|
||||||
Remove "{track.title}" from your library?
|
|
||||||
</h4>
|
|
||||||
<p class="mt-1 text-xs text-text-muted">
|
|
||||||
This deletes the file from disk.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label class="mt-3 flex items-start gap-2 text-sm text-text-primary">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={unmonitor}
|
|
||||||
class="mt-0.5 rounded border-border"
|
|
||||||
/>
|
|
||||||
<span>Also stop Lidarr from finding a replacement</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p class="mt-2 text-xs text-action-destructive">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-border px-2.5 py-1 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
|
||||||
onclick={onClose}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={confirm}
|
|
||||||
disabled={busy}
|
|
||||||
class="inline-flex items-center gap-1 rounded-md border border-border bg-surface px-2.5 py-1 text-sm text-action-destructive hover:bg-surface-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} strokeWidth={1} />
|
|
||||||
{busy ? 'Removing…' : 'Remove'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
|
||||||
|
|
||||||
const invalidateMock = vi.fn();
|
|
||||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
|
||||||
const actual = (await orig()) as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useQueryClient: () => ({ invalidateQueries: invalidateMock })
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('$lib/api/admin/tracks', () => ({
|
|
||||||
removeTrack: vi.fn()
|
|
||||||
}));
|
|
||||||
|
|
||||||
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
|
|
||||||
import { removeTrack } from '$lib/api/admin/tracks';
|
|
||||||
|
|
||||||
const track = makeTrack({ title: 'Roygbiv' });
|
|
||||||
|
|
||||||
afterEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
describe('RemoveTrackPopover', () => {
|
|
||||||
test('renders the title and subtext', () => {
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
|
||||||
expect(screen.getByText(/remove "roygbiv" from your library/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/this deletes the file from disk/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders the Lidarr unmonitor checkbox', () => {
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
|
||||||
const cb = screen.getByRole('checkbox', { name: /also stop lidarr/i });
|
|
||||||
expect(cb).toBeInTheDocument();
|
|
||||||
expect((cb as HTMLInputElement).checked).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Cancel button calls onClose without firing removeTrack', async () => {
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
|
||||||
expect(removeTrack).not.toHaveBeenCalled();
|
|
||||||
expect(onClose).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Remove without checkbox calls removeTrack with unmonitor=false', async () => {
|
|
||||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
|
||||||
// confirm() chains 3-5 awaited invalidateQueries calls before onClose,
|
|
||||||
// each a separate microtask. waitFor polls until the side effects land.
|
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
||||||
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Remove with checkbox checked calls removeTrack with unmonitor=true', async () => {
|
|
||||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
|
||||||
await fireEvent.click(screen.getByRole('checkbox', { name: /also stop lidarr/i }));
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
||||||
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('failed remove surfaces error via copyForCode and does not close', async () => {
|
|
||||||
(removeTrack as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'not_found' });
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
|
||||||
await waitFor(() => expect(removeTrack).toHaveBeenCalled());
|
|
||||||
// The catch path runs synchronously after the rejected await; one more
|
|
||||||
// microtask boundary is enough.
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(onClose).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('success invalidates album, artist and home queries', async () => {
|
|
||||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
deleted_track_id: 't1',
|
|
||||||
deleted_album_id: 'a1',
|
|
||||||
deleted_artist_id: 'ar1'
|
|
||||||
});
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
|
||||||
// Wait for all three baseline invalidations (album, artist, home) plus
|
|
||||||
// the two cascade ones (deleted_album_id, deleted_artist_id) to land.
|
|
||||||
await waitFor(() => expect(invalidateMock.mock.calls.length).toBeGreaterThanOrEqual(5));
|
|
||||||
const keys = invalidateMock.mock.calls.map((c) => c[0].queryKey);
|
|
||||||
const flat = keys.map((k) => Array.isArray(k) ? k.join('|') : String(k));
|
|
||||||
expect(flat.some((k) => k.startsWith('album|a1'))).toBe(true);
|
|
||||||
expect(flat.some((k) => k.startsWith('artist|ar1'))).toBe(true);
|
|
||||||
expect(flat.some((k) => k === 'home')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
ListPlus,
|
ListVideo,
|
||||||
Plus,
|
ListMusic,
|
||||||
Heart,
|
Heart,
|
||||||
HeartOff,
|
HeartOff,
|
||||||
ListMusic,
|
ListPlus,
|
||||||
Album,
|
Radio,
|
||||||
Disc3,
|
Disc3,
|
||||||
Flag,
|
User,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Eye,
|
Eye
|
||||||
Trash2
|
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import FlagPopover from './FlagPopover.svelte';
|
import FlagPopover from './FlagPopover.svelte';
|
||||||
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
|
|
||||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||||
import { user } from '$lib/auth/store.svelte';
|
|
||||||
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
||||||
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
|
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
|
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -44,7 +41,6 @@
|
|||||||
|
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let flagOpen = $state(false);
|
let flagOpen = $state(false);
|
||||||
let removeOpen = $state(false);
|
|
||||||
let addOpen = $state(false);
|
let addOpen = $state(false);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -63,8 +59,6 @@
|
|||||||
return rows.some((r) => r.track_id === track.id);
|
return rows.some((r) => r.track_id === track.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAdmin = $derived(user.value?.is_admin === true);
|
|
||||||
|
|
||||||
function toggleMenu(e: MouseEvent) {
|
function toggleMenu(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
menuOpen = !menuOpen;
|
menuOpen = !menuOpen;
|
||||||
@@ -73,7 +67,6 @@
|
|||||||
function closeAll() {
|
function closeAll() {
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
flagOpen = false;
|
flagOpen = false;
|
||||||
removeOpen = false;
|
|
||||||
addOpen = false;
|
addOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +80,11 @@
|
|||||||
closeAll();
|
closeAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onStartRadio() {
|
||||||
|
playRadio(track.id);
|
||||||
|
closeAll();
|
||||||
|
}
|
||||||
|
|
||||||
async function onToggleLike() {
|
async function onToggleLike() {
|
||||||
closeAll();
|
closeAll();
|
||||||
if (liked) {
|
if (liked) {
|
||||||
@@ -119,18 +117,8 @@
|
|||||||
goto(`/artists/${track.artist_id}`);
|
goto(`/artists/${track.artist_id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFlag() {
|
|
||||||
menuOpen = false;
|
|
||||||
flagOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRemoveOpen() {
|
|
||||||
menuOpen = false;
|
|
||||||
removeOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return;
|
if (!menuOpen && !flagOpen && !addOpen) return;
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
closeAll();
|
closeAll();
|
||||||
@@ -161,9 +149,16 @@
|
|||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') menuOpen = false; }}
|
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') menuOpen = false; }}
|
||||||
>
|
>
|
||||||
|
<!-- Item set + order mirror Android's canonical TrackActionsSheet so
|
||||||
|
the kebab reads the same on both clients: queue group → like /
|
||||||
|
add-to-playlist / start-radio group → navigation group → hide.
|
||||||
|
"Start radio" is shown even under hideQueueActions (reseeding a
|
||||||
|
station from the current track is meaningful where play-next /
|
||||||
|
add-to-queue are not). No single-click destructive action lives
|
||||||
|
here — track removal is intentionally kept out of the kebab. -->
|
||||||
{#if !hideQueueActions}
|
{#if !hideQueueActions}
|
||||||
<TrackMenuItem icon={ListPlus} label="Play next" onclick={onPlayNext} />
|
<TrackMenuItem icon={ListVideo} label="Play next" onclick={onPlayNext} />
|
||||||
<TrackMenuItem icon={Plus} label="Add to queue" onclick={onAddToQueue} />
|
<TrackMenuItem icon={ListMusic} label="Add to queue" onclick={onAddToQueue} />
|
||||||
|
|
||||||
<TrackMenuDivider />
|
<TrackMenuDivider />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -174,32 +169,24 @@
|
|||||||
onclick={onToggleLike}
|
onclick={onToggleLike}
|
||||||
/>
|
/>
|
||||||
<TrackMenuItem
|
<TrackMenuItem
|
||||||
icon={ListMusic}
|
icon={ListPlus}
|
||||||
label="Add to playlist…"
|
label="Add to playlist…"
|
||||||
onclick={() => { addOpen = true; menuOpen = false; }}
|
onclick={() => { addOpen = true; menuOpen = false; }}
|
||||||
/>
|
/>
|
||||||
|
<TrackMenuItem icon={Radio} label="Start radio" onclick={onStartRadio} />
|
||||||
|
|
||||||
<TrackMenuDivider />
|
<TrackMenuDivider />
|
||||||
|
|
||||||
<TrackMenuItem icon={Album} label="Go to album" onclick={onGoToAlbum} />
|
<TrackMenuItem icon={Disc3} label="Go to album" onclick={onGoToAlbum} />
|
||||||
<TrackMenuItem icon={Disc3} label="Go to artist" onclick={onGoToArtist} />
|
<TrackMenuItem icon={User} label="Go to artist" onclick={onGoToArtist} />
|
||||||
|
|
||||||
<TrackMenuDivider />
|
<TrackMenuDivider />
|
||||||
|
|
||||||
<TrackMenuItem icon={Flag} label="Flag this track…" onclick={onFlag} />
|
|
||||||
<TrackMenuItem
|
<TrackMenuItem
|
||||||
icon={hidden ? Eye : EyeOff}
|
icon={hidden ? Eye : EyeOff}
|
||||||
label={hidden ? 'Unhide' : 'Hide'}
|
label={hidden ? 'Unhide' : 'Hide'}
|
||||||
onclick={onToggleHide}
|
onclick={onToggleHide}
|
||||||
/>
|
/>
|
||||||
{#if isAdmin}
|
|
||||||
<TrackMenuItem
|
|
||||||
icon={Trash2}
|
|
||||||
label="Remove from library"
|
|
||||||
onclick={onRemoveOpen}
|
|
||||||
danger
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -207,10 +194,6 @@
|
|||||||
<FlagPopover {track} onClose={closeAll} />
|
<FlagPopover {track} onClose={closeAll} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if removeOpen}
|
|
||||||
<RemoveTrackPopover {track} onClose={closeAll} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if addOpen}
|
{#if addOpen}
|
||||||
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
|
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -25,19 +25,16 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
|||||||
|
|
||||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||||
|
|
||||||
vi.mock('$lib/api/admin/tracks', () => ({
|
|
||||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('$lib/player/store.svelte', () => ({
|
||||||
playNext: vi.fn(),
|
playNext: vi.fn(),
|
||||||
enqueueTrack: vi.fn()
|
enqueueTrack: vi.fn(),
|
||||||
|
playRadio: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
|
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
||||||
|
|
||||||
const track = makeTrack({ title: 'Roygbiv' });
|
const track = makeTrack({ title: 'Roygbiv' });
|
||||||
|
|
||||||
@@ -55,7 +52,7 @@ describe('TrackMenu', () => {
|
|||||||
expect(kebab.getAttribute('aria-expanded')).toBe('true');
|
expect(kebab.getAttribute('aria-expanded')).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('opening menu shows all 9 entries for an admin user', async () => {
|
test('opening menu shows all 8 entries', async () => {
|
||||||
render(TrackMenu, { props: { track } });
|
render(TrackMenu, { props: { track } });
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||||
|
|
||||||
@@ -63,19 +60,12 @@ describe('TrackMenu', () => {
|
|||||||
expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('menuitem', { name: /start radio/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getAllByRole('menuitem')).toHaveLength(9);
|
// No "Remove from library" — destructive removal is intentionally absent.
|
||||||
});
|
|
||||||
|
|
||||||
test('"Remove from library" hidden for non-admin', async () => {
|
|
||||||
userState.current = { id: 'u2', username: 'normal', is_admin: false };
|
|
||||||
render(TrackMenu, { props: { track } });
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
|
||||||
expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument();
|
||||||
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
|
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
|
||||||
});
|
});
|
||||||
@@ -113,10 +103,17 @@ describe('TrackMenu', () => {
|
|||||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking "Flag this track…" opens FlagPopover and closes menu', async () => {
|
test('"Start radio" dispatches playRadio(track.id)', async () => {
|
||||||
render(TrackMenu, { props: { track } });
|
render(TrackMenu, { props: { track } });
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||||
await fireEvent.click(screen.getByRole('menuitem', { name: /flag this track/i }));
|
await fireEvent.click(screen.getByRole('menuitem', { name: /start radio/i }));
|
||||||
|
expect(playRadio).toHaveBeenCalledWith(track.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking "Hide" opens the flag/quarantine popover and closes menu', async () => {
|
||||||
|
render(TrackMenu, { props: { track } });
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||||
|
await fireEvent.click(screen.getByRole('menuitem', { name: /^hide$/i }));
|
||||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('dialog', { name: /flag this track as broken/i })
|
screen.getByRole('dialog', { name: /flag this track as broken/i })
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||||
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
|
import TrackMenu from '$lib/components/TrackMenu.svelte';
|
||||||
import QueueList from '$lib/components/QueueList.svelte';
|
import QueueList from '$lib/components/QueueList.svelte';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
|
|
||||||
@@ -78,6 +79,15 @@
|
|||||||
<ArrowLeft size={22} strokeWidth={1.5} />
|
<ArrowLeft size={22} strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
<div class="ml-2 text-xs uppercase tracking-wide text-text-secondary">Now playing</div>
|
<div class="ml-2 text-xs uppercase tracking-wide text-text-secondary">Now playing</div>
|
||||||
|
<!-- Full-screen Now Playing carries the same kebab as Android's
|
||||||
|
NowPlayingScreen (hideQueueActions — the menu's track IS the one
|
||||||
|
playing), so Start radio / Add to playlist / Hide etc. are reachable
|
||||||
|
here, not just from the mini-player bar. -->
|
||||||
|
{#if current}
|
||||||
|
<div class="ml-auto">
|
||||||
|
<TrackMenu track={current} hideQueueActions />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if !current}
|
{#if !current}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ vi.mock('$lib/api/likes', () => ({
|
|||||||
unlikeEntity: vi.fn()
|
unlikeEntity: vi.fn()
|
||||||
}));
|
}));
|
||||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||||
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
|
|
||||||
|
|
||||||
import PlaylistDetailPage from './+page.svelte';
|
import PlaylistDetailPage from './+page.svelte';
|
||||||
import { createPlaylistQuery, deletePlaylist, refreshSystem } from '$lib/api/playlists';
|
import { createPlaylistQuery, deletePlaylist, refreshSystem } from '$lib/api/playlists';
|
||||||
|
|||||||
Reference in New Issue
Block a user