Compare commits

..
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 918db27fec feat(web): shared collection header, Play and Shuffle, and the centred page column (M494)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 5m51s
release / android (push) Successful in 6m53s
release / Build signed APK (releases and dev) (push) Successful in 7m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
- CollectionHeader holds cover, title, facts, then Play and Shuffle, for
  album and playlist. The playlist page gains Play and Shuffle; it had
  neither. The cover uses the shared Cover component, so the playlist
  header also gets the fallback image.
- PlayActions is the one Play and Shuffle pair: filled Play, outlined
  Shuffle, on album, playlist and artist pages (operator, 2026-10-08).
  The artist page's round icon button said Play but shuffled; Play now
  keeps the server's order, and Shuffle shuffles.
- PageColumn is the playlist page's centred column, now used by album,
  playlist, Liked, History and Search → Tracks.
- The playlist page builds its queue with playlistTrackToRef, the shared
  converter, in place of its own copy. Its copy skipped only removed
  tracks, so a missing file (#2527) could still be queued from there.
- shuffled() in lib/utils is the one shuffle, replacing three private
  copies (album page, artist page, ArtistCard).
- The album's refetch error now uses text-error-fg. `text-oxblood` is
  not a colour in the theme, so it never rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 aaa6fa15d6 test(web): import history components after their mocks (M494)
HistoryRow now renders LikeButton, and both history test files imported
the component before the likes mock helper. LikeButton then loaded while
emptyLikesMock was still uninitialised, and the suites failed at import.
Every other suite imports components after its mocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 b7f50c42a9 feat(web): one track-list row shape, and drag reorder that snaps (M494, #5393)
release / govulncheck (push) Successful in 34s
release / go (push) Successful in 1m37s
release / web (push) Failing after 1m20s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m38s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m49s
Every track list (album, Liked, Search, Search → Tracks, playlist,
History) now draws its rows with TrackListRow inside a TrackList box,
in place of four row components that had drifted apart:

- TrackListRow owns the look: lead column, title over "artist · album",
  like, radio, add to queue, menu, duration, then a trailing slot. It
  also owns the stripe, hover, the now-playing band and the
  unavailable state. The stripe is dropped on now-playing and selected
  rows, where Tailwind would otherwise let `odd:` win.
- TrackRow, PlaylistTrackRow and HistoryRow are wrappers that keep only
  their behaviour: track number and multi-select; grip, remove and
  dead-entry states; cover and timestamp. Playlist and History rows
  gain radio, add and the menu, so every list offers the same actions.
- The radio control is Lucide's Radio icon, not the 📻 emoji.
- Enter on a control inside a row now belongs to that control. The old
  TrackRow played the track when Enter was pressed on "add to queue".
- TrackList has no fill, so the stripe shows on playlists too.

Drag reorder (#5393): neodrag kept the drop offset, so after the list
re-rendered the dragged row sat between two slots. reorderDrag, shared
by the playlist and queue rows:
- snaps the row to whole rows while dragging;
- resets it to its slot on drop;
- on playlists, gives the touch gesture to the grip only, so the list
  still scrolls by touch.
The playlist page reorders optimistically and rolls back on error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:06 -04:00
bvandeusenandClaude Opus 5.5 e16d182417 fix(web): drop the shelf-header accent rule Tailwind 4 started drawing (#5021)
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 4m47s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
The rule after each shelf title used `after:bg-accent/60`. Tailwind 3
cannot put an opacity modifier on a `var()` colour, so it emitted nothing
for that class and the rule never showed. Tailwind 4 mixes the opacity
in with color-mix, so the rule appeared after the upgrade. Removed, so
the headers look as they did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:17:15 -04:00
bvandeusenandClaude Opus 5.5 ca9917d8cd chore(web): clear kit 3's alias and Vite's config-loader deprecations (#5021)
release / govulncheck (push) Successful in 23s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 4m49s
release / android (push) Successful in 5m32s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
CI run 8789 warned on both:

- kit 3 deprecates `config.alias`. The $test-utils alias becomes a
  `#test-utils/*` subpath import, matching #lib, and its 43 import sites
  move with it.
- Vite's coming native config loader needs the extension on
  vitest.config.ts's import of vite.config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:03:38 -04:00
bvandeusenandClaude Opus 5.5 b1b10b0c77 fix(web): run Tailwind 4 through @tailwindcss/vite, not PostCSS (#5021)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 56s
release / go (push) Successful in 2m22s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
The image build on fecd030b failed in `vite build`:

  [postcss] ENOENT: no such file or directory, open '/web/tailwindcss'

With a PostCSS config present, Vite's own @import inliner resolves
`@import 'tailwindcss'` before @tailwindcss/postcss sees it, and reads it
as a relative file. svelte-check and Vitest never build CSS, so only the
image job caught it. The Vite plugin is Tailwind's documented setup for
Vite projects and handles the import itself, so postcss.config.cjs and
the direct postcss and @tailwindcss/postcss dependencies go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:55:48 -04:00
bvandeusenandClaude Opus 5.5 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:

- svelte.config.js is gone. Its options move into sveltekit() in
  vite.config.ts, exported as kitOptions so vitest.config.ts runs the
  same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
  src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
  ^8.0.12, svelte-check ^4.7.5.

By hand, from the codemod's list of non-automated tasks:

- goto's replaceState option is now replace; keepFocus becomes
  reset: false. For the search typeahead, reset: false also stops the
  scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
  base/assets, which kit 3 removed, and mock refreshAll in place of
  invalidateAll.
- The other flagged files only read page.url or goto internal routes,
  so they needed no change.

TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.

With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:50:39 -04:00
bvandeusen beba5b2082 Merge remote-tracking branch 'origin/renovate/sveltejs-kit-3.x' into dev 2026-10-08 10:49:44 -04:00
bvandeusenandClaude Opus 5.5 fecd030b68 chore(deps): Tailwind 4 (#5021)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m53s
release / integration (push) Successful in 5m37s
release / android (push) Successful in 6m54s
release / Build signed APK (releases and dev) (push) Successful in 7m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Failing after 34s
release / Verify release artifacts (tag releases only) (push) Skipped
Renovate's tailwindcss bump (PR #150) plus the migration it needs:

- Theme moves from tailwind.config.js into app.css as `@theme inline`,
  mapping the same FabledSword tokens. tailwind.config.js is gone.
- PostCSS runs @tailwindcss/postcss; autoprefixer is dropped, since
  Tailwind 4 prefixes through Lightning CSS.
- Class renames from @tailwindcss/upgrade 4.3.3, reviewed: outline-none
  -> outline-hidden, focus-visible:outline -> outline-solid, shadow ->
  shadow-sm, shadow-sm -> shadow-xs, flex-shrink-0 -> shrink-0. Bare
  `rounded` stays: v4 keeps it at 0.25rem, as before.
- Three v3 preflight defaults kept in a base layer so nothing changes on
  screen: gray-200 default border colour, gray-400 placeholder text and
  the pointer cursor on buttons.
- The unused class-based dark variant is not carried over; no template
  uses `dark:`.

Clears the five high and two moderate npm audit findings that came in
through Tailwind 3 (braces, chokidar, micromatch, fast-glob,
postcss-selector-parser).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:46:07 -04:00
Renovate Bot 0500f6aac5 chore(deps): update dependency @sveltejs/kit to v3
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:45:01 +00:00
Renovate Bot c83933b61a chore(deps): update dependency @sveltejs/adapter-static to v4
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:45:00 +00:00
205 changed files with 2824 additions and 2612 deletions
+7 -6
View File
@@ -295,12 +295,13 @@ jobs:
- name: Install deps
run: npm ci
# What ships to browsers: `dependencies` and the runtime they pull in
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
# kit's dev server) is left out because none of it reaches a user, and
# its open advisories need major-version upgrades tracked separately.
- name: npm audit (shipped dependencies)
run: npm audit --omit=dev --audit-level=moderate
# The whole tree, build and test tooling included. Until #5021 this
# audited only what ships to browsers (`--omit=dev`), because vite,
# vitest, tailwind and kit carried advisories that needed major
# upgrades. Those upgrades landed and the full tree audits clean, so
# the tooling that builds the shipped bundle is held to the same bar.
- name: npm audit (all dependencies)
run: npm audit --audit-level=moderate
- name: Type-check + svelte-check
run: npm run check
+1 -1
View File
@@ -94,7 +94,7 @@ Keystore, so a copy of the app's files doesn't yield a usable session.
Nothing is published unless every check passes: the Go, integration, web and
Android test suites, `govulncheck` against the toolchain that builds the
image, and `npm audit` on the packages that ship to the browser. See
image, and `npm audit` on every web dependency, build tooling included. See
`.gitea/workflows/release.yml`.
## Reporting a problem
+668 -1062
View File
File diff suppressed because it is too large Load Diff
+12 -9
View File
@@ -12,25 +12,28 @@
"tokens": "node scripts/tokens-to-css.js"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/adapter-static": "^4.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/node": "^25.9.1",
"autoprefixer": "^10.5.0",
"jsdom": "^25.0.1",
"postcss": "^8.5.15",
"svelte": "^5.1.9",
"svelte-check": "^4.4.8",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.3",
"vite": "^8.0.0",
"svelte": "^5.57.1",
"svelte-check": "^4.7.5",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.0",
"vite": "^8.0.12",
"vitest": "^5.0.0"
},
"dependencies": {
"@neodrag/svelte": "^2.3.3",
"@tanstack/svelte-query": "^5.90.2",
"lucide-svelte": "^1.0.1"
},
"imports": {
"#lib/*": "./src/lib/*",
"#test-utils/*": "./src/test-utils/*"
}
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+71 -4
View File
@@ -1,8 +1,75 @@
@import './lib/styles/tokens.generated.css';
@import './lib/styles/tokens.generated.css' layer(base);
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
/* The FabledSword tokens as Tailwind's theme. `inline` makes each utility emit
* the var(--fs-*) itself, so a theme swap that redefines the tokens reaches the
* utilities without going through a second variable. */
@theme inline {
--color-background: var(--fs-obsidian);
--color-surface: var(--fs-iron);
--color-surface-hover: var(--fs-slate);
--color-border: var(--fs-pewter);
--color-text-primary: var(--fs-parchment);
--color-text-secondary: var(--fs-vellum);
--color-text-muted: var(--fs-ash);
--color-action-primary: var(--fs-moss);
--color-action-secondary: var(--fs-bronze);
--color-action-destructive: var(--fs-oxblood);
--color-action-fg: var(--fs-on-action);
--color-accent: var(--fs-accent);
--color-accent-tint: color-mix(in srgb, var(--fs-accent) 12%, transparent);
--color-accent-fg: var(--fs-accent-fg);
--color-warning: var(--fs-warning);
--color-warning-fg: var(--fs-warning-fg);
--color-error: var(--fs-error);
--color-error-fg: var(--fs-error-fg);
--color-info: var(--fs-info);
--color-info-fg: var(--fs-info-fg);
--radius-sm: var(--fs-radius-sm);
--radius-md: var(--fs-radius-md);
--radius-lg: var(--fs-radius-lg);
--radius-xl: var(--fs-radius-xl);
--font-display: var(--fs-font-display);
--font-sans: var(--fs-font-body);
--font-mono: var(--fs-font-mono);
}
/* Tailwind 3's preflight defaults that 4 changed (#5021). Kept so the upgrade
* changes nothing on screen; each can go once the elements relying on it name
* the value themselves.
* - A border with no colour utility was gray-200; 4 makes it currentcolor.
* - Placeholder text was gray-400; 4 makes it the text colour at 50%.
* - Buttons had a pointer cursor; 4 leaves the browser default. */
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
input::placeholder,
textarea::placeholder {
color: var(--color-gray-400);
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
}
:root {
color-scheme: dark;
+2 -2
View File
@@ -83,7 +83,7 @@ describe('api.get/post/del', () => {
describe('apiFetch 401 interceptor', () => {
test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
const logoutSpy = vi.fn();
vi.doMock('$lib/auth/store.svelte', () => ({
vi.doMock('#lib/auth/store.svelte.js', () => ({
logout: logoutSpy,
login: vi.fn(),
bootstrap: vi.fn(),
@@ -97,6 +97,6 @@ describe('apiFetch 401 interceptor', () => {
status: 401
});
expect(logoutSpy).toHaveBeenCalledWith({ silent: true });
vi.doUnmock('$lib/auth/store.svelte');
vi.doUnmock('#lib/auth/store.svelte.js');
});
});
+1 -1
View File
@@ -44,7 +44,7 @@ export async function apiFetch(path: string, init?: RequestInit): Promise<unknow
// Lazy import: auth/store imports from this file, so a top-level
// import would be circular. By the time any 401 actually happens
// at runtime, both modules have finished loading.
const { logout } = await import('$lib/auth/store.svelte');
const { logout } = await import('#lib/auth/store.svelte.js');
await logout({ silent: true });
}
// Two error envelope shapes ship in this codebase today:
+4 -4
View File
@@ -1,10 +1,10 @@
import { api, type User, type LoginResponse } from '$lib/api/client';
import { putMyTimezone } from '$lib/api/me';
import { queryClient } from '$lib/query/client';
import { api, type User, type LoginResponse } from '#lib/api/client.js';
import { putMyTimezone } from '#lib/api/me.js';
import { queryClient } from '#lib/query/client.js';
import { signalSessionEnd } from './sessionEnd.svelte';
import { user, setUser } from './user.svelte';
// Re-export so existing `import { user } from '$lib/auth/store.svelte'`
// Re-export so existing `import { user } from '#lib/auth/store.svelte'`
// callers keep working. New code can import directly from auth/user.svelte.
export { user };
+4 -4
View File
@@ -1,14 +1,14 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { apiClientMock } from '../../test-utils/mocks/client';
vi.mock('$lib/api/client', () => apiClientMock());
vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('$lib/query/client', () => ({
vi.mock('#lib/query/client.js', () => ({
queryClient: { clear: vi.fn() }
}));
import { api } from '$lib/api/client';
import { queryClient } from '$lib/query/client';
import { api } from '#lib/api/client.js';
import { queryClient } from '#lib/query/client.js';
import { bootstrap, login, logout, user } from './store.svelte';
beforeEach(() => {
+1 -1
View File
@@ -4,7 +4,7 @@
// component that needs the current user can import `user` from either
// this module or auth/store — auth/store re-exports it for backward
// compatibility.
import type { User } from '$lib/api/client';
import type { User } from '#lib/api/client.js';
let _user = $state<User | null>(null);
+6 -6
View File
@@ -7,9 +7,9 @@
updateAcoustIDSettings,
type AcoustIDOverview,
type AcoustIDUnsettled
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
// untagged tracks lack, so the similarity arms can see them. Off until the
@@ -125,7 +125,7 @@
const rowClass = 'flex items-center justify-between gap-4 py-2';
const inputClass =
'rounded-md border border-border bg-background px-3 py-1.5 text-sm text-text-primary ' +
'placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent';
'placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent';
</script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="acoustid-card">
@@ -238,7 +238,7 @@
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || !scoreOk}
onclick={save}
@@ -293,7 +293,7 @@
<button
type="button"
class="rounded-md border border-border px-4 py-2 text-sm text-text-primary hover:bg-background
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!overview.ready || overview.status.running || starting}
title={overview.ready ? undefined : 'Switch the lookup on and add a key first'}
+4 -4
View File
@@ -1,15 +1,15 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import type { AcoustIDOverview } from "$lib/api/admin";
import type { AcoustIDOverview } from "#lib/api/admin.js";
vi.mock("$lib/api/admin", () => ({
vi.mock("#lib/api/admin.js", () => ({
getAcoustID: vi.fn(),
updateAcoustIDSettings: vi.fn(),
runAcoustIDLookup: vi.fn(),
listUnsettledAcoustID: vi.fn(),
}));
vi.mock("$lib/stores/toast.svelte", () => ({ pushToast: vi.fn() }));
vi.mock("#lib/stores/toast.svelte.js", () => ({ pushToast: vi.fn() }));
import AcoustIDCard from "./AcoustIDCard.svelte";
import {
@@ -17,7 +17,7 @@ import {
listUnsettledAcoustID,
runAcoustIDLookup,
updateAcoustIDSettings,
} from "$lib/api/admin";
} from "#lib/api/admin.js";
const base: AcoustIDOverview = {
settings: { enabled: true, min_score: 0.85, api_key_set: true },
+5 -5
View File
@@ -6,9 +6,9 @@
revokeSession,
revokeOtherSessions,
type ActiveSession
} from '$lib/api/me';
import { errCode } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/me.js';
import { errCode } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Self-contained: nothing else in the app reads this data, so it holds its
// own state and reloads explicitly rather than joining the query cache.
@@ -143,7 +143,7 @@
<li class="flex items-start gap-3 py-3">
<MonitorSmartphone
size={18}
class="mt-0.5 flex-shrink-0 text-text-secondary"
class="mt-0.5 shrink-0 text-text-secondary"
aria-hidden="true"
/>
<div class="min-w-0 flex-1">
@@ -173,7 +173,7 @@
{#if !s.current}
<button
type="button"
class="flex-shrink-0 rounded border border-border px-2 py-1 text-sm
class="shrink-0 rounded border border-border px-2 py-1 text-sm
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
disabled={busy}
@@ -6,13 +6,13 @@ const listSessions = vi.fn();
const revokeSession = vi.fn();
const revokeOtherSessions = vi.fn();
vi.mock('$lib/api/me', () => ({
vi.mock('#lib/api/me.js', () => ({
listSessions: (...a: unknown[]) => listSessions(...a),
revokeSession: (...a: unknown[]) => revokeSession(...a),
revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...a)
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
type Row = {
id: string;
@@ -1,12 +1,12 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createPlaylistsQuery, appendTracks, createPlaylist } from '$lib/api/playlists';
import { qk } from '$lib/api/queries';
import { errMessage } from '$lib/api/errors';
import { createPlaylistsQuery, appendTracks, createPlaylist } from '#lib/api/playlists.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
let {
tracks,
@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
const playlistsData = vi.hoisted(() => ({
owned: [
@@ -43,11 +43,11 @@ const playlistsData = vi.hoisted(() => ({
public: [] as unknown[]
}));
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
}));
vi.mock('$lib/api/playlists', () => ({
vi.mock('#lib/api/playlists.js', () => ({
createPlaylistsQuery: () =>
readable({ data: playlistsData, isPending: false, isError: false }),
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
@@ -86,7 +86,7 @@ describe('AddToPlaylistMenu', () => {
test('clicking a playlist appends and closes', async () => {
const onClose = vi.fn();
const { appendTracks } = await import('$lib/api/playlists');
const { appendTracks } = await import('#lib/api/playlists.js');
render(AddToPlaylistMenu, { props: { tracks: [track], onClose } });
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { pageUrlModule } from '$test-utils/mocks/appState';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/admin')
+4 -4
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
import { api } from '$lib/api/client';
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
import type { AlbumRef, AlbumDetail } from '#lib/api/types.js';
import { api } from '#lib/api/client.js';
import { enqueueTracks, playQueue } from '#lib/player/store.svelte.js';
import { Play } from 'lucide-svelte';
import AlbumMenu from './AlbumMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte';
@@ -31,7 +31,7 @@
>
<div
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
shadow-sm transition-all duration-150
shadow-xs transition-all duration-150
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
>
<Cover
+10 -10
View File
@@ -1,27 +1,27 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types';
import { FALLBACK_COVER } from '$lib/media/covers';
import type { AlbumRef, AlbumDetail, TrackRef } from '#lib/api/types.js';
import { FALLBACK_COVER } from '#lib/media/covers.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { apiClientMock } from '../../test-utils/mocks/client';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
vi.mock('$lib/api/client', () => apiClientMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn(),
playQueue: vi.fn()
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/albums', async (orig) => {
vi.mock('#lib/api/albums.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
});
vi.mock('$lib/api/playlists', async (orig) => {
vi.mock('#lib/api/playlists.js', async (orig) => {
const { readable } = await import('svelte/store');
const actual = (await orig()) as Record<string, unknown>;
return {
@@ -34,8 +34,8 @@ vi.mock('$lib/api/playlists', async (orig) => {
});
import AlbumCard from './AlbumCard.svelte';
import { api } from '$lib/api/client';
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
import { api } from '#lib/api/client.js';
import { enqueueTracks, playQueue } from '#lib/player/store.svelte.js';
const album: AlbumRef = {
id: 'xyz',
+4 -4
View File
@@ -2,13 +2,13 @@
import { MoreVertical, Heart, HeartOff, ListMusic, Plus, Disc3 } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { useQueryClient } from '@tanstack/svelte-query';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
import { listAlbumTracks } from '$lib/api/albums';
import { enqueueTracks } from '$lib/player/store.svelte';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
import { listAlbumTracks } from '#lib/api/albums.js';
import { enqueueTracks } from '#lib/player/store.svelte.js';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
let {
albumId,
+9 -9
View File
@@ -2,28 +2,28 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
import type { TrackRef } from '$lib/api/types';
import { makeTracks } from '$test-utils/fixtures/track';
import type { TrackRef } from '#lib/api/types.js';
import { makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/albums', async (orig) => {
vi.mock('#lib/api/albums.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
});
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn()
}));
import AlbumMenu from './AlbumMenu.svelte';
import { enqueueTracks } from '$lib/player/store.svelte';
import { listAlbumTracks } from '$lib/api/albums';
import { likeEntity } from '$lib/api/likes';
import { enqueueTracks } from '#lib/player/store.svelte.js';
import { listAlbumTracks } from '#lib/api/albums.js';
import { likeEntity } from '#lib/api/likes.js';
import { goto } from '$app/navigation';
const TRACKS: TrackRef[] = makeTracks(2, {
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { ApiError } from '$lib/api/client';
import type { ApiError } from '#lib/api/client.js';
let {
error,
+6 -14
View File
@@ -1,32 +1,24 @@
<script lang="ts">
import type { ArtistRef, TrackRef } from '$lib/api/types';
import { api } from '$lib/api/client';
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
import { shuffled } from '#lib/utils/shuffle.js';
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
import { api } from '#lib/api/client.js';
import { playQueue, enqueueTracks } from '#lib/player/store.svelte.js';
import { Play } from 'lucide-svelte';
import ArtistMenu from './ArtistMenu.svelte';
import CardActionCluster from './CardActionCluster.svelte';
import Cover from './Cover.svelte';
import { listArtistTracks } from '$lib/api/artists';
import { listArtistTracks } from '#lib/api/artists.js';
let { artist }: { artist: ArtistRef } = $props();
let queueing = $state(false);
function shuffle<T>(items: T[]): T[] {
const arr = items.slice();
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
async function onPlayClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
if (tracks.length === 0) return;
playQueue(shuffle(tracks), 0);
playQueue(shuffled(tracks), 0);
}
async function onAddToQueue(e: MouseEvent) {
+10 -10
View File
@@ -2,21 +2,21 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { apiClientMock } from '../../test-utils/mocks/client';
import type { ArtistRef, TrackRef } from '$lib/api/types';
import { makeTrack, makeTracks } from '$test-utils/fixtures/track';
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
import { makeTrack, makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$lib/api/client', () => apiClientMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(),
enqueueTracks: vi.fn()
}));
vi.mock('$lib/api/artists', () => ({
vi.mock('#lib/api/artists.js', () => ({
listArtistTracks: vi.fn().mockResolvedValue([])
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
import ArtistCard from './ArtistCard.svelte';
import { api } from '$lib/api/client';
import { playQueue } from '$lib/player/store.svelte';
import { api } from '#lib/api/client.js';
import { playQueue } from '#lib/player/store.svelte.js';
const artist: ArtistRef = {
id: 'art-1',
@@ -82,7 +82,7 @@ describe('ArtistCard', () => {
const fakeTracks = [
makeTrack({ artist_id: 'art-1', artist_name: 'BoC' })
];
const { listArtistTracks } = await import('$lib/api/artists');
const { listArtistTracks } = await import('#lib/api/artists.js');
(listArtistTracks as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fakeTracks);
render(ArtistCard, { props: { artist } });
const btn = screen.getByRole('button', { name: /Add Boards of Canada to queue/i });
@@ -90,7 +90,7 @@ describe('ArtistCard', () => {
// Allow promise microtask to flush.
await new Promise((r) => setTimeout(r, 0));
expect(listArtistTracks).toHaveBeenCalledWith('art-1');
const { enqueueTracks } = await import('$lib/player/store.svelte');
const { enqueueTracks } = await import('#lib/player/store.svelte.js');
expect(enqueueTracks).toHaveBeenCalledWith(fakeTracks);
});
});
+4 -4
View File
@@ -1,13 +1,13 @@
<script lang="ts">
import { MoreVertical, Heart, HeartOff, ListMusic, Plus } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
import { listArtistTracks } from '$lib/api/artists';
import { enqueueTracks } from '$lib/player/store.svelte';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
import { listArtistTracks } from '#lib/api/artists.js';
import { enqueueTracks } from '#lib/player/store.svelte.js';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
let {
artistId,
+9 -9
View File
@@ -2,27 +2,27 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
import type { TrackRef } from '$lib/api/types';
import { makeTracks } from '$test-utils/fixtures/track';
import type { TrackRef } from '#lib/api/types.js';
import { makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/artists', () => ({
vi.mock('#lib/api/artists.js', () => ({
listArtistTracks: vi.fn().mockResolvedValue([])
}));
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn()
}));
import ArtistMenu from './ArtistMenu.svelte';
import { enqueueTracks } from '$lib/player/store.svelte';
import { listArtistTracks } from '$lib/api/artists';
import { likeEntity, unlikeEntity } from '$lib/api/likes';
import { enqueueTracks } from '#lib/player/store.svelte.js';
import { listArtistTracks } from '#lib/api/artists.js';
import { likeEntity, unlikeEntity } from '#lib/api/likes.js';
const TRACKS: TrackRef[] = makeTracks(2, {
album_id: 'al1', artist_id: 'a1', artist_name: 'Test'
@@ -0,0 +1,60 @@
<script lang="ts">
// The header of every page that is one collection of tracks, album and
// playlist (milestone 494): cover, title, a line or two of facts, then
// Play and Shuffle (PlayActions).
import type { Snippet } from 'svelte';
import PlayActions from './PlayActions.svelte';
import Cover from './Cover.svelte';
let {
title,
coverUrl,
coverAlt = '',
playDisabled = false,
onPlay,
onShuffle,
titleExtra,
tools,
children,
actions
}: {
title: string;
coverUrl: string | null | undefined;
coverAlt?: string;
playDisabled?: boolean;
onPlay: () => void;
onShuffle: () => void;
/** Beside the title, e.g. the album's like button. */
titleExtra?: Snippet;
/** Top right: refresh, link, edit, delete. */
tools?: Snippet;
/** The facts under the title. */
children?: Snippet;
/** After Play and Shuffle, e.g. an admin's Refetch cover. */
actions?: Snippet;
} = $props();
</script>
<header class="mb-6 flex items-start gap-4">
<div class="h-32 w-32 shrink-0 overflow-hidden rounded-md md:h-40 md:w-40">
<Cover src={coverUrl ?? undefined} alt={coverAlt} />
</div>
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start gap-3">
<h1 class="min-w-0 truncate font-display text-2xl font-medium text-text-primary">{title}</h1>
{#if titleExtra}{@render titleExtra()}{/if}
{#if tools}
<div class="ml-auto flex shrink-0 items-center gap-1">{@render tools()}</div>
{/if}
</div>
{#if children}{@render children()}{/if}
<div class="pt-1">
<PlayActions name={title} disabled={playDisabled} {onPlay} {onShuffle}>
{#if actions}{@render actions()}{/if}
</PlayActions>
</div>
</div>
</header>
@@ -1,8 +1,8 @@
<script lang="ts">
import { Plus } from 'lucide-svelte';
import type { TrackRef } from '$lib/api/types';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
import { coverUrl } from '$lib/media/covers';
import type { TrackRef } from '#lib/api/types.js';
import { playQueue, enqueueTrack } from '#lib/player/store.svelte.js';
import { coverUrl } from '#lib/media/covers.js';
import TrackMenu from './TrackMenu.svelte';
import LikeButton from './LikeButton.svelte';
import Cover from './Cover.svelte';
@@ -43,7 +43,7 @@
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">
<div class="h-12 w-12 shrink-0 overflow-hidden rounded bg-surface-hover">
<Cover src={cover} />
</div>
<div class="min-w-0 flex-1">
@@ -1,31 +1,31 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import { makeTracks } from '$test-utils/fixtures/track';
import { makeTracks } from '#test-utils/fixtures/track.js';
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { get value() { return { id: 'u1', username: 'u', is_admin: false }; } }
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(),
enqueueTrack: vi.fn(),
playNext: vi.fn()
}));
import CompactTrackCard from './CompactTrackCard.svelte';
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
import { playQueue, enqueueTrack } from '#lib/player/store.svelte.js';
const titles = ['First', 'Second', 'Third'];
const tracks: TrackRef[] = makeTracks(3, { artist_name: 'Artist' }).map((t, i) => ({
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { FALLBACK_COVER } from '$lib/media/covers';
import { FALLBACK_COVER } from '#lib/media/covers.js';
import { Disc3 } from 'lucide-svelte';
// Shared cover-artwork renderer. The web counterpart of Android's
@@ -86,7 +86,7 @@
<button
type="button"
aria-label={`Bring ${title} back`}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-hidden focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze?.()}
>
Undo
@@ -112,7 +112,7 @@
type="button"
aria-label={`Not right now — hide ${title} for a while`}
title="Not right now"
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
onclick={() => onSnooze?.()}
>
<Clock size={16} strokeWidth={1} />
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { pageUrlModule } from '$test-utils/mocks/appState';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/discover')
@@ -5,9 +5,9 @@
getFingerprintSettings,
updateFingerprintSettings,
type FingerprintSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
// Lives on the Duplicates page, beside the results these settings shape.
@@ -83,7 +83,7 @@
const inputClass =
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
</script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
@@ -183,7 +183,7 @@
class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary"
data-testid="length-warning"
>
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
Saving re-fingerprints {libraryTotal > 0
? `all ${libraryTotal.toLocaleString()} tracks`
@@ -206,7 +206,7 @@
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || problems.length > 0}
onclick={save}
@@ -1,18 +1,18 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { FingerprintSettings } from '$lib/api/admin';
import { ERROR_COPY } from '$lib/api/error-copy';
import type { FingerprintSettings } from '#lib/api/admin.js';
import { ERROR_COPY } from '#lib/api/error-copy.js';
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getFingerprintSettings: vi.fn(),
updateFingerprintSettings: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import FingerprintSettingsCard from './FingerprintSettingsCard.svelte';
import { getFingerprintSettings, updateFingerprintSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
import { getFingerprintSettings, updateFingerprintSettings } from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
const base: FingerprintSettings = {
enabled: true,
+6 -6
View File
@@ -2,10 +2,10 @@
import { untrack } from 'svelte';
import { Flag } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { flagTrack } from '$lib/api/quarantine';
import { errCode } from '$lib/api/errors';
import { qk } from '$lib/api/queries';
import type { TrackRef, LidarrQuarantineReason } from '$lib/api/types';
import { flagTrack } from '#lib/api/quarantine.js';
import { errCode } from '#lib/api/errors.js';
import { qk } from '#lib/api/queries.js';
import type { TrackRef, LidarrQuarantineReason } from '#lib/api/types.js';
let {
track,
@@ -59,7 +59,7 @@
<span class="block text-xs text-text-secondary">Reason</span>
<select
bind:value={reason}
class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
>
<option value="bad_rip">Bad rip</option>
<option value="wrong_file">Wrong file</option>
@@ -74,7 +74,7 @@
bind:value={notes}
maxlength="200"
placeholder="What's wrong with it?"
class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
class="mt-1 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
rows="2"
></textarea>
</label>
+3 -3
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import FlagPopover from './FlagPopover.svelte';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
const invalidateMock = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
@@ -12,11 +12,11 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
};
});
vi.mock('$lib/api/quarantine', () => ({
vi.mock('#lib/api/quarantine.js', () => ({
flagTrack: vi.fn().mockResolvedValue({ track_id: 't1', reason: 'bad_rip' })
}));
import { flagTrack } from '$lib/api/quarantine';
import { flagTrack } from '#lib/api/quarantine.js';
const track = makeTrack();
+22 -22
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import type { HistoryEvent } from '$lib/api/history';
import { playQueue } from '$lib/player/store.svelte';
import { coverUrl } from '$lib/media/covers';
import type { HistoryEvent } from '#lib/api/history.js';
import { playQueue } from '#lib/player/store.svelte.js';
import { coverUrl } from '#lib/media/covers.js';
import TrackListRow from './TrackListRow.svelte';
import Cover from './Cover.svelte';
let { event } = $props<{ event: HistoryEvent }>();
@@ -40,25 +42,23 @@
const tooltip = $derived(new Date(event.played_at).toISOString());
</script>
<button
type="button"
onclick={handleClick}
aria-label={`Play ${event.track.title}`}
class="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-surface-hover"
<TrackListRow
title={event.track.title}
subtitle={`${event.track.artist_name} · ${event.track.album_title}`}
durationSec={event.track.duration_sec}
track={event.track}
ariaLabel={`Play ${event.track.title}`}
onActivate={handleClick}
>
<img
src={coverUrl(event.track.album_id)}
alt=""
class="h-11 w-11 flex-shrink-0 rounded object-cover"
loading="lazy"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-text-primary">{event.track.title}</div>
<div class="truncate text-xs text-text-secondary">
{event.track.artist_name} · {event.track.album_title}
</div>
</div>
<span title={tooltip} class="flex-shrink-0 text-xs text-text-secondary">
{#snippet lead()}
<span class="block h-8 w-8 overflow-hidden rounded">
<Cover src={coverUrl(event.track.album_id)} />
</span>
{/snippet}
{#snippet trailing()}
<span title={tooltip} class="w-16 shrink-0 text-right text-xs text-text-secondary">
{timestamp}
</span>
</button>
{/snippet}
</TrackListRow>
+32 -5
View File
@@ -1,15 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import HistoryRow from './HistoryRow.svelte';
import type { HistoryEvent } from '$lib/api/history';
import { makeTrack } from '$test-utils/fixtures/track';
import type { HistoryEvent } from '#lib/api/history.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
const playQueue = vi.fn();
vi.mock('$lib/player/store.svelte', () => ({
playQueue: (...args: unknown[]) => playQueue(...args)
// The row is a TrackListRow, so LikeButton and TrackMenu render too.
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: (...args: unknown[]) => playQueue(...args),
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playRadio: vi.fn(),
player: { current: undefined }
}));
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { value: { id: 'u1', username: 'me', is_admin: false } }
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
// Imported after the mocks: the component pulls in LikeButton, whose
// mocked module must already be initialised.
import HistoryRow from './HistoryRow.svelte';
const sampleTrack = makeTrack({
title: 'Song Title',
album_title: 'Album',
@@ -41,6 +61,13 @@ describe('HistoryRow', () => {
expect(playQueue).toHaveBeenCalledWith([sampleTrack], 0);
});
it('carries the shared row controls: like, radio, add and the menu', () => {
render(HistoryRow, { props: { event: sampleEvent } });
expect(screen.getByRole('button', { name: /play radio/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add song title to queue/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /track actions for/i })).toBeInTheDocument();
});
it('renders a relative timestamp for events under 1 hour ago', () => {
render(HistoryRow, { props: { event: sampleEvent } });
expect(screen.getByText(/\d+m ago/)).toBeInTheDocument();
@@ -62,14 +62,8 @@
<div class="section" aria-label={ariaLabel}>
<header class="section-header">
{#if title}
<!-- Section header: Fraunces (font-display) at 24px with an
inline accent rule that bleeds out from the title — gives
each shelf a clear chapter break without adding chrome. -->
<h2
class="font-display text-2xl font-medium text-text-primary
flex items-baseline gap-3 after:h-[2px] after:w-12 after:flex-shrink-0
after:rounded-full after:bg-accent/60 after:content-['']"
>{title}</h2>
<!-- Section header: Fraunces (font-display) at 24px. -->
<h2 class="font-display text-2xl font-medium text-text-primary">{title}</h2>
{:else}
<span></span>
{/if}
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { createLikedIdsQuery, likeEntity, unlikeEntity, type EntityKind } from '$lib/api/likes';
import { createLikedIdsQuery, likeEntity, unlikeEntity, type EntityKind } from '#lib/api/likes.js';
let {
entityType,
+2 -2
View File
@@ -6,14 +6,14 @@ const cacheState = vi.hoisted(() => ({
data: { track_ids: ['t1'] as string[], album_ids: [] as string[], artist_ids: [] as string[] }
}));
vi.mock('$lib/api/likes', () => ({
vi.mock('#lib/api/likes.js', () => ({
createLikedIdsQuery: () => readable({ data: cacheState.data, isPending: false, isError: false }),
likeEntity: vi.fn().mockResolvedValue(undefined),
unlikeEntity: vi.fn().mockResolvedValue(undefined)
}));
import LikeButton from './LikeButton.svelte';
import { likeEntity, unlikeEntity } from '$lib/api/likes';
import { likeEntity, unlikeEntity } from '#lib/api/likes.js';
afterEach(() => {
vi.clearAllMocks();
@@ -6,9 +6,9 @@
updateLoudnessSettings,
type LoudnessCoverage,
type LoudnessSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Loudness analysis (#4995): the background measurement that loudness
// normalization levels playback from, its progress, and its knobs. The
@@ -79,7 +79,7 @@
const inputClass =
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
</script>
<section class="space-y-4 rounded border border-border bg-surface p-4">
@@ -185,7 +185,7 @@
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || !valid}
onclick={save}
@@ -1,18 +1,18 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { LoudnessCoverage, LoudnessSettings } from '$lib/api/admin';
import type { LoudnessCoverage, LoudnessSettings } from '#lib/api/admin.js';
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getLoudnessSettings: vi.fn(),
updateLoudnessSettings: vi.fn(),
getLoudnessCoverage: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import LoudnessSettingsCard from './LoudnessSettingsCard.svelte';
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
import { getLoudnessCoverage, getLoudnessSettings, updateLoudnessSettings } from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
const base: LoudnessSettings = { enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 };
const coverage: LoudnessCoverage = {
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Smartphone } from 'lucide-svelte';
import { api } from '$lib/api/client';
import { api } from '#lib/api/client.js';
// Polls /api/client/version once on mount. Renders nothing on 404
// (no bundled APK — graceful degradation in dev / pre-CI images)
@@ -5,8 +5,8 @@
getNetworkSettings,
updateNetworkSettings,
type NetworkSettings
} from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
let settings = $state<NetworkSettings | null>(null);
let hops = $state(1);
@@ -77,7 +77,7 @@
max={settings.max_hops}
bind:value={hops}
class="w-24 rounded border border-border bg-background px-2 py-1
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
</label>
<button
@@ -116,7 +116,7 @@
<div class="space-y-2 rounded border border-border bg-background p-3 text-sm">
<p class="flex items-start gap-2 text-text-secondary">
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
Count your proxies — don't guess high. This number tells Minstrel how much of the
<span class="font-mono">X-Forwarded-For</span> header to believe, and that header is
@@ -5,12 +5,12 @@ import NetworkSettingsCard from './NetworkSettingsCard.svelte';
const getNetworkSettings = vi.fn();
const updateNetworkSettings = vi.fn();
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getNetworkSettings: () => getNetworkSettings(),
updateNetworkSettings: (hops: number) => updateNetworkSettings(hops)
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
function settings(over: Record<string, unknown> = {}) {
return {
@@ -4,13 +4,13 @@
NORMALIZATION_MODES,
NORMALIZATION_TARGETS,
type NormalizationPrefs
} from '$lib/api/normalization';
} from '#lib/api/normalization.js';
import {
normalization,
loadNormalization,
setNormalization
} from '$lib/stores/normalization.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/stores/normalization.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Volume leveling (M464 #4998). The choice is the user's, stored on the
// server, so it follows them to the Android app and to casts.
@@ -1,17 +1,17 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
vi.mock('$lib/api/normalization', async (importOriginal) => ({
...(await importOriginal<typeof import('$lib/api/normalization')>()),
vi.mock('#lib/api/normalization.js', async (importOriginal) => ({
...(await importOriginal<typeof import('#lib/api/normalization.js')>()),
getNormalization: vi.fn(),
putNormalization: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import NormalizationSetting from './NormalizationSetting.svelte';
import { getNormalization, putNormalization } from '$lib/api/normalization';
import { normalization } from '$lib/stores/normalization.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
import { getNormalization, putNormalization } from '#lib/api/normalization.js';
import { normalization } from '#lib/stores/normalization.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
afterEach(() => {
vi.clearAllMocks();
@@ -9,9 +9,9 @@
markAllNotificationsRead,
markNotificationRead,
type NotificationItem
} from '$lib/api/notifications';
import { relativeTime } from '$lib/utils/relativeTime';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/notifications.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// The notifications inbox (M489): a bell in the header with an unread badge,
// opening a panel of what happened while you were away.
@@ -67,7 +67,7 @@
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (open = false)} />
<div class="relative flex-shrink-0">
<div class="relative shrink-0">
<button
bind:this={buttonRef}
type="button"
@@ -92,7 +92,7 @@
{#if open}
<div
bind:this={panelRef}
class="absolute right-0 z-50 mt-1 flex max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] flex-col rounded border border-border bg-surface shadow"
class="absolute right-0 z-50 mt-1 flex max-h-[70vh] w-[min(22rem,calc(100vw-2rem))] flex-col rounded border border-border bg-surface shadow-sm"
role="dialog"
aria-label="Notifications"
>
@@ -123,7 +123,7 @@
onclick={() => openItem(item)}
>
<span
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full {item.read_at ? '' : 'bg-text-primary'}"
class="mt-1.5 h-2 w-2 shrink-0 rounded-full {item.read_at ? '' : 'bg-text-primary'}"
aria-hidden="true"
></span>
<span class="min-w-0 flex-1">
@@ -8,7 +8,7 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
});
vi.mock('$lib/api/notifications', async (orig) => {
vi.mock('#lib/api/notifications.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
badgeLabel: actual.badgeLabel,
@@ -19,7 +19,7 @@ vi.mock('$lib/api/notifications', async (orig) => {
};
});
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import NotificationBell from './NotificationBell.svelte';
import { goto } from '$app/navigation';
@@ -30,7 +30,7 @@ import {
markAllNotificationsRead,
markNotificationRead,
type NotificationItem
} from '$lib/api/notifications';
} from '#lib/api/notifications.js';
function item(over: Partial<NotificationItem> = {}): NotificationItem {
return {
@@ -4,9 +4,9 @@
getNotificationEmailSettings,
updateNotificationEmailSettings,
type NotificationEmailSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// When notification emails go out (M489 #5346). Nothing is emailed per
// event: new music is a daily summary, everything else a batch.
@@ -84,7 +84,7 @@
<select
bind:value={form.summary_hour}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
>
{#each hours as h (h)}
@@ -104,7 +104,7 @@
max="1440"
bind:value={form.batch_window_minutes}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -114,7 +114,7 @@
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving}
onclick={save}
@@ -1,16 +1,16 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getNotificationEmailSettings: vi.fn(),
updateNotificationEmailSettings: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import NotificationEmailCard from './NotificationEmailCard.svelte';
import { getNotificationEmailSettings, updateNotificationEmailSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
import { getNotificationEmailSettings, updateNotificationEmailSettings } from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
afterEach(() => vi.clearAllMocks());
@@ -8,10 +8,10 @@
type NotificationKindSetting,
type NotificationSettingChange,
type NotificationSettings
} from '$lib/api/notifications';
import { qk } from '$lib/api/queries';
import { user } from '$lib/auth/store.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/notifications.js';
import { qk } from '#lib/api/queries.js';
import { user } from '#lib/auth/store.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Per-user notification settings (M489): one row per kind, a toggle per
// channel. Toggles are optimistic; a failed save puts the switch back.
@@ -8,7 +8,7 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
return { ...actual, useQueryClient: () => ({ setQueryData, invalidateQueries: vi.fn() }) };
});
vi.mock('$lib/api/notifications', async (orig) => {
vi.mock('#lib/api/notifications.js', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
@@ -18,12 +18,12 @@ vi.mock('$lib/api/notifications', async (orig) => {
});
const userState = vi.hoisted(() => ({ current: { id: '1', username: 'alice', is_admin: false } }));
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { get value() { return userState.current; } }
}));
const pushToast = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
import NotificationSettings from './NotificationSettings.svelte';
import {
@@ -31,7 +31,7 @@ import {
putNotificationSettings,
type NotificationKindSetting,
type NotificationSettings as Settings
} from '$lib/api/notifications';
} from '#lib/api/notifications.js';
const requesterKinds: NotificationKindSetting[] = [
{ kind: 'request_approved', admin_only: false, inbox: true, phone: true, email: true },
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
// The centred column every track-list page sits in: album, playlist,
// Liked, History and Search → Tracks (milestone 494). The playlist page
// had it alone from May 2026; the operator picked it as the page shape
// on 2026-10-08. Shelf and grid pages (Home, Library, Discover, Artist,
// Search) stay full width, since a narrow column would cost them cards.
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="mx-auto w-full max-w-4xl space-y-4 px-4 py-6">
{@render children()}
</div>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
// Play and Shuffle, the same on every page that has them: album,
// playlist and artist (milestone 494). Play is filled and Shuffle
// outlined. The fill is the accent: the operator's call on 2026-10-08,
// made with the house rule against accent on action buttons stated to
// them.
import type { Snippet } from 'svelte';
import { Play, Shuffle } from 'lucide-svelte';
let {
name,
disabled = false,
onPlay,
onShuffle,
children
}: {
/** What is played, for the buttons' accessible names. */
name: string;
disabled?: boolean;
onPlay: () => void;
onShuffle: () => void;
/** Further actions after Shuffle. */
children?: Snippet;
} = $props();
</script>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
aria-label={`Play ${name}`}
onclick={onPlay}
{disabled}
class="flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-medium
text-text-primary hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
>
<Play size={16} strokeWidth={1.5} fill="currentColor" />
Play
</button>
<button
type="button"
aria-label={`Shuffle ${name}`}
onclick={onShuffle}
{disabled}
class="flex items-center gap-2 rounded-md border border-border bg-surface px-4 py-2
text-sm font-medium text-text-primary hover:border-accent
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
>
<Shuffle size={16} strokeWidth={1.5} />
Shuffle
</button>
{#if children}{@render children()}{/if}
</div>
+9 -9
View File
@@ -10,11 +10,11 @@
togglePlay, skipNext, skipPrev, seekTo, setVolume,
toggleShuffle, cycleRepeat, playQueue,
toggleQueueDrawer
} from '$lib/player/store.svelte';
import { formatDuration } from '$lib/media/duration';
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor';
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
} from '#lib/player/store.svelte.js';
import { formatDuration } from '#lib/media/duration.js';
import { FALLBACK_COVER, coverUrl } from '#lib/media/covers.js';
import { dominantColorFromUrl, rgbToCssString } from '#lib/media/dominantColor.js';
import { useSmoothPosition } from '#lib/player/smoothPosition.svelte.js';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
@@ -118,7 +118,7 @@
<a
href="/now-playing"
aria-label="Open now playing"
class="flex min-w-0 items-center gap-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
class="flex min-w-0 items-center gap-2 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
style="flex: 1 1 35%;"
>
<img
@@ -153,7 +153,7 @@
type="button"
aria-label={player.isPlaying ? 'Pause' : 'Play'}
onclick={togglePlay}
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow-sm transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
>
{#if player.state === 'loading' || player.state === 'idle'}
<Loader2 data-testid="play-spinner" size={22} strokeWidth={1.5} class="animate-spin" />
@@ -281,7 +281,7 @@
<a
href="/now-playing"
aria-label="Open now playing"
class="shrink-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
class="shrink-0 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
>
<img
src={coverUrl(current.album_id)}
@@ -341,7 +341,7 @@
type="button"
aria-label={player.isPlaying ? 'Pause' : 'Play'}
onclick={togglePlay}
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow-sm transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent"
>
{#if player.state === 'loading' || player.state === 'idle'}
<Loader2 size={24} strokeWidth={1.5} class="animate-spin" />
+6 -6
View File
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
// Mutable state the mocked store reads from.
const state = vi.hoisted(() => ({
@@ -20,7 +20,7 @@ const state = vi.hoisted(() => ({
queueDrawerOpen: false
}));
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
player: {
get queue() { return state.queue; },
get index() { return state.index; },
@@ -46,16 +46,16 @@ vi.mock('$lib/player/store.svelte', () => ({
toggleQueueDrawer: vi.fn()
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
import PlayerBar from './PlayerBar.svelte';
import {
togglePlay, skipNext, skipPrev, seekTo, setVolume,
toggleShuffle, cycleRepeat, playQueue,
toggleQueueDrawer
} from '$lib/player/store.svelte';
} from '#lib/player/store.svelte.js';
function track(): TrackRef {
return makeTrack({
+11 -11
View File
@@ -1,15 +1,15 @@
<script lang="ts">
import { Play, MoreVertical, RefreshCcw } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import type { Playlist, PlaylistTrack, TrackRef } from '$lib/api/types';
import { user } from '$lib/auth/store.svelte';
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
import { errCode } from '$lib/api/errors';
import { qk } from '$lib/api/queries';
import { playQueue } from '$lib/player/store.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
import type { Playlist, PlaylistTrack, TrackRef } from '#lib/api/types.js';
import { user } from '#lib/auth/store.svelte.js';
import { getPlaylist, systemShuffle, refreshSystem } from '#lib/api/playlists.js';
import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js';
import { systemPlaylistRefetch } from '#lib/playlists/systemRefetch.js';
import { errCode } from '#lib/api/errors.js';
import { qk } from '#lib/api/queries.js';
import { playQueue } from '#lib/player/store.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
let { playlist }: { playlist: Playlist } = $props();
@@ -184,7 +184,7 @@
>
<div
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
bg-surface-hover shadow-sm transition-all duration-150
bg-surface-hover shadow-xs transition-all duration-150
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
>
{#if playlist.cover_url}
@@ -203,7 +203,7 @@
is often a 2x2 collage; the gradient mutes the collage so
the playlist NAME becomes the dominant element. -->
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-t
class="pointer-events-none absolute inset-0 bg-linear-to-t
from-black/85 via-black/40 to-transparent"
></div>
<!-- Title burned in at the bottom of the cover. Two lines max;
+15 -15
View File
@@ -1,13 +1,13 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import PlaylistCard from './PlaylistCard.svelte';
import type { Playlist, PlaylistDetail } from '$lib/api/types';
import type { Playlist, PlaylistDetail } from '#lib/api/types.js';
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
}));
vi.mock('$lib/api/playlists', () => ({
vi.mock('#lib/api/playlists.js', () => ({
getPlaylist: vi.fn().mockResolvedValue({
id: 'p-1',
user_id: 'u-self',
@@ -75,11 +75,11 @@ vi.mock('$lib/api/playlists', () => ({
refreshSystem: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5, track_ids: ['t-1'] })
}));
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn()
}));
vi.mock('$lib/api/queries', () => ({
vi.mock('#lib/api/queries.js', () => ({
qk: {
playlist: (id: string) => ['playlist', id],
playlists: (kind?: string) => ['playlists', { kind: kind ?? 'user' }]
@@ -162,8 +162,8 @@ describe('PlaylistCard', () => {
});
test('play button calls playQueue with tracks when clicked', async () => {
const { getPlaylist } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
const { getPlaylist } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
@@ -222,7 +222,7 @@ describe('PlaylistCard', () => {
});
test('clicking Refresh calls refreshSystem with the raw variant', async () => {
const { refreshSystem } = await import('$lib/api/playlists');
const { refreshSystem } = await import('#lib/api/playlists.js');
const discoverPlaylist: Playlist = {
...base,
kind: 'system',
@@ -237,8 +237,8 @@ describe('PlaylistCard', () => {
});
test('For-You play calls systemShuffle, not getPlaylist', async () => {
const { getPlaylist, systemShuffle } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
const forYouPlaylist: Playlist = {
...base,
kind: 'system',
@@ -261,7 +261,7 @@ describe('PlaylistCard', () => {
});
test('For-You play passes source:for_you to playQueue (no client shuffle)', async () => {
const { playQueue } = await import('$lib/player/store.svelte');
const { playQueue } = await import('#lib/player/store.svelte.js');
const forYouPlaylist: Playlist = {
...base,
kind: 'system',
@@ -281,8 +281,8 @@ describe('PlaylistCard', () => {
});
test('user-playlist play uses getPlaylist + plain playQueue (no source)', async () => {
const { getPlaylist, systemShuffle } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
const { getPlaylist, systemShuffle } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
await fireEvent.click(playButton);
@@ -294,8 +294,8 @@ describe('PlaylistCard', () => {
});
test('user-playlist play does not refresh anything', async () => {
const { refreshSystem, getPlaylist } = await import('$lib/api/playlists');
const { playQueue } = await import('$lib/player/store.svelte');
const { refreshSystem, getPlaylist } = await import('#lib/api/playlists.js');
const { playQueue } = await import('#lib/player/store.svelte.js');
render(PlaylistCard, { props: { playlist: base } });
const playButton = screen.getByLabelText(/Play Saturday morning/i);
+45 -78
View File
@@ -1,12 +1,10 @@
<script lang="ts">
// A track in a playlist. The look is TrackListRow's; what this adds is
// the reorder grip, remove, and the two ways an entry can be dead.
import { GripVertical, X } from 'lucide-svelte';
import { draggable, type DragEventData } from '@neodrag/svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import type { PlaylistTrack } from '$lib/api/types';
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
import { player } from '$lib/player/store.svelte';
import { offsetToDelta } from './queue-row-math';
import type { PlaylistTrack } from '#lib/api/types.js';
import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js';
import TrackListRow from './TrackListRow.svelte';
let {
row,
@@ -32,22 +30,21 @@
const isRemoved = $derived(row.track_id === null);
const isUnavailable = $derived(isRemoved || isMissingFile);
// Reconstruct a minimal TrackRef for the kebab menu when the
// upstream track still exists. When unavailable, the menu is hidden.
// null when the entry cannot be played, which hides like, radio, add
// and the menu.
const liveTrack = $derived(playlistTrackToRef(row));
let measuredRowHeight = 56; // sensible default; replaced on dragStart
const canDrag = $derived(isOwner && !isUnavailable);
function handleDragStart(data: DragEventData) {
const rect = data.rootNode.getBoundingClientRect();
if (rect.height > 0) measuredRowHeight = rect.height;
}
function handleDragEnd(data: DragEventData) {
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
if (delta === 0) return;
onMove?.(row.position, row.position + delta);
const drag = $derived(
isOwner
? {
disabled: !canDrag,
handle: '[data-reorder-handle]',
onMove: (delta: number) => onMove?.(row.position, row.position + delta)
}
: null
);
function handleHandleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowUp') {
@@ -63,87 +60,57 @@
e.preventDefault();
}
}
function format(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Drag is gated on owner+available. When neither, the action runs but
// the dragEnd handler skips by returning before onMove fires; visually
// the user never sees the row move.
const canDrag = $derived(isOwner && !isUnavailable);
// "Now playing" highlight: matches QueueTrackRow's treatment so the
// user can see which row is current without reading the player bar.
// Unavailable rows (trackId === null) never match.
const isCurrent = $derived(
!isUnavailable && player.current?.id === row.track_id,
);
</script>
<div
role="listitem"
class="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-surface-hover
{isUnavailable ? 'text-text-muted' : 'text-text-primary'}
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
use:draggable={{
axis: 'y',
bounds: 'parent',
disabled: !canDrag,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd
}}
<TrackListRow
title={row.title}
subtitle={`${row.artist_name} · ${row.album_title}`}
durationSec={row.duration_sec}
track={liveTrack}
ariaLabel={`Play ${row.title}`}
rowTitle={isMissingFile
? 'This track’s file is missing from the library, so it can’t be played. It stays in the playlist, and returns automatically if the file comes back.'
: undefined}
disabled={isUnavailable}
struck={isRemoved}
{drag}
onActivate={() => onPlay(row.position)}
>
{#snippet lead()}
{#if isOwner}
<button
type="button"
data-reorder-handle
disabled={!canDrag}
aria-label="Reorder track (drag or use arrow keys)"
aria-keyshortcuts="ArrowUp ArrowDown"
onclick={(e) => e.stopPropagation()}
onkeydown={handleHandleKeydown}
class="flex-shrink-0 cursor-grab text-text-muted hover:text-text-primary
active:cursor-grabbing focus-visible:outline focus-visible:outline-2
class="cursor-grab p-1 text-text-muted hover:text-text-primary
active:cursor-grabbing focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<GripVertical size={14} strokeWidth={1} />
<GripVertical size={16} strokeWidth={1} />
</button>
{/if}
{/snippet}
<button
type="button"
class="min-w-0 flex-1 text-left"
onclick={() => !isUnavailable && onPlay(row.position)}
disabled={isUnavailable}
title={isMissingFile
? 'This track’s file is missing from the library, so it can’t be played. It stays in the playlist, and returns automatically if the file comes back.'
: undefined}
>
<div class="truncate {isRemoved ? 'line-through' : ''}">{row.title}</div>
<div class="truncate text-xs text-text-muted">
{row.artist_name} · {row.album_title}
{#snippet note()}
{#if isMissingFile}
<span class="text-action-destructive"> · File missing</span>
{/if}
</div>
</button>
<span class="flex-shrink-0 text-xs text-text-muted">{format(row.duration_sec)}</span>
{#if !isUnavailable && liveTrack}
<LikeButton entityType="track" entityId={liveTrack.id} />
<TrackMenu track={liveTrack} />
<span class="text-error-fg"> · File missing</span>
{/if}
{/snippet}
{#snippet trailing()}
{#if isOwner}
<button
type="button"
onclick={() => onRemove(row.position)}
onclick={(e) => { e.stopPropagation(); onRemove(row.position); }}
aria-label={`Remove ${row.title} from playlist`}
class="flex-shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
class="shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
>
<X size={14} strokeWidth={1} />
<X size={16} strokeWidth={1} />
</button>
{/if}
</div>
{/snippet}
</TrackListRow>
@@ -2,14 +2,14 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import type { PlaylistTrack } from '$lib/api/types';
import type { PlaylistTrack } from '#lib/api/types.js';
// LikeButton + TrackMenu transitively pull in TanStack Query, the auth
// store, likes/quarantine APIs, the player store, and SvelteKit
// navigation. Mock all of them at module level so the component can be
// rendered in isolation.
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { value: { id: 'u1', username: 'me', is_admin: false } }
}));
@@ -17,11 +17,11 @@ vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playQueue: vi.fn(),
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Save, TriangleAlert } from 'lucide-svelte';
import { getNetworkSettings, updatePublicUrl } from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
import { getNetworkSettings, updatePublicUrl } from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
let saved = $state<string | null>(null);
let value = $state('');
@@ -68,7 +68,7 @@
placeholder="https://music.example.com"
bind:value
class="w-full rounded border border-border bg-background px-2 py-1 font-mono
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
</label>
{#if here && value.trim() !== here}
@@ -96,7 +96,7 @@
{#if !saved}
<p class="flex items-start gap-2 text-sm text-text-secondary" role="status">
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
Not set, so password-reset emails are not being sent. Minstrel won't build the link from
whatever address a request claims, because anyone can claim any address.
@@ -5,13 +5,13 @@ import PublicAddressCard from './PublicAddressCard.svelte';
const getNetworkSettings = vi.fn();
const updatePublicUrl = vi.fn();
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getNetworkSettings: () => getNetworkSettings(),
updatePublicUrl: (url: string) => updatePublicUrl(url)
}));
const pushToast = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({
vi.mock('#lib/stores/toast.svelte.js', () => ({
pushToast: (...args: unknown[]) => pushToast(...args)
}));
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { player, closeQueueDrawer } from '$lib/player/store.svelte';
import { player, closeQueueDrawer } from '#lib/player/store.svelte.js';
import QueueList from './QueueList.svelte';
let previouslyFocused: HTMLElement | null = null;
+4 -4
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
import type { TrackRef } from '#lib/api/types.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
// QueueTrackRow (rendered inside QueueDrawer → QueueList) now renders a
// LikeButton, which reads createLikedIdsQuery. Stub the likes API so the
@@ -10,14 +10,14 @@ import { makeTrack } from '$test-utils/fixtures/track';
// emptyLikesMock import — must precede the QueueDrawer import below, since
// loading the component evaluates the mocked modules and the hoisted
// factories need their referenced bindings already initialized.
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
const closeQueueDrawer = vi.fn();
let queueValue: TrackRef[] = [];
let indexValue = 0;
let openValue = true;
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
closeQueueDrawer: (...args: unknown[]) => closeQueueDrawer(...args),
player: {
get queue() { return queueValue; },
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { X, Trash2, ArrowDown } from 'lucide-svelte';
import { player, clearQueue } from '$lib/player/store.svelte';
import { player, clearQueue } from '#lib/player/store.svelte.js';
import QueueTrackRow from './QueueTrackRow.svelte';
// onClose: when provided, renders an X button in the header so the
+12 -26
View File
@@ -1,10 +1,9 @@
<script lang="ts">
import { GripVertical, X } from 'lucide-svelte';
import { draggable, type DragEventData } from '@neodrag/svelte';
import type { TrackRef } from '$lib/api/types';
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
import { coverUrl, FALLBACK_COVER } from '$lib/media/covers';
import { offsetToDelta } from './queue-row-math';
import type { TrackRef } from '#lib/api/types.js';
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '#lib/player/store.svelte.js';
import { coverUrl, FALLBACK_COVER } from '#lib/media/covers.js';
import { reorderDrag } from './reorder-drag';
import LikeButton from './LikeButton.svelte';
let { track, index, isCurrent } = $props<{
@@ -13,14 +12,6 @@
isCurrent: boolean;
}>();
let measuredRowHeight = 64; // sensible default; replaced on dragStart
function handleDragStart(data: DragEventData) {
// The action's `data.rootNode` is the element use:draggable was applied to.
const rect = data.rootNode.getBoundingClientRect();
if (rect.height > 0) measuredRowHeight = rect.height;
}
function handleBodyClick() {
if (isCurrent) return;
playFromQueueIndex(index);
@@ -30,12 +21,6 @@
removeFromQueue(index);
}
function handleDragEnd(data: DragEventData) {
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
if (delta === 0) return;
moveQueueItem(index, index + delta);
}
function handleHandleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowUp') {
e.preventDefault();
@@ -53,14 +38,15 @@
</script>
<div
use:draggable={{ axis: 'y', bounds: 'parent', onDragStart: handleDragStart, onDragEnd: handleDragEnd }}
use:reorderDrag={{ onMove: (delta) => moveQueueItem(index, index + delta) }}
class="flex items-center gap-2 border-b border-border px-3 py-2 h-16
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
[&.neodrag-dragging]:relative [&.neodrag-dragging]:z-10 [&.neodrag-dragging]:shadow-lg
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : 'bg-background'}"
>
<!--
The album art is the grab surface (#2395). The grip used to occupy its own
column in every row; it now sits OVER the art, so it costs no horizontal
space at all. `use:draggable` is on the row (above), so dragging already
space at all. `use:reorderDrag` is on the row (above), so dragging already
worked from anywhere — the grip's real jobs are being the visual cue and
the keyboard target, and both survive here.
@@ -71,7 +57,7 @@
hover/focus so the artwork stays legible the rest of the time; the drop
shadow is what keeps the glyph readable over pale covers without one.
-->
<div class="relative h-10 w-10 flex-shrink-0">
<div class="relative h-10 w-10 shrink-0">
<img
src={coverUrl(track.album_id)}
alt=""
@@ -86,7 +72,7 @@
class="group absolute inset-0 flex cursor-grab items-center justify-center rounded
text-white/70 transition hover:bg-black/45 hover:text-white
focus-visible:bg-black/45 focus-visible:text-white
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
style="filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.9))"
>
<GripVertical size={16} />
@@ -101,7 +87,7 @@
>
<div class="flex items-center gap-2 min-w-0">
{#if isCurrent}
<span class="text-xs text-accent-fg uppercase tracking-wide flex-shrink-0">Now playing</span>
<span class="text-xs text-accent-fg uppercase tracking-wide shrink-0">Now playing</span>
{/if}
<span class="font-medium truncate text-text-primary">{track.title}</span>
</div>
@@ -114,7 +100,7 @@
type="button"
onclick={handleRemove}
aria-label="Remove from queue"
class="text-text-secondary hover:text-text-primary flex-shrink-0"
class="text-text-secondary hover:text-text-primary shrink-0"
>
<X size={16} />
</button>
+3 -3
View File
@@ -1,14 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
// QueueTrackRow now renders a LikeButton, which reads createLikedIdsQuery.
// Stub the likes API so the row doesn't need a real QueryClient in context.
// The mock (and its emptyLikesMock import) must precede the component import
// below: importing QueueTrackRow transitively loads LikeButton → the mocked
// module, and the hoisted factory needs emptyLikesMock already initialized.
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
import QueueTrackRow from './QueueTrackRow.svelte';
@@ -16,7 +16,7 @@ const playFromQueueIndex = vi.fn();
const removeFromQueue = vi.fn();
const moveQueueItem = vi.fn();
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
playFromQueueIndex: (...args: unknown[]) => playFromQueueIndex(...args),
removeFromQueue: (...args: unknown[]) => removeFromQueue(...args),
moveQueueItem: (...args: unknown[]) => moveQueueItem(...args)
+1 -1
View File
@@ -58,7 +58,7 @@
oninput={onInput}
onkeydown={onKey}
class="w-full rounded border border-border bg-surface pl-7 pr-7 py-1 text-sm
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
{#if raw}
<button
@@ -5,9 +5,9 @@
getReacquisitionSettings,
updateReacquisitionSettings,
type ReacquisitionSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// Policy for turning a missing file back into a Lidarr request
// (milestone #290). Lives on the missing-files page rather than under
@@ -127,7 +127,7 @@
max="720"
bind:value={form.grace_hours}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -144,7 +144,7 @@
max="10"
bind:value={form.max_attempts}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -157,7 +157,7 @@
max="168"
bind:value={form.backoff_base_hours}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -170,7 +170,7 @@
max="720"
bind:value={form.backoff_max_hours}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -186,7 +186,7 @@
max="200"
bind:value={form.max_per_pass}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline focus-visible:outline-2
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
@@ -213,7 +213,7 @@
watching those rows never get a request would reasonably conclude
the feature is broken. -->
<p class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
{saved.unnameable_albums}
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no
@@ -227,7 +227,7 @@
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving}
onclick={save}
@@ -1,18 +1,18 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { ReacquisitionSettings } from '$lib/api/admin';
import { ERROR_COPY } from '$lib/api/error-copy';
import type { ReacquisitionSettings } from '#lib/api/admin.js';
import { ERROR_COPY } from '#lib/api/error-copy.js';
vi.mock('$lib/api/admin', () => ({
vi.mock('#lib/api/admin.js', () => ({
getReacquisitionSettings: vi.fn(),
updateReacquisitionSettings: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import ReacquisitionSettingsCard from './ReacquisitionSettingsCard.svelte';
import { getReacquisitionSettings, updateReacquisitionSettings } from '$lib/api/admin';
import { pushToast } from '$lib/stores/toast.svelte';
import { getReacquisitionSettings, updateReacquisitionSettings } from '#lib/api/admin.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
const base: ReacquisitionSettings = {
enabled: true,
+7 -7
View File
@@ -18,16 +18,16 @@
function navigate(next: string) {
const onSearchPage = page.url.pathname.startsWith('/search');
const trimmed = next.trim();
// keepFocus: SvelteKit blurs the active element on navigation by default.
// For a typeahead-style URL update we want the user to keep typing
// without re-clicking the input every 250 ms.
// reset: false: SvelteKit blurs the active element and scrolls to the top
// on navigation by default. For a typeahead-style URL update we want the
// user to keep typing without re-clicking the input every 250 ms.
if (trimmed === '') {
if (onSearchPage) goto('/search', { replaceState: true, keepFocus: true });
if (onSearchPage) goto('/search', { replace: true, reset: false });
return;
}
goto(`/search?q=${encodeURIComponent(trimmed)}`, {
replaceState: onSearchPage,
keepFocus: true,
replace: onSearchPage,
reset: false,
});
}
@@ -66,6 +66,6 @@
bind:value
oninput={onInput}
onkeydown={onKey}
class="w-full rounded border border-border bg-surface pl-7 pr-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
class="w-full rounded border border-border bg-surface pl-7 pr-3 py-1 text-sm focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
</label>
+7 -7
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { pageUrlModule } from '$test-utils/mocks/appState';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
const state = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/')
@@ -40,7 +40,7 @@ describe('SearchInput', () => {
expect(goto).not.toHaveBeenCalled();
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/search?q=h', { replaceState: false, keepFocus: true });
expect(goto).toHaveBeenCalledWith('/search?q=h', { replace: false, reset: false });
});
test('rapid keystrokes debounce to a single goto', async () => {
@@ -53,16 +53,16 @@ describe('SearchInput', () => {
await fireEvent.input(input, { target: { value: 'hel' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/search?q=hel', { replaceState: false, keepFocus: true });
expect(goto).toHaveBeenCalledWith('/search?q=hel', { replace: false, reset: false });
});
test('typing while on /search uses replaceState=true', async () => {
test('typing while on /search uses replace=true', async () => {
state.pageUrl = new URL('http://localhost/search?q=h');
render(SearchInput);
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'he' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search?q=he', { replaceState: true, keepFocus: true });
expect(goto).toHaveBeenCalledWith('/search?q=he', { replace: true, reset: false });
});
test('clearing input on /search calls goto("/search") (drops ?q=)', async () => {
@@ -71,7 +71,7 @@ describe('SearchInput', () => {
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: '' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search', { replaceState: true, keepFocus: true });
expect(goto).toHaveBeenCalledWith('/search', { replace: true, reset: false });
});
test('Escape clears the input value', async () => {
@@ -88,6 +88,6 @@ describe('SearchInput', () => {
const input = screen.getByRole('searchbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'a b&c' } });
vi.advanceTimersByTime(250);
expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replaceState: false, keepFocus: true });
expect(goto).toHaveBeenCalledWith('/search?q=a%20b%26c', { replace: false, reset: false });
});
});
+3 -3
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { X, ListPlus, Plus, ListMusic, Heart } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { selection, clearSelection } from '$lib/selection/store.svelte';
import { playNextMany, enqueueTracks } from '$lib/player/store.svelte';
import { likeEntity, createLikedIdsQuery } from '$lib/api/likes';
import { selection, clearSelection } from '#lib/selection/store.svelte.js';
import { playNextMany, enqueueTracks } from '#lib/player/store.svelte.js';
import { likeEntity, createLikedIdsQuery } from '#lib/api/likes.js';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
const queryClient = useQueryClient();
+7 -7
View File
@@ -2,9 +2,9 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { ChevronDown, Compass, House, LibraryBig } from 'lucide-svelte';
import { user, logout } from '$lib/auth/store.svelte';
import { player } from '$lib/player/store.svelte';
import { appName } from '$lib/branding';
import { user, logout } from '#lib/auth/store.svelte.js';
import { player } from '#lib/player/store.svelte.js';
import { appName } from '#lib/branding.js';
import MinstrelMark from './MinstrelMark.svelte';
import NotificationBell from './NotificationBell.svelte';
import PlayerBar from './PlayerBar.svelte';
@@ -35,7 +35,7 @@
async function handleLogout() {
menuOpen = false;
await logout();
goto('/login', { replaceState: true });
goto('/login', { replace: true });
}
function isActive(prefix: string): boolean {
@@ -94,13 +94,13 @@
</nav>
<div class="flex items-center gap-1 md:gap-3 justify-self-end min-w-0">
<div class="hidden md:block min-w-0 max-w-xs flex-shrink">
<div class="hidden md:block min-w-0 max-w-xs shrink">
<SearchInput />
</div>
<NotificationBell />
<div class="relative flex-shrink-0">
<div class="relative shrink-0">
<button
bind:this={menuButtonRef}
type="button"
@@ -120,7 +120,7 @@
{#if menuOpen}
<div
bind:this={menuRef}
class="absolute right-0 z-50 mt-1 w-40 rounded border border-border bg-surface shadow"
class="absolute right-0 z-50 mt-1 w-40 rounded border border-border bg-surface shadow-sm"
role="menu"
tabindex="-1"
>
+4 -4
View File
@@ -19,14 +19,14 @@ const userState = vi.hoisted(() => ({
} | null
}));
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { get value() { return userState.current; } },
logout: vi.fn().mockResolvedValue(undefined)
}));
// The bell's queries need a QueryClient the Shell tests do not provide; its own
// behaviour is covered in NotificationBell.test.ts.
vi.mock('$lib/api/notifications', async () => {
vi.mock('#lib/api/notifications.js', async () => {
const { mockQuery } = await import('../../test-utils/query');
return {
createUnreadCountQuery: () => mockQuery({ data: 0 }),
@@ -38,7 +38,7 @@ vi.mock('$lib/api/notifications', async () => {
});
import Shell from './Shell.svelte';
import { logout } from '$lib/auth/store.svelte';
import { logout } from '#lib/auth/store.svelte.js';
import { goto } from '$app/navigation';
afterEach(() => {
@@ -110,6 +110,6 @@ describe('Shell', () => {
await fireEvent.click(screen.getByRole('button', { name: /alice/i }));
await fireEvent.click(screen.getByRole('menuitem', { name: /log out/i }));
expect(logout).toHaveBeenCalledTimes(1);
expect(goto).toHaveBeenCalledWith('/login', { replaceState: true });
expect(goto).toHaveBeenCalledWith('/login', { replace: true });
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts" module>
import type { LidarrRequestStatus } from '$lib/api/types';
import type { LidarrRequestStatus } from '#lib/api/types.js';
type Tone = 'warning' | 'info' | 'success' | 'error';
+6 -6
View File
@@ -5,12 +5,12 @@
createSnoozesQuery,
snoozeSuggestion,
unsnoozeSuggestion
} from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
import { qk } from '$lib/api/queries';
import { pushToast } from '$lib/stores/toast.svelte';
} from '#lib/api/suggestions.js';
import { createRequest } from '#lib/api/requests.js';
import { qk } from '#lib/api/queries.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import DiscoverResultCard from './DiscoverResultCard.svelte';
import type { ArtistSuggestion, SeedContribution, SuggestionSnooze } from '$lib/api/types';
import type { ArtistSuggestion, SeedContribution, SuggestionSnooze } from '#lib/api/types.js';
const client = useQueryClient();
const queryStore = createSuggestionsQuery();
@@ -199,7 +199,7 @@
<button
type="button"
aria-label={`Bring ${snoozed.name} back now`}
class="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
class="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-hidden focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze(snoozed.mbid, snoozed.name)}
>
Bring back
@@ -8,19 +8,19 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) };
});
vi.mock('$lib/api/suggestions', () => ({
vi.mock('#lib/api/suggestions.js', () => ({
createSuggestionsQuery: vi.fn(),
createSnoozesQuery: vi.fn(),
snoozeSuggestion: vi.fn().mockResolvedValue(undefined),
unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('$lib/api/requests', () => ({
vi.mock('#lib/api/requests.js', () => ({
createRequest: vi.fn().mockResolvedValue({})
}));
const pushToastMock = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({
vi.mock('#lib/stores/toast.svelte.js', () => ({
pushToast: (...args: unknown[]) => pushToastMock(...args)
}));
@@ -30,9 +30,9 @@ import {
createSnoozesQuery,
snoozeSuggestion,
unsnoozeSuggestion
} from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
import type { ArtistSuggestion, SuggestionSnooze } from '$lib/api/types';
} from '#lib/api/suggestions.js';
import { createRequest } from '#lib/api/requests.js';
import type { ArtistSuggestion, SuggestionSnooze } from '#lib/api/types.js';
const oneSeed: ArtistSuggestion = {
mbid: 'mb1',
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts">
// Single global toast renderer. Mounted once in +layout.svelte; pages call
// pushToast() from $lib/stores/toast.svelte. The {#key} re-mount on id
// pushToast() from #lib/stores/toast.svelte. The {#key} re-mount on id
// change is what makes screen readers re-announce a replacement toast.
import { toast } from '$lib/stores/toast.svelte';
import { toast } from '#lib/stores/toast.svelte.js';
</script>
{#if toast.value}
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
// The box every track list sits in (milestone 494). No fill of its own:
// the rows stripe themselves, and a filled box would hide the stripe.
// Rows are direct children so `odd:` counts them.
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="overflow-hidden rounded-md border border-border">
{@render children()}
</div>
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
// The one shape every track list in the web app draws (milestone 494):
// a lead column (track number, drag grip or cover), title over a muted
// subtitle, then like, radio, add-to-queue, the track menu and the
// duration, then whatever the list adds at the end (remove, a timestamp).
// The wrappers — TrackRow, PlaylistTrackRow, HistoryRow — own behaviour
// (selection, reorder, remove); this component owns how a row looks, so
// the lists cannot drift apart again.
import type { Snippet } from 'svelte';
import { Plus, Radio } from 'lucide-svelte';
import type { TrackRef } from '#lib/api/types.js';
import { formatDuration } from '#lib/media/duration.js';
import { player, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import { reorderDrag, type ReorderDragOptions } from './reorder-drag';
let {
title,
subtitle = '',
durationSec,
track,
ariaLabel,
rowTitle,
isSelected = false,
disabled = false,
struck = false,
drag = null,
onActivate,
lead,
note,
trailing
}: {
title: string;
subtitle?: string;
durationSec: number;
/** The playable track. null when the row cannot be played, which hides
like, radio, add and the menu: there is nothing for them to act on. */
track: TrackRef | null;
ariaLabel: string;
/** Tooltip for the whole row. */
rowTitle?: string;
isSelected?: boolean;
disabled?: boolean;
/** Strike the title through: the track is gone for good. */
struck?: boolean;
drag?: ReorderDragOptions | null;
onActivate: (e: MouseEvent | KeyboardEvent) => void;
lead?: Snippet;
/** Appended to the subtitle line, e.g. a "File missing" marker. */
note?: Snippet;
trailing?: Snippet;
} = $props();
const isCurrent = $derived(!!track && player.current?.id === track.id);
// The stripe is only drawn on rows with no state of their own. Tailwind
// emits `odd:` after the plain utilities, so a striped odd row would win
// over the now-playing or selected background.
const background = $derived(
isCurrent
? 'border-l-2 border-l-accent bg-surface-hover'
: isSelected
? 'bg-surface-hover'
: 'odd:bg-surface/50 hover:bg-surface-hover'
);
function onclick(e: MouseEvent) {
if (!disabled) onActivate(e);
}
function onkeydown(e: KeyboardEvent) {
// Keys pressed on a control inside the row belong to that control.
if (e.target !== e.currentTarget) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (!disabled) onActivate(e);
}
function onRadio(e: MouseEvent) {
e.stopPropagation();
if (track) playRadio(track.id);
}
function onAdd(e: MouseEvent) {
e.stopPropagation();
if (track) enqueueTrack(track);
}
</script>
<div
role="button"
tabindex="0"
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
title={rowTitle}
data-selected={isSelected}
{onclick}
{onkeydown}
use:reorderDrag={drag}
class="group relative flex w-full items-center gap-3 px-3 py-2 text-left text-sm
transition-colors focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent [&.neodrag-dragging]:z-10
[&.neodrag-dragging]:bg-surface-hover [&.neodrag-dragging]:shadow-lg
{background}
{disabled ? 'cursor-default text-text-muted' : 'cursor-pointer text-text-primary'}"
>
{#if lead}
<span class="flex w-8 shrink-0 items-center justify-center">{@render lead()}</span>
{/if}
<span class="min-w-0 flex-1">
<span class="block truncate {struck ? 'line-through' : ''}">{title}</span>
{#if subtitle || note}
<span class="block truncate text-xs text-text-secondary">
{subtitle}{#if note}{@render note()}{/if}
</span>
{/if}
</span>
{#if track}
<LikeButton entityType="track" entityId={track.id} />
<button
type="button"
aria-label="Play radio from this track"
title="Play radio"
onclick={onRadio}
class="hidden shrink-0 rounded p-1 text-text-secondary hover:text-text-primary sm:inline-flex"
>
<Radio size={16} strokeWidth={1} />
</button>
<button
type="button"
aria-label={`Add ${track.title} to queue`}
title="Add to queue"
onclick={onAdd}
class="hidden shrink-0 rounded p-1 text-text-secondary hover:text-text-primary sm:inline-flex"
>
<Plus size={16} strokeWidth={1} />
</button>
<TrackMenu {track} />
{/if}
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
{formatDuration(durationSec)}
</span>
{#if trailing}
{@render trailing()}
{/if}
</div>
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import { makeTrack } from '#test-utils/fixtures/track.js';
const player = vi.hoisted(() => ({ current: undefined as { id: string } | undefined }));
vi.mock('#lib/player/store.svelte.js', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playQueue: vi.fn(),
playRadio: vi.fn(),
player
}));
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
import TrackListRow from './TrackListRow.svelte';
import { enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
const track = makeTrack({ id: 't1', title: 'Roygbiv', duration_sec: 137 });
function base(overrides: Record<string, unknown> = {}) {
return {
title: 'Roygbiv',
subtitle: 'Boards of Canada · MHTRTC',
durationSec: 137,
track,
ariaLabel: 'Roygbiv',
onActivate: vi.fn(),
...overrides
};
}
afterEach(() => {
vi.clearAllMocks();
player.current = undefined;
});
describe('TrackListRow', () => {
test('draws title, subtitle and duration', () => {
render(TrackListRow, { props: base() });
expect(screen.getByText('Roygbiv')).toBeInTheDocument();
expect(screen.getByText('Boards of Canada · MHTRTC')).toBeInTheDocument();
expect(screen.getByText('2:17')).toBeInTheDocument();
});
test('a plain row is striped; the now-playing row is banded instead', () => {
const { unmount } = render(TrackListRow, { props: base() });
expect(screen.getByRole('button', { name: 'Roygbiv' }).className).toContain('odd:bg-surface/50');
unmount();
player.current = { id: 't1' };
render(TrackListRow, { props: base() });
const row = screen.getByRole('button', { name: 'Roygbiv' });
expect(row.className).toContain('border-l-accent');
expect(row.className).not.toContain('odd:bg-surface/50');
});
test('click and Enter activate the row', async () => {
const props = base();
render(TrackListRow, { props });
const row = screen.getByRole('button', { name: 'Roygbiv' });
await fireEvent.click(row);
await fireEvent.keyDown(row, { key: 'Enter' });
expect(props.onActivate).toHaveBeenCalledTimes(2);
});
// Enter on a control inside the row is that control's, not the row's.
// The old TrackRow played the track when Enter was pressed on "add".
test('Enter on an inner control does not activate the row', async () => {
const props = base();
render(TrackListRow, { props });
await fireEvent.keyDown(screen.getByRole('button', { name: /add roygbiv to queue/i }), { key: 'Enter' });
expect(props.onActivate).not.toHaveBeenCalled();
});
test('radio and add act on the track without activating the row', async () => {
const props = base();
render(TrackListRow, { props });
await fireEvent.click(screen.getByRole('button', { name: /play radio/i }));
await fireEvent.click(screen.getByRole('button', { name: /add roygbiv to queue/i }));
expect(playRadio).toHaveBeenCalledWith('t1');
expect(enqueueTrack).toHaveBeenCalledWith(track);
expect(props.onActivate).not.toHaveBeenCalled();
});
test('a row with no playable track hides like, radio, add and the menu', () => {
render(TrackListRow, { props: base({ track: null, disabled: true }) });
expect(screen.queryByRole('button', { name: /play radio/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /to queue/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /track actions for/i })).not.toBeInTheDocument();
});
test('a disabled row does not activate', async () => {
const props = base({ disabled: true });
render(TrackListRow, { props });
await fireEvent.click(screen.getByRole('button', { name: 'Roygbiv' }));
expect(props.onActivate).not.toHaveBeenCalled();
});
});
+5 -5
View File
@@ -18,11 +18,11 @@
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.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, playRadio } from '$lib/player/store.svelte';
import type { TrackRef } from '$lib/api/types';
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
import { createMyQuarantineQuery, unflagTrack } from '#lib/api/quarantine.js';
import { qk } from '#lib/api/queries.js';
import { playNext, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
import type { TrackRef } from '#lib/api/types.js';
let {
track,
+7 -7
View File
@@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
// Mutable mock-user handle so individual tests can flip is_admin / null
// without re-importing the module under test.
@@ -13,7 +13,7 @@ const userState = vi.hoisted(() => ({
| null
}));
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: { get value() { return userState.current; } }
}));
@@ -21,20 +21,20 @@ vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playRadio: vi.fn()
}));
import TrackMenu from './TrackMenu.svelte';
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import { playNext, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
const track = makeTrack({ title: 'Roygbiv' });
+40 -74
View File
@@ -1,38 +1,43 @@
<script lang="ts">
import type { TrackRef } from '$lib/api/types';
import { formatDuration } from '$lib/media/duration';
import { player, playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import { Plus, Check } from 'lucide-svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
// A track in a library list (album, Liked, Search). The look is
// TrackListRow's; what this adds is the track number and multi-select.
import type { TrackRef } from '#lib/api/types.js';
import { playQueue } from '#lib/player/store.svelte.js';
import { Check } from 'lucide-svelte';
import TrackListRow from './TrackListRow.svelte';
import {
selection, toggleOne, selectRange
} from '$lib/selection/store.svelte';
} from '#lib/selection/store.svelte.js';
type PlayHandler = (tracks: TrackRef[], index: number) => void;
let {
tracks,
index,
onPlay
}: { tracks: TrackRef[]; index: number; onPlay?: PlayHandler } = $props();
onPlay,
showAlbum = true
}: {
tracks: TrackRef[];
index: number;
onPlay?: PlayHandler;
/** Off on the album page, where the album is the page itself. */
showAlbum?: boolean;
} = $props();
const track = $derived(tracks[index]);
const isCurrent = $derived(player.current?.id === track.id);
const isSelected = $derived(selection.isSelected(track.id));
const selectMode = $derived(selection.active);
function activate() {
if (onPlay) onPlay(tracks, index);
else playQueue(tracks, index);
}
const subtitle = $derived(
showAlbum ? `${track.artist_name} · ${track.album_title}` : track.artist_name
);
function onRowClick(e: MouseEvent) {
// In selection mode any click toggles; outside selection mode click
// plays as before. Shift+click extends the range from the anchor
// regardless of mode (enters selection mode if nothing was picked).
if (e.shiftKey) {
function activate(e: MouseEvent | KeyboardEvent) {
// In selection mode any activation toggles; outside it, it plays.
// Shift+click extends the range from the anchor regardless of mode
// (entering selection mode if nothing was picked).
if (e instanceof MouseEvent && e.shiftKey) {
e.preventDefault();
selectRange(index, tracks);
return;
@@ -41,15 +46,8 @@
toggleOne(track, index);
return;
}
activate();
}
function onRowKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (selectMode) toggleOne(track, index);
else activate();
}
if (onPlay) onPlay(tracks, index);
else playQueue(tracks, index);
}
function onCheckboxClick(e: MouseEvent) {
@@ -57,43 +55,31 @@
if (e.shiftKey) selectRange(index, tracks);
else toggleOne(track, index);
}
function onAddClick(e: MouseEvent) {
e.stopPropagation();
enqueueTrack(track);
}
function onRadioClick(e: MouseEvent) {
e.stopPropagation();
playRadio(track.id);
}
</script>
<div
role="button"
tabindex="0"
aria-label={track.title}
data-selected={isSelected}
onclick={onRowClick}
onkeydown={onRowKey}
class="group grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''} {isSelected ? 'bg-surface-hover' : ''}"
<TrackListRow
title={track.title}
{subtitle}
durationSec={track.duration_sec}
{track}
ariaLabel={track.title}
{isSelected}
onActivate={activate}
>
<span class="relative flex h-6 w-8 items-center justify-end">
<!-- Track number: visible by default; hidden when the row is hovered
(so the checkbox replaces it) or when this row is selected. -->
{#snippet lead()}
<!-- Track number by default; the checkbox replaces it on hover, in
selection mode, or when this row is selected. -->
<span
class="tabular-nums text-text-secondary
{selectMode || isSelected ? 'hidden' : 'group-hover:hidden'}"
>{track.track_number ?? '—'}</span>
<!-- Checkbox: hidden by default; revealed on row hover OR whenever
the selection bar is active OR this row is the selected one. -->
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={isSelected ? `Deselect ${track.title}` : `Select ${track.title}`}
onclick={onCheckboxClick}
class="flex h-5 w-5 items-center justify-center rounded border
class="h-5 w-5 items-center justify-center rounded border
{isSelected
? 'border-accent bg-accent text-text-primary'
: 'border-border bg-surface text-transparent'}
@@ -101,25 +87,5 @@
>
<Check size={14} strokeWidth={2} />
</button>
</span>
<span class="truncate">{track.title}</span>
<LikeButton entityType="track" entityId={track.id} />
<button
type="button"
aria-label="Play radio from this track"
title="Play radio"
onclick={onRadioClick}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>📻</button>
<button
type="button"
aria-label={`Add ${track.title} to queue`}
title="Add to queue"
onclick={onAddClick}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>
<Plus size={16} strokeWidth={1} />
</button>
<TrackMenu {track} />
<span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
</div>
{/snippet}
</TrackListRow>
+7 -7
View File
@@ -1,24 +1,24 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
import { makeTrack } from '$test-utils/fixtures/track';
import { makeTrack } from '#test-utils/fixtures/track.js';
vi.mock('$lib/player/store.svelte', () => ({
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(),
enqueueTrack: vi.fn(),
playRadio: vi.fn(),
player: { current: undefined }
}));
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
import TrackRow from './TrackRow.svelte';
import { playQueue, enqueueTrack, playRadio } from '$lib/player/store.svelte';
import { selection, clearSelection } from '$lib/selection/store.svelte';
import { playQueue, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
import { selection, clearSelection } from '#lib/selection/store.svelte.js';
const tracks: TrackRef[] = [
makeTrack({ title: 'So What', duration_sec: 545 }),
+4 -3
View File
@@ -1,7 +1,8 @@
// Translate a vertical drag offset (px) into an integer row-delta,
// given the measured row height (px). Used by QueueTrackRow when a
// neodrag drop completes — we round to the nearest row boundary so
// the user's drop intent maps to a clean queue index.
// given the measured row height (px). Used by reorderDrag both to snap
// the row while it is dragged and to report where it was dropped — it
// rounds to the nearest row boundary, so the slot the row shows is the
// slot it lands in.
export function offsetToDelta(offsetY: number, rowHeight: number): number {
if (rowHeight <= 0) return 0;
// `|| 0` normalizes -0 to +0. Math.round(-0.5) === -0; downstream
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { reorderDrag } from './reorder-drag';
// jsdom lays nothing out, so the row and its list get explicit geometry:
// a 560px list of 56px rows. neodrag divides by offsetWidth / rect.width
// to undo CSS scaling, so offsetWidth has to agree with the rect.
function rect(height: number): DOMRect {
return {
top: 0, left: 0, right: 300, bottom: height, width: 300, height,
x: 0, y: 0, toJSON: () => ({})
} as DOMRect;
}
function mount() {
const list = document.createElement('div');
const row = document.createElement('div');
const handle = document.createElement('button');
handle.setAttribute('data-reorder-handle', '');
row.appendChild(handle);
list.appendChild(row);
document.body.appendChild(list);
list.getBoundingClientRect = () => rect(560);
row.getBoundingClientRect = () => rect(56);
Object.defineProperty(row, 'offsetWidth', { value: 300 });
return { list, row, handle };
}
function pointer(target: EventTarget, type: string, clientY: number) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: 10, clientY, button: 0 }));
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('reorderDrag', () => {
test('a drag past half a row moves one slot, and the row lands back in place', () => {
const { row } = mount();
const onMove = vi.fn();
const action = reorderDrag(row, { onMove });
pointer(row, 'pointerdown', 10);
pointer(row, 'pointermove', 40);
// 30px of a 56px row snaps to the next slot while dragging.
expect(row.style.transform).toContain('56px');
pointer(row, 'pointerup', 40);
expect(onMove).toHaveBeenCalledWith(1);
expect(row.style.transform).not.toContain('56px');
action.destroy();
});
test('a drag of under half a row stays in its slot and reports nothing', () => {
const { row } = mount();
const onMove = vi.fn();
const action = reorderDrag(row, { onMove });
pointer(row, 'pointerdown', 10);
pointer(row, 'pointermove', 30);
pointer(row, 'pointerup', 30);
expect(onMove).not.toHaveBeenCalled();
action.destroy();
});
test('with a handle, only the handle starts a drag and takes the touch gesture', () => {
const { row, handle } = mount();
const onMove = vi.fn();
const action = reorderDrag(row, { handle: '[data-reorder-handle]', onMove });
expect(row.style.touchAction).toBe('');
expect(handle.style.touchAction).toBe('none');
pointer(row, 'pointerdown', 10);
pointer(row, 'pointermove', 80);
pointer(row, 'pointerup', 80);
expect(onMove).not.toHaveBeenCalled();
pointer(handle, 'pointerdown', 10);
pointer(handle, 'pointermove', 80);
pointer(handle, 'pointerup', 80);
expect(onMove).toHaveBeenCalledWith(1);
action.destroy();
});
});
+98
View File
@@ -0,0 +1,98 @@
// Drag-to-reorder for a row in a vertical list, shared by the playlist and
// queue rows (#5393). Wraps @neodrag/svelte with the three things a list
// needs that neodrag does not do on its own:
//
// - The row moves in whole-row steps, so it visibly snaps into the slot it
// will land in. neodrag's own `grid` rounds with Math.ceil, which jumps a
// full row down on a 1px drag but needs a full row to move up, so the
// snapping is done here in `transform` with offsetToDelta's rounding.
// - The row goes back to translate 0 on drop. Left uncontrolled, neodrag
// keeps the drop offset, and once the list re-renders in its new order
// that offset leaves the row sitting between two slots.
// - Only the handle takes the touch gesture. neodrag sets touch-action:none
// on the whole node, which stops a touch screen scrolling the list from
// any row it is dragged from; with a handle, the row scrolls normally.
import { draggable, type DragOptions } from '@neodrag/svelte';
import { offsetToDelta } from './queue-row-math';
export type ReorderDragOptions = {
disabled?: boolean;
/** Selector for the drag handle inside the row. Omit to drag from anywhere. */
handle?: string;
/** Called on drop with the whole number of rows moved (never 0). */
onMove: (delta: number) => void;
};
const FALLBACK_ROW_HEIGHT = 56;
export function reorderDrag(node: HTMLElement, initial: ReorderDragOptions | null) {
let opts = initial;
let rowHeight = FALLBACK_ROW_HEIGHT;
let instance: ReturnType<typeof draggable> | null = null;
function handleEl(): HTMLElement | undefined {
if (!opts?.handle) return undefined;
return node.querySelector<HTMLElement>(opts.handle) ?? undefined;
}
function build(): DragOptions {
return {
axis: 'y',
bounds: 'parent',
disabled: opts?.disabled ?? false,
handle: handleEl(),
// Controlled at {0, 0}: every update() puts the row back in its slot.
position: { x: 0, y: 0 },
transform: ({ offsetY }) =>
`translate3d(0, ${offsetToDelta(offsetY, rowHeight) * rowHeight}px, 0)`,
onDragStart: ({ rootNode }) => {
const h = rootNode.getBoundingClientRect().height;
if (h > 0) rowHeight = h;
},
onDragEnd: ({ offsetY }) => {
const delta = offsetToDelta(offsetY, rowHeight);
instance?.update?.(build());
if (delta !== 0) opts?.onMove(delta);
}
};
}
function touchToHandle() {
const handle = handleEl();
if (!handle) return;
node.style.touchAction = '';
handle.style.touchAction = 'none';
}
function attach() {
if (!opts || instance) return;
instance = draggable(node, build());
touchToHandle();
}
attach();
return {
update(next: ReorderDragOptions | null) {
opts = next;
if (!opts) {
instance?.destroy?.();
instance = null;
node.style.touchAction = '';
node.style.transform = '';
return;
}
if (instance) {
instance.update?.(build());
touchToHandle();
} else {
attach();
}
},
destroy() {
instance?.destroy?.();
instance = null;
}
};
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
import { audioLoader, setAudioLoader, __resetAudioLoader } from './audioLoader';
const sampleTrack: TrackRef = {
+1 -1
View File
@@ -9,7 +9,7 @@
// no-op (the browser handles it via the prefetch <audio> element);
// Tauri can warm its native cache here.
import type { TrackRef } from '$lib/api/types';
import type { TrackRef } from '#lib/api/types.js';
export type AudioLoader = {
resolve: (track: TrackRef) => string;
+4 -4
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { flushSync } from 'svelte';
vi.mock('$lib/api/client', () => ({
vi.mock('#lib/api/client.js', () => ({
api: {
post: vi.fn().mockResolvedValue({ play_event_id: 'pe-1', session_id: 's-1' })
}
@@ -19,9 +19,9 @@ import {
reportDuration,
skipNext,
} from './store.svelte';
import { api } from '$lib/api/client';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
import { api } from '#lib/api/client.js';
import type { TrackRef } from '#lib/api/types.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
function track(id: string, dur = 200): TrackRef {
return makeTrack({
+2 -2
View File
@@ -1,7 +1,7 @@
import { player } from './store.svelte';
import { api } from '$lib/api/client';
import { api } from '#lib/api/client.js';
import { getOrCreateClientId } from './clientId';
import type { PlayStartedResponse, EventOkResponse } from '$lib/api/types';
import type { PlayStartedResponse, EventOkResponse } from '#lib/api/types.js';
// useEventsDispatcher installs $effect-based watchers that emit play events
// to /api/events. Call once from +layout.svelte's mount.
+3 -3
View File
@@ -1,8 +1,8 @@
import { describe, expect, test } from 'vitest';
import { dbToLinear, gainDb, playingAsAlbum, MAX_BOOST_DB } from './gain';
import type { NormalizationPrefs } from '$lib/api/normalization';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
import type { NormalizationPrefs } from '#lib/api/normalization.js';
import type { TrackRef } from '#lib/api/types.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
const prefs = (over: Partial<NormalizationPrefs> = {}): NormalizationPrefs => ({
mode: 'auto',
+2 -2
View File
@@ -1,8 +1,8 @@
// Loudness normalization math for the web player (M464 #4999). Pure, so the
// rules are tested without an <audio> element or an AudioContext.
import type { NormalizationPrefs, ReplayGain } from '$lib/api/normalization';
import type { TrackRef } from '$lib/api/types';
import type { NormalizationPrefs, ReplayGain } from '#lib/api/normalization.js';
import type { TrackRef } from '#lib/api/types.js';
// The server's gains are to the ReplayGain 2.0 reference.
const REFERENCE_LUFS = -18;
+5 -5
View File
@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
import type { TrackRef } from '#lib/api/types.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
vi.mock('$lib/auth/store.svelte', () => ({
vi.mock('#lib/auth/store.svelte.js', () => ({
user: {
get value() {
return { id: 'test-user' };
@@ -11,8 +11,8 @@ vi.mock('$lib/auth/store.svelte', () => ({
}));
const getReplayGain = vi.fn();
vi.mock('$lib/api/normalization', async (orig) => ({
...(await orig<typeof import('$lib/api/normalization')>()),
vi.mock('#lib/api/normalization.js', async (orig) => ({
...(await orig<typeof import('#lib/api/normalization.js')>()),
getReplayGain: (ids: string[]) => getReplayGain(ids)
}));
+2 -2
View File
@@ -10,8 +10,8 @@
// what the track sounded like before leveling existed.
import { SvelteMap } from 'svelte/reactivity';
import { getReplayGain, type ReplayGain } from '$lib/api/normalization';
import { normalization } from '$lib/stores/normalization.svelte';
import { getReplayGain, type ReplayGain } from '#lib/api/normalization.js';
import { normalization } from '#lib/stores/normalization.svelte.js';
import { player } from './store.svelte';
import { dbToLinear, gainDb, playingAsAlbum } from './gain';

Some files were not shown because too many files have changed in this diff Show More