Merge pull request 'Web UI: Most Played hover fix, narrower seek bar, Android-parity track kebab' (#99) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-web / test (push) Successful in 35s
release / Build + push container image (push) Successful in 1m50s

This commit was merged in pull request #99.
This commit is contained in:
2026-06-14 22:26:52 -04:00
12 changed files with 88 additions and 390 deletions
-64
View File
@@ -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'
});
});
});
-42
View File
@@ -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;
}
+27 -11
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import type { TrackRef } from '$lib/api/types';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
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 +
// artist on the right. Mirrors Android's CompactTrackTile so the
@@ -42,7 +43,7 @@
type="button"
aria-label={`Play ${track.title}`}
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"
>
<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>
</button>
<CardActionCluster
likeEntityType="track"
likeEntityId={track.id}
onAdd={onAddToQueue}
addLabel={`Add ${track.title} to queue`}
<!-- Single inline cluster, vertically centred on the right. The shared
CardActionCluster splits Like+Add (top) and the menu (bottom) into
opposite corners — correct for the tall square Album/Artist cards but
it makes the two groups collide on this short one-line row. A compact
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()}
<TrackMenu {track} />
{/snippet}
</CardActionCluster>
<LikeButton entityType="track" entityId={track.id} size="sm" />
<button
type="button"
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>
@@ -10,10 +10,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
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/auth/store.svelte', () => ({
+10 -3
View File
@@ -272,8 +272,12 @@
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"
>
<!-- Left: cover + title + artist + like + menu -->
<div class="flex w-72 min-w-0 items-center gap-3">
<!-- Left: cover + title + artist + like + menu.
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
href="/now-playing"
aria-label="Open now playing"
@@ -318,7 +322,10 @@
</button>
</div>
{: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 -->
<div class="flex items-center justify-center gap-3">
<button
@@ -21,10 +21,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
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', () => ({
playNext: 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);
});
});
+26 -43
View File
@@ -1,30 +1,27 @@
<script lang="ts">
import {
MoreVertical,
ListPlus,
Plus,
ListVideo,
ListMusic,
Heart,
HeartOff,
ListMusic,
Album,
ListPlus,
Radio,
Disc3,
Flag,
User,
EyeOff,
Eye,
Trash2
Eye
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query';
import FlagPopover from './FlagPopover.svelte';
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import { user } from '$lib/auth/store.svelte';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
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';
let {
@@ -44,7 +41,6 @@
let menuOpen = $state(false);
let flagOpen = $state(false);
let removeOpen = $state(false);
let addOpen = $state(false);
const queryClient = useQueryClient();
@@ -63,8 +59,6 @@
return rows.some((r) => r.track_id === track.id);
});
const isAdmin = $derived(user.value?.is_admin === true);
function toggleMenu(e: MouseEvent) {
e.stopPropagation();
menuOpen = !menuOpen;
@@ -73,7 +67,6 @@
function closeAll() {
menuOpen = false;
flagOpen = false;
removeOpen = false;
addOpen = false;
}
@@ -87,6 +80,11 @@
closeAll();
}
function onStartRadio() {
playRadio(track.id);
closeAll();
}
async function onToggleLike() {
closeAll();
if (liked) {
@@ -119,18 +117,8 @@
goto(`/artists/${track.artist_id}`);
}
function onFlag() {
menuOpen = false;
flagOpen = true;
}
function onRemoveOpen() {
menuOpen = false;
removeOpen = true;
}
function onKeydown(e: KeyboardEvent) {
if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return;
if (!menuOpen && !flagOpen && !addOpen) return;
if (e.key === 'Escape') {
e.preventDefault();
closeAll();
@@ -161,9 +149,16 @@
onclick={(e) => e.stopPropagation()}
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}
<TrackMenuItem icon={ListPlus} label="Play next" onclick={onPlayNext} />
<TrackMenuItem icon={Plus} label="Add to queue" onclick={onAddToQueue} />
<TrackMenuItem icon={ListVideo} label="Play next" onclick={onPlayNext} />
<TrackMenuItem icon={ListMusic} label="Add to queue" onclick={onAddToQueue} />
<TrackMenuDivider />
{/if}
@@ -174,32 +169,24 @@
onclick={onToggleLike}
/>
<TrackMenuItem
icon={ListMusic}
icon={ListPlus}
label="Add to playlist…"
onclick={() => { addOpen = true; menuOpen = false; }}
/>
<TrackMenuItem icon={Radio} label="Start radio" onclick={onStartRadio} />
<TrackMenuDivider />
<TrackMenuItem icon={Album} label="Go to album" onclick={onGoToAlbum} />
<TrackMenuItem icon={Disc3} label="Go to artist" onclick={onGoToArtist} />
<TrackMenuItem icon={Disc3} label="Go to album" onclick={onGoToAlbum} />
<TrackMenuItem icon={User} label="Go to artist" onclick={onGoToArtist} />
<TrackMenuDivider />
<TrackMenuItem icon={Flag} label="Flag this track…" onclick={onFlag} />
<TrackMenuItem
icon={hidden ? Eye : EyeOff}
label={hidden ? 'Unhide' : 'Hide'}
onclick={onToggleHide}
/>
{#if isAdmin}
<TrackMenuItem
icon={Trash2}
label="Remove from library"
onclick={onRemoveOpen}
danger
/>
{/if}
</div>
{/if}
@@ -207,10 +194,6 @@
<FlagPopover {track} onClose={closeAll} />
{/if}
{#if removeOpen}
<RemoveTrackPopover {track} onClose={closeAll} />
{/if}
{#if addOpen}
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
{/if}
+15 -18
View File
@@ -25,19 +25,16 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
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/player/store.svelte', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn()
enqueueTrack: vi.fn(),
playRadio: vi.fn()
}));
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' });
@@ -55,7 +52,7 @@ describe('TrackMenu', () => {
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 } });
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: /^like$/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 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: /remove from library/i })).toBeInTheDocument();
expect(screen.getAllByRole('menuitem')).toHaveLength(9);
});
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 }));
// No "Remove from library" — destructive removal is intentionally absent.
expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument();
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
});
@@ -113,10 +103,17 @@ describe('TrackMenu', () => {
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 } });
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.getByRole('dialog', { name: /flag this track as broken/i })
+10
View File
@@ -16,6 +16,7 @@
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
import LikeButton from '$lib/components/LikeButton.svelte';
import TrackMenu from '$lib/components/TrackMenu.svelte';
import QueueList from '$lib/components/QueueList.svelte';
import { pageTitle } from '$lib/branding';
@@ -78,6 +79,15 @@
<ArrowLeft size={22} strokeWidth={1.5} />
</button>
<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>
{#if !current}
@@ -41,7 +41,6 @@ vi.mock('$lib/api/likes', () => ({
unlikeEntity: vi.fn()
}));
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
import PlaylistDetailPage from './+page.svelte';
import { createPlaylistQuery, deletePlaylist, refreshSystem } from '$lib/api/playlists';