Compare commits
32 Commits
dacb8b9278
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 89cf66f28a | |||
| f5a6b919aa | |||
| 231887c3b7 | |||
| cdcacc56d1 | |||
| b966ad8be5 | |||
| 6595417246 | |||
| 94361899a8 | |||
| 8a0e6782ad | |||
| 4aa071eeeb | |||
| 45a624b642 | |||
| 808c52484c | |||
| 44c8d1870f | |||
| a8e060d1a8 | |||
| 8ae447e08d | |||
| df8c67b368 | |||
| f5767ff55e | |||
| 3984c7a499 | |||
| b37fabd936 | |||
| 9c70b8a11f | |||
| 5c8f89675d | |||
| df2531171e | |||
| d1b2b40ffd | |||
| 8a70f478c3 | |||
| 9c344b98c4 | |||
| 42080b37ea | |||
| a37c19fd45 | |||
| facc215450 | |||
| 98e9344261 | |||
| 1228118027 | |||
| 538cfb9c5b | |||
| 2ad3b128d6 | |||
| 55aa8933af |
@@ -1,2 +1,6 @@
|
|||||||
# Default backend URL (overridable at runtime in the UI)
|
# Default backend URL (overridable at runtime in the UI)
|
||||||
PUBLIC_API_BASE_URL=http://localhost:8080/api/v1
|
PUBLIC_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
|
|
||||||
|
# Show the public sign-up UI on the connect screen. Set to false to hide it.
|
||||||
|
# The backend's ALLOW_REGISTRATION is the real authority; this only gates the UI.
|
||||||
|
PUBLIC_ENABLE_REGISTRATION=true
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Number of tagged (non-latest) versions to keep per image name.
|
# Number of tagged (non-latest) versions to keep per image name.
|
||||||
KEEP_VERSIONS: "5"
|
KEEP_VERSIONS: '5'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
host: ${{ steps.meta.outputs.host }}
|
host: ${{ steps.meta.outputs.host }}
|
||||||
image: ${{ steps.meta.outputs.image }}
|
image: ${{ steps.meta.outputs.image }}
|
||||||
sha: ${{ steps.meta.outputs.sha }}
|
sha: ${{ steps.meta.outputs.sha }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -2,15 +2,25 @@
|
|||||||
# Write the SPA's runtime operator config at container start.
|
# Write the SPA's runtime operator config at container start.
|
||||||
#
|
#
|
||||||
# The nginx base image runs every /docker-entrypoint.d/*.sh before launching
|
# The nginx base image runs every /docker-entrypoint.d/*.sh before launching
|
||||||
# nginx, so this overwrites the build-time public/config.js stub with the value
|
# nginx, so this overwrites the build-time public/config.js stub with the
|
||||||
# of $PUBLIC_API_BASE_URL. That lets one prebuilt image target any backend
|
# operator's runtime config ($PUBLIC_API_BASE_URL, $PUBLIC_ENABLE_REGISTRATION).
|
||||||
# origin without rebuilding. Resolution + precedence live in src/config/env.ts.
|
# That lets one prebuilt image target any backend origin and toggle sign-up
|
||||||
|
# without rebuilding. Resolution + precedence live in src/config/env.ts.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
: "${PUBLIC_API_BASE_URL:=/api/v1}"
|
: "${PUBLIC_API_BASE_URL:=/api/v1}"
|
||||||
|
: "${PUBLIC_ENABLE_REGISTRATION:=true}"
|
||||||
ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}"
|
ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}"
|
||||||
|
|
||||||
printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s"};\n' "$PUBLIC_API_BASE_URL" \
|
# Anything but "false"/"0" enables the sign-up UI (mirrors parseFlag in env.ts).
|
||||||
|
if [ "$PUBLIC_ENABLE_REGISTRATION" = "false" ] || [ "$PUBLIC_ENABLE_REGISTRATION" = "0" ]; then
|
||||||
|
ENABLE_REGISTRATION=false
|
||||||
|
else
|
||||||
|
ENABLE_REGISTRATION=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s","enableRegistration":%s};\n' \
|
||||||
|
"$PUBLIC_API_BASE_URL" "$ENABLE_REGISTRATION" \
|
||||||
>"$ROOT/config.js"
|
>"$ROOT/config.js"
|
||||||
|
|
||||||
echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL to $ROOT/config.js"
|
echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL enableRegistration=$ENABLE_REGISTRATION to $ROOT/config.js"
|
||||||
|
|||||||
Generated
+74
-5
@@ -8,7 +8,10 @@
|
|||||||
"name": "mcma-webui",
|
"name": "mcma-webui",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@olly/modern-sk": "0.1.4-3",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@olly/modern-sk": "^0.1.5",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
@@ -16,7 +19,8 @@
|
|||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-redux": "^9.3.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-router": "^7.16.0"
|
"react-router": "^7.16.0",
|
||||||
|
"use-debounce": "^10.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rsbuild/core": "^2.0.7",
|
"@rsbuild/core": "^2.0.7",
|
||||||
@@ -551,6 +555,59 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@@ -693,9 +750,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@olly/modern-sk": {
|
"node_modules/@olly/modern-sk": {
|
||||||
"version": "0.1.4-3",
|
"version": "0.1.5",
|
||||||
"resolved": "https://git.ollyhearn.ru/api/packages/olly/npm/%40olly%2Fmodern-sk/-/0.1.4-3/modern-sk-0.1.4-3.tgz",
|
"resolved": "https://git.ollyhearn.ru/api/packages/olly/npm/%40olly%2Fmodern-sk/-/0.1.5/modern-sk-0.1.5.tgz",
|
||||||
"integrity": "sha512-h+d+Jd3DBr7d51V78p1Eb5rVrpN4PAskwQFnh2X4Dk7Q8oajiMVJuhZU1amx97bKHFDHgcOfhwc4cS8P4tFCmQ==",
|
"integrity": "sha512-rhKp4U2IovSZkgdfg4oZqyhF0GgB8oR5TPlPXg0iYQEuEtff5zAgRXS+uY3dOPg2tStG3ysHUJaohD9YS2ADiA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
@@ -4469,6 +4526,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-debounce": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sidecar": {
|
"node_modules/use-sidecar": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
|||||||
+6
-2
@@ -13,7 +13,10 @@
|
|||||||
"test:watch": "rstest --watch"
|
"test:watch": "rstest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@olly/modern-sk": "0.1.4-3",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@olly/modern-sk": "^0.1.5",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
@@ -21,7 +24,8 @@
|
|||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-redux": "^9.3.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-router": "^7.16.0"
|
"react-router": "^7.16.0",
|
||||||
|
"use-debounce": "^10.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rsbuild/core": "^2.0.7",
|
"@rsbuild/core": "^2.0.7",
|
||||||
|
|||||||
+5
-2
@@ -60,7 +60,9 @@ async function handleAudio(event) {
|
|||||||
// 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a
|
// 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a
|
||||||
// complete copy, then satisfy the original request (range-sliced if asked).
|
// complete copy, then satisfy the original request (range-sliced if asked).
|
||||||
try {
|
try {
|
||||||
const fullReq = new Request(req.url, { headers: withoutRange(req.headers) });
|
const fullReq = new Request(req.url, {
|
||||||
|
headers: withoutRange(req.headers),
|
||||||
|
});
|
||||||
const resp = await fetch(fullReq);
|
const resp = await fetch(fullReq);
|
||||||
if (isCacheable(resp)) {
|
if (isCacheable(resp)) {
|
||||||
event.waitUntil(storeInCache(key, resp.clone()));
|
event.waitUntil(storeInCache(key, resp.clone()));
|
||||||
@@ -135,7 +137,8 @@ async function buildRangeResponse(response, rangeHeader) {
|
|||||||
const buf = await response.clone().arrayBuffer();
|
const buf = await response.clone().arrayBuffer();
|
||||||
const size = buf.byteLength;
|
const size = buf.byteLength;
|
||||||
const r = parseRangeHeader(rangeHeader, size);
|
const r = parseRangeHeader(rangeHeader, size);
|
||||||
const type = response.headers.get('content-type') || 'application/octet-stream';
|
const type =
|
||||||
|
response.headers.get('content-type') || 'application/octet-stream';
|
||||||
|
|
||||||
if (!r) {
|
if (!r) {
|
||||||
return new Response(buf, {
|
return new Response(buf, {
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ export default defineConfig({
|
|||||||
// "Install app". The service worker (audio offline cache) is registered
|
// "Install app". The service worker (audio offline cache) is registered
|
||||||
// from src/index.tsx, not here.
|
// from src/index.tsx, not here.
|
||||||
tags: [
|
tags: [
|
||||||
|
// Theme bootstrap — runs inline before first paint to kill the flash of
|
||||||
|
// white on a dark-themed load. Mirrors modern-sk's own logic exactly
|
||||||
|
// (localStorage 'modern-sk-theme' || 'dark' → data-theme on <html>), so
|
||||||
|
// there's no second flip when <ThemeProvider> mounts. Inline (not an
|
||||||
|
// external file) so it costs zero round-trips.
|
||||||
|
{
|
||||||
|
tag: 'script',
|
||||||
|
children:
|
||||||
|
"(function(){try{var t=localStorage.getItem('modern-sk-theme')||'dark';document.documentElement.setAttribute('data-theme',t);}catch(e){}})();",
|
||||||
|
head: true,
|
||||||
|
append: false,
|
||||||
|
},
|
||||||
// Runtime operator config. A classic (non-deferred) head script, so it
|
// Runtime operator config. A classic (non-deferred) head script, so it
|
||||||
// runs before the deferred app bundle and window.__APP_CONFIG__ is set by
|
// runs before the deferred app bundle and window.__APP_CONFIG__ is set by
|
||||||
// the time src/config/env.ts reads it. Served from public/ in dev and
|
// the time src/config/env.ts reads it. Served from public/ in dev and
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
import { toUser, type RawUser } from '../mappers';
|
import { toUser, type RawUser } from '../mappers';
|
||||||
import type { AuthTokens, LoginRequest, LoginResponse, User } from '../types';
|
import type {
|
||||||
|
AuthTokens,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
RegisterRequest,
|
||||||
|
User,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth seam over the backend's wire format: tokens-only login + a separate
|
* Auth seam over the backend's wire format: tokens-only login + a separate
|
||||||
@@ -48,6 +54,29 @@ export const authApi = api.injectEndpoints({
|
|||||||
return { data: { user, tokens } };
|
return { data: { user, tokens } };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// Sign-up mirrors login: POST /auth/register returns a token pair (the
|
||||||
|
// backend logs the new account straight in), then GET /auth/me resolves the
|
||||||
|
// user — so the UI gets the same unified { user, tokens } as login.
|
||||||
|
register: build.mutation<LoginResponse, RegisterRequest>({
|
||||||
|
async queryFn(body, _api, _extra, baseQuery) {
|
||||||
|
const tokenRes = await baseQuery({
|
||||||
|
url: '/auth/register',
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (tokenRes.error) return { error: tokenRes.error };
|
||||||
|
const tokens = toTokens(tokenRes.data as RawTokenResponse);
|
||||||
|
|
||||||
|
const meRes = await baseQuery({
|
||||||
|
url: '/auth/me',
|
||||||
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||||
|
});
|
||||||
|
if (meRes.error) return { error: meRes.error };
|
||||||
|
const user = toUser(meRes.data as RawUser);
|
||||||
|
|
||||||
|
return { data: { user, tokens } };
|
||||||
|
},
|
||||||
|
}),
|
||||||
logout: build.mutation<void, { refreshToken: string }>({
|
logout: build.mutation<void, { refreshToken: string }>({
|
||||||
query: ({ refreshToken }) => ({
|
query: ({ refreshToken }) => ({
|
||||||
url: '/auth/logout',
|
url: '/auth/logout',
|
||||||
@@ -74,6 +103,7 @@ export const authApi = api.injectEndpoints({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useLoginMutation,
|
useLoginMutation,
|
||||||
|
useRegisterMutation,
|
||||||
useLogoutMutation,
|
useLogoutMutation,
|
||||||
useRefreshTokenMutation,
|
useRefreshTokenMutation,
|
||||||
useMeQuery,
|
useMeQuery,
|
||||||
|
|||||||
@@ -1,30 +1,88 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
import type { DownloadJob } from '../types';
|
import {
|
||||||
|
toDownloadJob,
|
||||||
|
toPage,
|
||||||
|
type RawDownloadJob,
|
||||||
|
type RawPaged,
|
||||||
|
} from '../mappers';
|
||||||
|
import type {
|
||||||
|
DownloadJob,
|
||||||
|
DownloadRequestResult,
|
||||||
|
PaginatedResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
// NOTE: the backend `/downloads` routes are still unimplemented stubs (they
|
interface ListParams {
|
||||||
// return no body / no schema). The request shapes below are provisional and the
|
status?: DownloadJob['status'];
|
||||||
// responses will need the same snake→camel mapper treatment as library/playlists
|
/** Only the current user's jobs (backend `mine=true`). */
|
||||||
// (see `mappers.ts`) once the backend defines DownloadJob's wire format. Do not
|
mine?: boolean;
|
||||||
// wire these into the UI until then.
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCreateResponse {
|
||||||
|
already_in_library: boolean;
|
||||||
|
track_id: string | null;
|
||||||
|
job: RawDownloadJob | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const downloadsApi = api.injectEndpoints({
|
export const downloadsApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getDownloads: build.query<
|
getDownloads: build.query<
|
||||||
DownloadJob[],
|
PaginatedResponse<DownloadJob>,
|
||||||
{ status?: DownloadJob['status'] } | void
|
ListParams | void
|
||||||
>({
|
>({
|
||||||
query: (params) => ({ url: '/downloads', params: params ?? {} }),
|
query: (params) => {
|
||||||
providesTags: ['Download'],
|
const p = params ?? {};
|
||||||
|
const size = p.pageSize ?? 50;
|
||||||
|
return {
|
||||||
|
url: '/downloads',
|
||||||
|
params: {
|
||||||
|
status: p.status,
|
||||||
|
mine: p.mine ? true : undefined,
|
||||||
|
limit: size,
|
||||||
|
offset: ((p.page ?? 1) - 1) * size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
transformResponse: (raw: RawPaged<RawDownloadJob>) =>
|
||||||
|
toPage(raw, toDownloadJob),
|
||||||
|
providesTags: (result) =>
|
||||||
|
result
|
||||||
|
? [
|
||||||
|
...result.items.map(({ id }) => ({
|
||||||
|
type: 'Download' as const,
|
||||||
|
id,
|
||||||
|
})),
|
||||||
|
'Download',
|
||||||
|
]
|
||||||
|
: ['Download'],
|
||||||
}),
|
}),
|
||||||
addDownload: build.mutation<
|
getDownload: build.query<DownloadJob, string>({
|
||||||
DownloadJob,
|
query: (id) => `/downloads/${id}`,
|
||||||
{
|
transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
|
||||||
url: string;
|
providesTags: (_r, _e, id) => [{ type: 'Download', id }],
|
||||||
metadata?: { title?: string; artist?: string; album?: string };
|
}),
|
||||||
}
|
/** Request a download of a discovered item (§A4 "Download to library"). */
|
||||||
|
createDownload: build.mutation<
|
||||||
|
DownloadRequestResult,
|
||||||
|
{ source: string; sourceId: string; query?: string }
|
||||||
>({
|
>({
|
||||||
query: (body) => ({ url: '/downloads', method: 'POST', body }),
|
query: (body) => ({
|
||||||
invalidatesTags: ['Download'],
|
url: '/downloads',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
source: body.source,
|
||||||
|
source_id: body.sourceId,
|
||||||
|
query: body.query,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
transformResponse: (raw: RawCreateResponse): DownloadRequestResult => ({
|
||||||
|
alreadyInLibrary: raw.already_in_library,
|
||||||
|
trackId: raw.track_id ?? undefined,
|
||||||
|
job: raw.job ? toDownloadJob(raw.job) : undefined,
|
||||||
|
}),
|
||||||
|
// A completed dedup can surface an existing library track; refresh both.
|
||||||
|
invalidatesTags: ['Download', 'Track'],
|
||||||
}),
|
}),
|
||||||
cancelDownload: build.mutation<void, string>({
|
cancelDownload: build.mutation<void, string>({
|
||||||
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
|
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
|
||||||
@@ -32,7 +90,8 @@ export const downloadsApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
retryDownload: build.mutation<DownloadJob, string>({
|
retryDownload: build.mutation<DownloadJob, string>({
|
||||||
query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }),
|
query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }),
|
||||||
invalidatesTags: ['Download'],
|
transformResponse: (raw: RawDownloadJob) => toDownloadJob(raw),
|
||||||
|
invalidatesTags: (_r, _e, id) => [{ type: 'Download', id }, 'Download'],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
@@ -40,7 +99,8 @@ export const downloadsApi = api.injectEndpoints({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useGetDownloadsQuery,
|
useGetDownloadsQuery,
|
||||||
useAddDownloadMutation,
|
useGetDownloadQuery,
|
||||||
|
useCreateDownloadMutation,
|
||||||
useCancelDownloadMutation,
|
useCancelDownloadMutation,
|
||||||
useRetryDownloadMutation,
|
useRetryDownloadMutation,
|
||||||
} = downloadsApi;
|
} = downloadsApi;
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { api } from '../index';
|
|||||||
import {
|
import {
|
||||||
toAlbum,
|
toAlbum,
|
||||||
toArtist,
|
toArtist,
|
||||||
|
toMetadataMatch,
|
||||||
toPage,
|
toPage,
|
||||||
toTrack,
|
toTrack,
|
||||||
type RawAlbum,
|
type RawAlbum,
|
||||||
type RawArtist,
|
type RawArtist,
|
||||||
|
type RawMetadataMatch,
|
||||||
type RawPaged,
|
type RawPaged,
|
||||||
type RawTrack,
|
type RawTrack,
|
||||||
} from '../mappers';
|
} from '../mappers';
|
||||||
@@ -13,6 +15,8 @@ import type {
|
|||||||
Track,
|
Track,
|
||||||
Album,
|
Album,
|
||||||
Artist,
|
Artist,
|
||||||
|
MetadataEdit,
|
||||||
|
MetadataMatch,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
LibraryFilters,
|
LibraryFilters,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
@@ -38,6 +42,7 @@ function trackParams(f: LibraryFilters) {
|
|||||||
q: f.search,
|
q: f.search,
|
||||||
artist_id: f.artistId,
|
artist_id: f.artistId,
|
||||||
album_id: f.albumId,
|
album_id: f.albumId,
|
||||||
|
source: f.source,
|
||||||
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
|
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
|
||||||
order: f.sortOrder,
|
order: f.sortOrder,
|
||||||
...paging(f.page, f.pageSize),
|
...paging(f.page, f.pageSize),
|
||||||
@@ -161,6 +166,41 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
providesTags: ['Track', 'Album', 'Artist'],
|
providesTags: ['Track', 'Album', 'Artist'],
|
||||||
}),
|
}),
|
||||||
|
getMetadataMatches: build.query<MetadataMatch[], string>({
|
||||||
|
query: (trackId) => `/tracks/${trackId}/metadata/matches`,
|
||||||
|
transformResponse: (raw: { items: RawMetadataMatch[] }) =>
|
||||||
|
raw.items.map(toMetadataMatch),
|
||||||
|
}),
|
||||||
|
applyMetadata: build.mutation<
|
||||||
|
Track,
|
||||||
|
{ trackId: string; edit: MetadataEdit }
|
||||||
|
>({
|
||||||
|
query: ({ trackId, edit }) => ({
|
||||||
|
url: `/tracks/${trackId}/metadata`,
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
title: edit.title,
|
||||||
|
artist_name: edit.artistName,
|
||||||
|
album_title: edit.albumTitle,
|
||||||
|
year: edit.year,
|
||||||
|
genre: edit.genre,
|
||||||
|
track_number: edit.trackNumber,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
transformResponse: (raw: RawTrack) => toTrack(raw),
|
||||||
|
invalidatesTags: (_r, _e, { trackId }) => [
|
||||||
|
{ type: 'Track', id: trackId },
|
||||||
|
'Album',
|
||||||
|
'Artist',
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
enrichTrack: build.mutation<{ track_id: string; job_id: string }, string>({
|
||||||
|
query: (trackId) => ({
|
||||||
|
url: `/tracks/${trackId}/metadata/enrich`,
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@@ -176,4 +216,7 @@ export const {
|
|||||||
useGetArtistAlbumsQuery,
|
useGetArtistAlbumsQuery,
|
||||||
useGetArtistTracksQuery,
|
useGetArtistTracksQuery,
|
||||||
useSearchLibraryQuery,
|
useSearchLibraryQuery,
|
||||||
|
useLazyGetMetadataMatchesQuery,
|
||||||
|
useApplyMetadataMutation,
|
||||||
|
useEnrichTrackMutation,
|
||||||
} = libraryApi;
|
} = libraryApi;
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { api } from '../index';
|
||||||
|
import {
|
||||||
|
toSearchResult,
|
||||||
|
toSourceInfo,
|
||||||
|
type RawSearchResult,
|
||||||
|
type RawSourceInfo,
|
||||||
|
} from '../mappers';
|
||||||
|
import type { ExternalSearchResult, SourceInfo } from '../types';
|
||||||
|
|
||||||
|
interface RawSearchResponse {
|
||||||
|
results: RawSearchResult[];
|
||||||
|
searched_sources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
results: ExternalSearchResult[];
|
||||||
|
/** Names of the sources actually queried (available ones). */
|
||||||
|
searchedSources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapSearch = (raw: RawSearchResponse): SearchResponse => ({
|
||||||
|
results: raw.results.map(toSearchResult),
|
||||||
|
searchedSources: raw.searched_sources,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchApi = api.injectEndpoints({
|
||||||
|
endpoints: (build) => ({
|
||||||
|
/** Registered source backends (for the §A4 source picker). */
|
||||||
|
getSources: build.query<SourceInfo[], void>({
|
||||||
|
query: () => '/sources',
|
||||||
|
transformResponse: (raw: RawSourceInfo[]) => raw.map(toSourceInfo),
|
||||||
|
}),
|
||||||
|
/** Search across every available fetch source (no `source` → aggregate). */
|
||||||
|
searchExternal: build.query<
|
||||||
|
SearchResponse,
|
||||||
|
{ q: string; source?: string; limit?: number }
|
||||||
|
>({
|
||||||
|
query: ({ q, source, limit }) =>
|
||||||
|
source
|
||||||
|
? { url: `/sources/${source}/search`, params: { q, limit } }
|
||||||
|
: { url: '/search', params: { q, limit } },
|
||||||
|
transformResponse: mapSearch,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
overrideExisting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { useGetSourcesQuery, useLazySearchExternalQuery } = searchApi;
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
|
import { toStorageStats, type RawStorageStats } from '../mappers';
|
||||||
import type { StorageStats } from '../types';
|
import type { StorageStats } from '../types';
|
||||||
|
|
||||||
// NOTE: the backend `/storage` routes are still unimplemented stubs (no body /
|
// `GET /storage` returns library + disk statistics (§A6). The maintenance
|
||||||
// no schema), and the real paths differ from these placeholders (`GET /storage`,
|
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
||||||
// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
// `POST /storage/cleanup`) are still backend stubs and unused by the UI.
|
||||||
// `POST /storage/cleanup`). Re-point paths and add snake→camel mappers (see
|
|
||||||
// `mappers.ts`) once the backend defines the storage response shapes; until then
|
|
||||||
// these are provisional and unused by the UI.
|
|
||||||
|
|
||||||
export const storageApi = api.injectEndpoints({
|
export const storageApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getStorageStats: build.query<StorageStats, void>({
|
getStorageStats: build.query<StorageStats, void>({
|
||||||
query: () => '/storage/stats',
|
query: () => '/storage',
|
||||||
|
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
|
||||||
providesTags: ['Storage'],
|
providesTags: ['Storage'],
|
||||||
}),
|
}),
|
||||||
scanStorage: build.mutation<{ jobId: string }, void>({
|
scanStorage: build.mutation<{ jobId: string }, void>({
|
||||||
|
|||||||
@@ -17,3 +17,33 @@ export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
|||||||
const base = getApiBaseUrl();
|
const base = getApiBaseUrl();
|
||||||
return `${base}${artUrl}`;
|
return `${base}${artUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover image URL for a track, served by `GET /tracks/{id}/cover`. Like the
|
||||||
|
* audio stream, an `<img>` can't send an `Authorization` header, so the access
|
||||||
|
* token rides as `?token=`. Returns undefined when the track has no cover.
|
||||||
|
*/
|
||||||
|
export function getTrackCoverUrl(
|
||||||
|
trackId: string,
|
||||||
|
token: string,
|
||||||
|
hasCover: boolean,
|
||||||
|
): string | undefined {
|
||||||
|
if (!hasCover) return undefined;
|
||||||
|
const base = getApiBaseUrl();
|
||||||
|
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover image URL for an album, served by `GET /albums/{id}/cover`. Same
|
||||||
|
* `?token=` rationale as the track cover. Returns undefined when the album has
|
||||||
|
* no cover (so callers fall back to generated tile art).
|
||||||
|
*/
|
||||||
|
export function getAlbumCoverUrl(
|
||||||
|
albumId: string,
|
||||||
|
token: string,
|
||||||
|
hasCover: boolean,
|
||||||
|
): string | undefined {
|
||||||
|
if (!hasCover) return undefined;
|
||||||
|
const base = getApiBaseUrl();
|
||||||
|
return `${base}/albums/${albumId}/cover?token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { REHYDRATE_API, type RehydrateApiPayload } from './rehydrate';
|
|||||||
export const api = createApi({
|
export const api = createApi({
|
||||||
reducerPath: 'api',
|
reducerPath: 'api',
|
||||||
baseQuery: baseQueryWithReauth,
|
baseQuery: baseQueryWithReauth,
|
||||||
|
// Stale-while-revalidate. The Tier-2 rehydrated cache (below) seeds fulfilled
|
||||||
|
// entries at startup, which would otherwise make RTKQ serve stale data and
|
||||||
|
// never hit the network. These flags keep showing the cached snapshot
|
||||||
|
// instantly but silently refetch from the server whenever it's reachable —
|
||||||
|
// on mount/arg change, on reconnect, and on window refocus. The result: the
|
||||||
|
// server is the source of truth when online; the cache is only a fallback.
|
||||||
|
refetchOnMountOrArgChange: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnFocus: true,
|
||||||
tagTypes: [
|
tagTypes: [
|
||||||
'Track',
|
'Track',
|
||||||
'Album',
|
'Album',
|
||||||
|
|||||||
+187
-2
@@ -14,12 +14,33 @@
|
|||||||
import type {
|
import type {
|
||||||
Album,
|
Album,
|
||||||
Artist,
|
Artist,
|
||||||
|
DownloadJob,
|
||||||
|
DownloadStatus,
|
||||||
|
ExternalSearchResult,
|
||||||
|
MetadataMatch,
|
||||||
|
MetadataStatus,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
SourceInfo,
|
||||||
|
StorageStats,
|
||||||
Track,
|
Track,
|
||||||
User,
|
User,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
const METADATA_STATUSES: readonly MetadataStatus[] = [
|
||||||
|
'pending',
|
||||||
|
'enriched',
|
||||||
|
'failed',
|
||||||
|
'manual',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Map the backend's free-form status string onto the UI union, defaulting any
|
||||||
|
* unknown value to `pending` (a safe "not yet identified" state). */
|
||||||
|
const toMetadataStatus = (raw: string): MetadataStatus =>
|
||||||
|
(METADATA_STATUSES as readonly string[]).includes(raw)
|
||||||
|
? (raw as MetadataStatus)
|
||||||
|
: 'pending';
|
||||||
|
|
||||||
// ---- raw wire shapes (snake_case, exactly as the backend emits) ----
|
// ---- raw wire shapes (snake_case, exactly as the backend emits) ----
|
||||||
|
|
||||||
export interface RawPaged<T> {
|
export interface RawPaged<T> {
|
||||||
@@ -48,11 +69,29 @@ export interface RawTrack {
|
|||||||
duration_seconds: number | null;
|
duration_seconds: number | null;
|
||||||
file_format: string;
|
file_format: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
|
genre: string | null;
|
||||||
|
year: number | null;
|
||||||
|
track_number: number | null;
|
||||||
metadata_status: string;
|
metadata_status: string;
|
||||||
|
metadata_error: string | null;
|
||||||
|
enriched_at: string | null;
|
||||||
|
has_cover: boolean;
|
||||||
source: string;
|
source: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One AcoustID candidate, as returned by `GET /tracks/{id}/metadata/matches`. */
|
||||||
|
export interface RawMetadataMatch {
|
||||||
|
acoustid: string;
|
||||||
|
score: number;
|
||||||
|
recording_mbid: string | null;
|
||||||
|
release_group_mbid: string | null;
|
||||||
|
title: string | null;
|
||||||
|
artist: string | null;
|
||||||
|
album: string | null;
|
||||||
|
year: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RawAlbum {
|
export interface RawAlbum {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -60,6 +99,7 @@ export interface RawAlbum {
|
|||||||
artist_name: string;
|
artist_name: string;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
track_count: number;
|
track_count: number;
|
||||||
|
has_cover: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,16 +138,37 @@ export const toTrack = (r: RawTrack): Track => ({
|
|||||||
artistName: r.artist_name,
|
artistName: r.artist_name,
|
||||||
albumId: r.album_id ?? '',
|
albumId: r.album_id ?? '',
|
||||||
albumTitle: r.album_title ?? '',
|
albumTitle: r.album_title ?? '',
|
||||||
// Cover endpoints aren't wired on the backend yet — leave art undefined so the
|
// `has_cover` says a cover exists; the actual URL (which needs a `?token=`) is
|
||||||
// UI renders generated tile art instead of a broken image.
|
// built in the component from the track id — see `getTrackCoverUrl`. Keep
|
||||||
|
// `albumArtUrl` undefined so callers fall back to generated tile art.
|
||||||
albumArtUrl: undefined,
|
albumArtUrl: undefined,
|
||||||
|
hasCover: r.has_cover,
|
||||||
durationMs: (r.duration_seconds ?? 0) * 1000,
|
durationMs: (r.duration_seconds ?? 0) * 1000,
|
||||||
// The lean TrackOut carries no availability/like state: a track returned by
|
// The lean TrackOut carries no availability/like state: a track returned by
|
||||||
// the library is on the server, and per-track like state comes from /likes.
|
// the library is on the server, and per-track like state comes from /likes.
|
||||||
availability: 'server',
|
availability: 'server',
|
||||||
|
metadataStatus: toMetadataStatus(r.metadata_status),
|
||||||
|
metadataError: r.metadata_error ?? undefined,
|
||||||
|
genre: r.genre ?? undefined,
|
||||||
|
year: r.year ?? undefined,
|
||||||
|
trackNumber: r.track_number ?? undefined,
|
||||||
liked: false,
|
liked: false,
|
||||||
format: r.file_format,
|
format: r.file_format,
|
||||||
fileSize: r.file_size,
|
fileSize: r.file_size,
|
||||||
|
source: r.source,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
enrichedAt: r.enriched_at ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toMetadataMatch = (r: RawMetadataMatch): MetadataMatch => ({
|
||||||
|
acoustid: r.acoustid,
|
||||||
|
score: r.score,
|
||||||
|
recordingMbid: r.recording_mbid ?? undefined,
|
||||||
|
releaseGroupMbid: r.release_group_mbid ?? undefined,
|
||||||
|
title: r.title ?? undefined,
|
||||||
|
artist: r.artist ?? undefined,
|
||||||
|
album: r.album ?? undefined,
|
||||||
|
year: r.year ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const toAlbum = (r: RawAlbum): Album => ({
|
export const toAlbum = (r: RawAlbum): Album => ({
|
||||||
@@ -115,7 +176,10 @@ export const toAlbum = (r: RawAlbum): Album => ({
|
|||||||
title: r.title,
|
title: r.title,
|
||||||
artistId: r.artist_id,
|
artistId: r.artist_id,
|
||||||
artistName: r.artist_name,
|
artistName: r.artist_name,
|
||||||
|
// The album record carries no cover *URL*; `hasCover` says one exists, and the
|
||||||
|
// URL (which needs `?token=`) is built in components via `getAlbumCoverUrl`.
|
||||||
artUrl: undefined,
|
artUrl: undefined,
|
||||||
|
hasCover: r.has_cover,
|
||||||
year: r.year ?? undefined,
|
year: r.year ?? undefined,
|
||||||
trackCount: r.track_count,
|
trackCount: r.track_count,
|
||||||
// AlbumOut has no aggregate duration; computed client-side from tracks when
|
// AlbumOut has no aggregate duration; computed client-side from tracks when
|
||||||
@@ -145,6 +209,127 @@ export const toPlaylist = (r: RawPlaylist): Playlist => ({
|
|||||||
updatedAt: r.created_at,
|
updatedAt: r.created_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface RawStorageStats {
|
||||||
|
total_tracks: number;
|
||||||
|
total_artists: number;
|
||||||
|
total_albums: number;
|
||||||
|
total_size: number;
|
||||||
|
total_duration_seconds: number;
|
||||||
|
largest_track_size: number;
|
||||||
|
earliest_added: string | null;
|
||||||
|
latest_added: string | null;
|
||||||
|
by_format: { file_format: string; track_count: number; total_size: number }[];
|
||||||
|
by_metadata_status: Record<string, number>;
|
||||||
|
by_source: Record<string, number>;
|
||||||
|
top_genres: { genre: string; track_count: number }[];
|
||||||
|
disk: { total: number; used: number; free: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { RawStorageStats };
|
||||||
|
|
||||||
|
export const toStorageStats = (r: RawStorageStats): StorageStats => ({
|
||||||
|
totalTracks: r.total_tracks,
|
||||||
|
totalArtists: r.total_artists,
|
||||||
|
totalAlbums: r.total_albums,
|
||||||
|
totalSize: r.total_size,
|
||||||
|
totalDurationSeconds: r.total_duration_seconds,
|
||||||
|
largestTrackSize: r.largest_track_size,
|
||||||
|
earliestAdded: r.earliest_added ?? undefined,
|
||||||
|
latestAdded: r.latest_added ?? undefined,
|
||||||
|
byFormat: r.by_format.map((f) => ({
|
||||||
|
fileFormat: f.file_format,
|
||||||
|
trackCount: f.track_count,
|
||||||
|
totalSize: f.total_size,
|
||||||
|
})),
|
||||||
|
byMetadataStatus: r.by_metadata_status,
|
||||||
|
bySource: r.by_source,
|
||||||
|
topGenres: r.top_genres.map((g) => ({
|
||||||
|
genre: g.genre,
|
||||||
|
trackCount: g.track_count,
|
||||||
|
})),
|
||||||
|
disk: r.disk ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- downloads + external search ----
|
||||||
|
|
||||||
|
const DOWNLOAD_STATUSES: readonly DownloadStatus[] = [
|
||||||
|
'queued',
|
||||||
|
'downloading',
|
||||||
|
'enriching',
|
||||||
|
'done',
|
||||||
|
'failed',
|
||||||
|
];
|
||||||
|
|
||||||
|
const toDownloadStatus = (raw: string): DownloadStatus =>
|
||||||
|
(DOWNLOAD_STATUSES as readonly string[]).includes(raw)
|
||||||
|
? (raw as DownloadStatus)
|
||||||
|
: 'queued';
|
||||||
|
|
||||||
|
export interface RawDownloadJob {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
source_id: string | null;
|
||||||
|
query: string | null;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
error_message: string | null;
|
||||||
|
retry_count: number;
|
||||||
|
track_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toDownloadJob = (r: RawDownloadJob): DownloadJob => ({
|
||||||
|
id: r.id,
|
||||||
|
source: r.source,
|
||||||
|
sourceId: r.source_id ?? undefined,
|
||||||
|
query: r.query ?? undefined,
|
||||||
|
status: toDownloadStatus(r.status),
|
||||||
|
progress: r.progress,
|
||||||
|
errorMessage: r.error_message ?? undefined,
|
||||||
|
trackId: r.track_id ?? undefined,
|
||||||
|
retryCount: r.retry_count,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RawSearchResult {
|
||||||
|
source: string;
|
||||||
|
source_id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string | null;
|
||||||
|
album: string | null;
|
||||||
|
duration_seconds: number | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toSearchResult = (r: RawSearchResult): ExternalSearchResult => ({
|
||||||
|
source: r.source,
|
||||||
|
sourceId: r.source_id,
|
||||||
|
title: r.title,
|
||||||
|
artist: r.artist ?? undefined,
|
||||||
|
album: r.album ?? undefined,
|
||||||
|
durationMs:
|
||||||
|
r.duration_seconds != null ? r.duration_seconds * 1000 : undefined,
|
||||||
|
thumbnailUrl: r.thumbnail_url ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RawSourceInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toSourceInfo = (r: RawSourceInfo): SourceInfo => ({
|
||||||
|
name: r.name,
|
||||||
|
label: r.label,
|
||||||
|
// Backend kinds are `indexable` | `fetch`; default unknowns to indexable
|
||||||
|
// (the conservative, non-searchable bucket).
|
||||||
|
kind: r.kind === 'fetch' ? 'fetch' : 'indexable',
|
||||||
|
available: r.available,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
|
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
|
||||||
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export const REHYDRATE_API = 'api/rehydrate';
|
|||||||
export interface RehydrateApiPayload {
|
export interface RehydrateApiPayload {
|
||||||
queries: Record<string, unknown>;
|
queries: Record<string, unknown>;
|
||||||
mutations: Record<string, unknown>;
|
mutations: Record<string, unknown>;
|
||||||
|
// RTKQ's invalidation slice reads `provided.tags`/`provided.keys` during
|
||||||
|
// rehydration (it does `Object.entries(provided.tags ?? {})`), so `provided`
|
||||||
|
// must be an object — a bare `{ queries, mutations }` makes it crash on
|
||||||
|
// `provided.tags` of undefined. Always present; empty objects are valid.
|
||||||
|
provided: { tags: Record<string, unknown>; keys: Record<string, unknown> };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API);
|
export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API);
|
||||||
|
|||||||
+130
-10
@@ -1,5 +1,13 @@
|
|||||||
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
|
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata-enrichment state, distinct from file `availability`. `pending` = the
|
||||||
|
* worker hasn't finished (or hasn't started); `enriched` = identity found;
|
||||||
|
* `failed` = no match / a worker error (see `metadataError`); `manual` = user-
|
||||||
|
* edited and never auto-overwritten.
|
||||||
|
*/
|
||||||
|
export type MetadataStatus = 'pending' | 'enriched' | 'failed' | 'manual';
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -8,16 +16,26 @@ export interface Track {
|
|||||||
albumId: string;
|
albumId: string;
|
||||||
albumTitle: string;
|
albumTitle: string;
|
||||||
albumArtUrl?: string;
|
albumArtUrl?: string;
|
||||||
|
hasCover: boolean;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
trackNumber?: number;
|
trackNumber?: number;
|
||||||
discNumber?: number;
|
discNumber?: number;
|
||||||
year?: number;
|
year?: number;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
availability: TrackAvailability;
|
availability: TrackAvailability;
|
||||||
|
metadataStatus: MetadataStatus;
|
||||||
|
/** Human-readable reason the last enrichment run set `failed`; else undefined. */
|
||||||
|
metadataError?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
format?: string;
|
format?: string;
|
||||||
bitrate?: number;
|
bitrate?: number;
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
|
/** Where the track entered the library (e.g. `upload`, `local_folder`). */
|
||||||
|
source?: string;
|
||||||
|
/** ISO timestamp the track was added to the library. */
|
||||||
|
createdAt?: string;
|
||||||
|
/** ISO timestamp the last successful enrichment ran; undefined if never. */
|
||||||
|
enrichedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Album {
|
export interface Album {
|
||||||
@@ -26,6 +44,8 @@ export interface Album {
|
|||||||
artistId: string;
|
artistId: string;
|
||||||
artistName: string;
|
artistName: string;
|
||||||
artUrl?: string;
|
artUrl?: string;
|
||||||
|
/** Whether the album has cover art served by `GET /albums/{id}/cover`. */
|
||||||
|
hasCover: boolean;
|
||||||
year?: number;
|
year?: number;
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
totalDurationMs: number;
|
totalDurationMs: number;
|
||||||
@@ -58,32 +78,102 @@ export interface PlaylistTrack extends Track {
|
|||||||
addedAt: string;
|
addedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lifecycle of a download job, mirroring the backend's `DownloadStatus`.
|
||||||
|
* `enriching` = file fetched, metadata pipeline running; `done` = imported. */
|
||||||
|
export type DownloadStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'downloading'
|
||||||
|
| 'enriching'
|
||||||
|
| 'done'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
export interface DownloadJob {
|
export interface DownloadJob {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
/** Source backend the job pulls from (e.g. `youtube`). */
|
||||||
title?: string;
|
source: string;
|
||||||
artist?: string;
|
/** Stable per-source id of the item (e.g. a YouTube videoId). */
|
||||||
album?: string;
|
sourceId?: string;
|
||||||
status: 'queued' | 'downloading' | 'processing' | 'done' | 'error';
|
/** The free-text query the job was created from, for display. */
|
||||||
|
query?: string;
|
||||||
|
status: DownloadStatus;
|
||||||
|
/** Fraction complete, 0..1. */
|
||||||
progress: number;
|
progress: number;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
/** Set once the download finishes and the library track exists. */
|
||||||
trackId?: string;
|
trackId?: string;
|
||||||
|
retryCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of POST /downloads. Either the item is already in the library
|
||||||
|
* (`alreadyInLibrary`, `trackId` set), or a job covers it (`job`). */
|
||||||
|
export interface DownloadRequestResult {
|
||||||
|
alreadyInLibrary: boolean;
|
||||||
|
trackId?: string;
|
||||||
|
job?: DownloadJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One hit from an external (fetch) source — the §A4 discover screen. */
|
||||||
|
export interface ExternalSearchResult {
|
||||||
|
source: string;
|
||||||
|
sourceId: string;
|
||||||
|
title: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A registered source backend, from GET /sources. `kind`: `indexable` (a
|
||||||
|
* mounted folder) or `fetch` (searchable + downloadable, e.g. YouTube). */
|
||||||
|
export interface SourceInfo {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'indexable' | 'fetch';
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UploadResponse {
|
export interface UploadResponse {
|
||||||
track_id: string;
|
track_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
already_exists: boolean;
|
already_exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageStats {
|
export interface StorageFormatBreakdown {
|
||||||
totalBytes: number;
|
fileFormat: string;
|
||||||
usedBytes: number;
|
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
albumCount: number;
|
totalSize: number;
|
||||||
artistCount: number;
|
}
|
||||||
|
|
||||||
|
export interface StorageGenreCount {
|
||||||
|
genre: string;
|
||||||
|
trackCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capacity of the volume backing the media store. Absent for object-store
|
||||||
|
* backends (S3), which have no fixed disk to report. */
|
||||||
|
export interface StorageDiskUsage {
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
free: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageStats {
|
||||||
|
totalTracks: number;
|
||||||
|
totalArtists: number;
|
||||||
|
totalAlbums: number;
|
||||||
|
/** Sum of every track's recorded file size (the library's footprint). */
|
||||||
|
totalSize: number;
|
||||||
|
totalDurationSeconds: number;
|
||||||
|
largestTrackSize: number;
|
||||||
|
earliestAdded?: string;
|
||||||
|
latestAdded?: string;
|
||||||
|
byFormat: StorageFormatBreakdown[];
|
||||||
|
byMetadataStatus: Record<string, number>;
|
||||||
|
bySource: Record<string, number>;
|
||||||
|
topGenres: StorageGenreCount[];
|
||||||
|
disk?: StorageDiskUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -113,6 +203,11 @@ export interface LoginResponse {
|
|||||||
tokens: AuthTokens;
|
tokens: AuthTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -126,6 +221,8 @@ export interface LibraryFilters {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
|
/** Filter by ingest origin, e.g. `upload`, `youtube`, `local`. */
|
||||||
|
source?: string;
|
||||||
liked?: boolean;
|
liked?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -138,3 +235,26 @@ export interface ApiError {
|
|||||||
message: string;
|
message: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One AcoustID candidate from `GET /tracks/{id}/metadata/matches` (§A7). */
|
||||||
|
export interface MetadataMatch {
|
||||||
|
acoustid: string;
|
||||||
|
/** Confidence 0..1. */
|
||||||
|
score: number;
|
||||||
|
recordingMbid?: string;
|
||||||
|
releaseGroupMbid?: string;
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
year?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual edits / an accepted match, sent to `PUT /tracks/{id}/metadata`. */
|
||||||
|
export interface MetadataEdit {
|
||||||
|
title?: string;
|
||||||
|
artistName?: string;
|
||||||
|
albumTitle?: string;
|
||||||
|
year?: number;
|
||||||
|
genre?: string;
|
||||||
|
trackNumber?: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import {
|
|||||||
ArrowsClockwise,
|
ArrowsClockwise,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
CloudSlash,
|
||||||
DotsSixVertical,
|
DotsSixVertical,
|
||||||
GearSix,
|
GearSix,
|
||||||
HardDrives,
|
HardDrives,
|
||||||
Heart,
|
Heart,
|
||||||
|
Info,
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
@@ -69,10 +71,12 @@ const ICONS = {
|
|||||||
'skip-forward': SkipForward,
|
'skip-forward': SkipForward,
|
||||||
repeat: Repeat,
|
repeat: Repeat,
|
||||||
heart: Heart,
|
heart: Heart,
|
||||||
|
info: Info,
|
||||||
'thumbs-down': ThumbsDown,
|
'thumbs-down': ThumbsDown,
|
||||||
'speaker-high': SpeakerHigh,
|
'speaker-high': SpeakerHigh,
|
||||||
'speaker-x': SpeakerSimpleX,
|
'speaker-x': SpeakerSimpleX,
|
||||||
cloud: Cloud,
|
cloud: Cloud,
|
||||||
|
'cloud-slash': CloudSlash,
|
||||||
'check-circle': CheckCircle,
|
'check-circle': CheckCircle,
|
||||||
'warning-circle': WarningCircle,
|
'warning-circle': WarningCircle,
|
||||||
'sign-out': SignOut,
|
'sign-out': SignOut,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
|
||||||
|
|
||||||
|
/** Single-line text that ping-pong scrolls (like a news ticker) only when it
|
||||||
|
* overflows its container, otherwise renders as static clipped text. Keeps the
|
||||||
|
* queue panel from ever growing a horizontal scrollbar on long titles. */
|
||||||
|
export function Marquee({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [shift, setShift] = useState(0);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => {
|
||||||
|
const inner = el.firstElementChild as HTMLElement | null;
|
||||||
|
const overflow = (inner?.scrollWidth ?? 0) - el.clientWidth;
|
||||||
|
setShift(overflow > 1 ? overflow : 0);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className={`marquee${shift ? ' on' : ''}${className ? ` ${className}` : ''}`}
|
||||||
|
style={
|
||||||
|
shift ? ({ '--mq-shift': `-${shift}px` } as CSSProperties) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="marquee-inner">{text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* "Hopping bars" equalizer indicator (YTM-style) shown next to the currently
|
||||||
|
* playing track. `animate` controls whether the bars bounce (playback active)
|
||||||
|
* or sit frozen at full height (paused). Reusable across track lists.
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
animate?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayingIndicator({ animate = true, className }: Props) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`playing-bars${animate ? '' : ' paused'}${className ? ` ${className}` : ''}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Suspense } from 'react';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||||
import { QueuePanel } from '../player/QueuePanel';
|
import { QueuePanel } from '../player/QueuePanel';
|
||||||
|
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
||||||
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
@@ -31,6 +32,7 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<QueuePanel />
|
<QueuePanel />
|
||||||
|
<TrackInfoDrawer />
|
||||||
</div>
|
</div>
|
||||||
<PersistentPlayer />
|
<PersistentPlayer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Icon, type IconName } from '../common/Icon';
|
import { Icon, type IconName } from '../common/Icon';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { usePermissions, type Permission } from '../../hooks/usePermissions';
|
import { usePermissions, type Permission } from '../../hooks/usePermissions';
|
||||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
import { useConnectionStatusSync } from '../../hooks/useConnectionStatus';
|
||||||
import { logout } from '../../store/slices/auth';
|
import { logout } from '../../store/slices/auth';
|
||||||
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
||||||
import { getActiveInstance } from '../../config/instances';
|
import { getActiveInstance } from '../../config/instances';
|
||||||
@@ -19,9 +19,24 @@ interface NavDef {
|
|||||||
|
|
||||||
const MAIN_NAV: NavDef[] = [
|
const MAIN_NAV: NavDef[] = [
|
||||||
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
|
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
|
||||||
{ to: '/discover', labelKey: 'nav.search', icon: 'magnifying-glass', perm: 'download' },
|
{
|
||||||
{ to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down', perm: 'download' },
|
to: '/discover',
|
||||||
{ to: '/upload', labelKey: 'nav.upload', icon: 'upload-simple', perm: 'upload' },
|
labelKey: 'nav.search',
|
||||||
|
icon: 'magnifying-glass',
|
||||||
|
perm: 'download',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/downloads',
|
||||||
|
labelKey: 'nav.downloads',
|
||||||
|
icon: 'arrow-circle-down',
|
||||||
|
perm: 'download',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/upload',
|
||||||
|
labelKey: 'nav.upload',
|
||||||
|
icon: 'upload-simple',
|
||||||
|
perm: 'upload',
|
||||||
|
},
|
||||||
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
|
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -41,7 +56,7 @@ export function Sidebar() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isAdmin, hasPermission } = usePermissions();
|
const { user, isAdmin, hasPermission } = usePermissions();
|
||||||
const status = useConnectionStatus();
|
const status = useConnectionStatusSync();
|
||||||
const { data: playlists } = useGetPlaylistsQuery();
|
const { data: playlists } = useGetPlaylistsQuery();
|
||||||
const instance = getActiveInstance();
|
const instance = getActiveInstance();
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ import {
|
|||||||
resume,
|
resume,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
setVolume,
|
setVolume,
|
||||||
toggleShuffle,
|
|
||||||
setRepeat,
|
|
||||||
toggleNowPlaying,
|
|
||||||
toggleQueue,
|
toggleQueue,
|
||||||
} from '../../store/slices/player';
|
} from '../../store/slices/player';
|
||||||
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||||
import { useStreamCached } from '../../hooks/useStreamCached';
|
import { useStreamCached } from '../../hooks/useStreamCached';
|
||||||
|
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
export function PersistentPlayer() {
|
export function PersistentPlayer() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -24,7 +23,11 @@ export function PersistentPlayer() {
|
|||||||
const { seek, playNext, playPrev } = useAudioPlayer();
|
const { seek, playNext, playPrev } = useAudioPlayer();
|
||||||
const player = useAppSelector((s) => s.player);
|
const player = useAppSelector((s) => s.player);
|
||||||
const queue = useAppSelector((s) => s.queue);
|
const queue = useAppSelector((s) => s.queue);
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
const currentEntry = queue.entries[queue.currentIndex];
|
const currentEntry = queue.entries[queue.currentIndex];
|
||||||
|
// Read through to the live Track cache so enrichment updates reach the player,
|
||||||
|
// not just the play-time snapshot frozen in the queue slice.
|
||||||
|
const current = useResolvedQueueEntry(currentEntry);
|
||||||
// Source indicator: cached → playing locally, otherwise streaming.
|
// Source indicator: cached → playing locally, otherwise streaming.
|
||||||
const cached = useStreamCached(currentEntry?.trackId);
|
const cached = useStreamCached(currentEntry?.trackId);
|
||||||
|
|
||||||
@@ -32,41 +35,42 @@ export function PersistentPlayer() {
|
|||||||
return <div className="player empty">{t('player.nothingPlaying')}</div>;
|
return <div className="player empty">{t('player.nothingPlaying')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
|
const artUrl =
|
||||||
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
|
getCoverUrl(currentEntry?.albumArtUrl) ??
|
||||||
|
(token && current?.hasCover
|
||||||
|
? getTrackCoverUrl(current.trackId, token, true)
|
||||||
|
: undefined);
|
||||||
|
const seedLabel = current?.albumTitle ?? current?.title ?? '';
|
||||||
const onStream = !cached;
|
const onStream = !cached;
|
||||||
|
const formatLabel = current?.format?.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player">
|
<div className="player">
|
||||||
<div
|
<div
|
||||||
className="pl-now"
|
className="pl-now"
|
||||||
onClick={() => dispatch(toggleNowPlaying())}
|
onClick={() =>
|
||||||
style={{ cursor: 'pointer' }}
|
currentEntry && dispatch(openTrackInfo(currentEntry.trackId))
|
||||||
|
}
|
||||||
|
style={{ cursor: currentEntry ? 'pointer' : 'default' }}
|
||||||
|
title={currentEntry ? t('trackInfo.open') : undefined}
|
||||||
>
|
>
|
||||||
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
|
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
|
||||||
<div className="pl-now-tt">
|
<div className="pl-now-tt">
|
||||||
<div className="t">{currentEntry?.title ?? '—'}</div>
|
<div className="t">{current?.title ?? '—'}</div>
|
||||||
<div className="a">{currentEntry?.artistName ?? ''}</div>
|
<div className="a">{current?.artistName ?? ''}</div>
|
||||||
<div
|
<div
|
||||||
className="pl-srcbadge"
|
className="pl-srcbadge"
|
||||||
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
|
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
|
||||||
>
|
>
|
||||||
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
|
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
|
||||||
{onStream ? t('player.streaming') : t('player.local')}
|
{onStream ? t('player.streaming') : t('player.local')}
|
||||||
|
{formatLabel && ` · ${formatLabel}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-center">
|
<div className="pl-center">
|
||||||
<div className="pl-transport">
|
<div className="pl-transport">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
|
|
||||||
onClick={() => dispatch(toggleShuffle())}
|
|
||||||
title={t('player.shuffle')}
|
|
||||||
>
|
|
||||||
<Icon name="shuffle" />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pl-tbtn"
|
className="pl-tbtn"
|
||||||
@@ -93,24 +97,6 @@ export function PersistentPlayer() {
|
|||||||
>
|
>
|
||||||
<Icon name="skip-forward" fill />
|
<Icon name="skip-forward" fill />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`pl-tbtn${player.repeat !== 'none' ? ' on' : ''}`}
|
|
||||||
onClick={() =>
|
|
||||||
dispatch(
|
|
||||||
setRepeat(
|
|
||||||
player.repeat === 'none'
|
|
||||||
? 'all'
|
|
||||||
: player.repeat === 'all'
|
|
||||||
? 'one'
|
|
||||||
: 'none',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={t('player.repeat', { mode: player.repeat })}
|
|
||||||
>
|
|
||||||
<Icon name="repeat" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-seek">
|
<div className="pl-seek">
|
||||||
<span className="pl-time">
|
<span className="pl-time">
|
||||||
|
|||||||
@@ -1,29 +1,72 @@
|
|||||||
import { Slider, Badge } from '@olly/modern-sk';
|
import {
|
||||||
|
Slider,
|
||||||
|
Badge,
|
||||||
|
Menu,
|
||||||
|
MenuTrigger,
|
||||||
|
MenuContent,
|
||||||
|
MenuItem,
|
||||||
|
IconButton,
|
||||||
|
} from '@olly/modern-sk';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
KeyboardSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Icon } from '../common/Icon';
|
import { Icon } from '../common/Icon';
|
||||||
import { ArtTile } from '../common/ArtTile';
|
import { ArtTile } from '../common/ArtTile';
|
||||||
|
import { Marquee } from '../common/Marquee';
|
||||||
|
import { PlayingIndicator } from '../common/PlayingIndicator';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
import {
|
import {
|
||||||
goToIndex,
|
goToIndex,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
|
moveInQueue,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
|
toggleShuffle,
|
||||||
|
toggleLoop,
|
||||||
|
type QueueEntry,
|
||||||
} from '../../store/slices/queue';
|
} from '../../store/slices/queue';
|
||||||
import { toggleQueue } from '../../store/slices/player';
|
import { toggleQueue } from '../../store/slices/player';
|
||||||
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
|
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||||
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
export function QueuePanel() {
|
export function QueuePanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const queue = useAppSelector((s) => s.queue);
|
const queue = useAppSelector((s) => s.queue);
|
||||||
|
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
||||||
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
||||||
|
|
||||||
const now =
|
const hasEntries = queue.entries.length > 0;
|
||||||
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
|
|
||||||
const upNext = queue.entries
|
|
||||||
.map((entry, index) => ({ entry, index }))
|
|
||||||
.filter(({ index }) => index > queue.currentIndex);
|
|
||||||
const isRadio = queue.source === 'radio';
|
const isRadio = queue.source === 'radio';
|
||||||
const sourceLabel = queue.sourceName ?? queue.source;
|
const sourceLabel = queue.sourceName ?? queue.source;
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
dispatch(moveInQueue({ from: Number(active.id), to: Number(over.id) }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
||||||
<div className="qd-inner">
|
<div className="qd-inner">
|
||||||
@@ -31,6 +74,22 @@ export function QueuePanel() {
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<h3>{t('queue.title')}</h3>
|
<h3>{t('queue.title')}</h3>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`iconbtn sm${queue.shuffle ? ' on' : ''}`}
|
||||||
|
onClick={() => dispatch(toggleShuffle())}
|
||||||
|
title={t('queue.shuffle')}
|
||||||
|
>
|
||||||
|
<Icon name="shuffle" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`iconbtn sm${queue.loop ? ' on' : ''}`}
|
||||||
|
onClick={() => dispatch(toggleLoop())}
|
||||||
|
title={t('queue.loop')}
|
||||||
|
>
|
||||||
|
<Icon name="repeat" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="iconbtn sm"
|
className="iconbtn sm"
|
||||||
@@ -64,32 +123,19 @@ export function QueuePanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="qd-scroll">
|
<div className="qd-scroll">
|
||||||
{now ? (
|
{hasEntries ? (
|
||||||
<>
|
<>
|
||||||
<span
|
|
||||||
className="msk-label"
|
|
||||||
style={{ display: 'block', marginBottom: 8 }}
|
|
||||||
>
|
|
||||||
{t('queue.nowPlaying')}
|
|
||||||
</span>
|
|
||||||
<div className="qd-now">
|
|
||||||
<ArtTile
|
|
||||||
seed={now.albumTitle}
|
|
||||||
size={44}
|
|
||||||
label={now.albumTitle}
|
|
||||||
/>
|
|
||||||
<div className="qt">
|
|
||||||
<div className="t">{now.title}</div>
|
|
||||||
<div className="r">{now.artistName}</div>
|
|
||||||
</div>
|
|
||||||
<Icon name="cloud" style={{ color: 'var(--fg-3)' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isRadio && (
|
{isRadio && (
|
||||||
<div className="qd-radio">
|
<div className="qd-radio">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<Icon name="radio" />
|
<Icon name="radio" />
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg-1)' }}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--fg-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('queue.radioActive')}
|
{t('queue.radioActive')}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
@@ -116,39 +162,36 @@ export function QueuePanel() {
|
|||||||
>
|
>
|
||||||
{t('queue.nextUp')}
|
{t('queue.nextUp')}
|
||||||
</span>
|
</span>
|
||||||
{upNext.length === 0 ? (
|
<DndContext
|
||||||
<div className="qd-empty">{t('queue.nothingNext')}</div>
|
sensors={sensors}
|
||||||
) : (
|
collisionDetection={closestCenter}
|
||||||
upNext.map(({ entry, index }) => (
|
onDragEnd={handleDragEnd}
|
||||||
<div
|
>
|
||||||
key={`${entry.trackId}-${index}`}
|
<SortableContext
|
||||||
className="qrow"
|
items={queue.entries.map((_, index) => String(index))}
|
||||||
onDoubleClick={() => dispatch(goToIndex(index))}
|
strategy={verticalListSortingStrategy}
|
||||||
title={t('queue.doubleClickPlay')}
|
>
|
||||||
>
|
{queue.entries.map((entry, index) => (
|
||||||
<span className="grip">
|
<QueueRow
|
||||||
<Icon name="dots-six-vertical" />
|
key={`${entry.trackId}-${index}`}
|
||||||
</span>
|
id={String(index)}
|
||||||
<ArtTile
|
entry={entry}
|
||||||
seed={entry.albumTitle}
|
isCurrent={index === queue.currentIndex}
|
||||||
size={36}
|
isPlaying={isPlaying}
|
||||||
label={entry.albumTitle}
|
onPlay={() => dispatch(goToIndex(index))}
|
||||||
|
onMoveNext={() =>
|
||||||
|
dispatch(
|
||||||
|
moveInQueue({
|
||||||
|
from: index,
|
||||||
|
to: queue.currentIndex + 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRemove={() => dispatch(removeFromQueue(index))}
|
||||||
/>
|
/>
|
||||||
<div className="qt">
|
))}
|
||||||
<div className="t">{entry.title}</div>
|
</SortableContext>
|
||||||
<div className="r">{entry.artistName}</div>
|
</DndContext>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="iconbtn sm"
|
|
||||||
onClick={() => dispatch(removeFromQueue(index))}
|
|
||||||
title={t('queue.removeFromQueue')}
|
|
||||||
>
|
|
||||||
<Icon name="x" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRadio && (
|
{isRadio && (
|
||||||
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
|
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
|
||||||
@@ -162,3 +205,102 @@ export function QueuePanel() {
|
|||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A queue row, resolving its display fields against the live Track cache so
|
||||||
|
* enrichment updates show. The currently-playing entry is outlined and shows
|
||||||
|
* a playing-bars indicator in place of the drag grip. */
|
||||||
|
function QueueRow({
|
||||||
|
id,
|
||||||
|
entry,
|
||||||
|
isCurrent,
|
||||||
|
isPlaying,
|
||||||
|
onPlay,
|
||||||
|
onMoveNext,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
entry: QueueEntry;
|
||||||
|
isCurrent: boolean;
|
||||||
|
isPlaying: boolean;
|
||||||
|
onPlay: () => void;
|
||||||
|
onMoveNext: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
const resolved = useResolvedQueueEntry(entry);
|
||||||
|
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
|
||||||
|
const artUrl =
|
||||||
|
getCoverUrl(resolved?.albumArtUrl) ??
|
||||||
|
(token && resolved?.hasCover
|
||||||
|
? getTrackCoverUrl(resolved.trackId, token, true)
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`qrow${isCurrent ? ' current' : ''}${isDragging ? ' dragging' : ''}`}
|
||||||
|
onDoubleClick={onPlay}
|
||||||
|
title={t('queue.doubleClickPlay')}
|
||||||
|
>
|
||||||
|
<span className="grip" {...attributes} {...listeners}>
|
||||||
|
<Icon name="dots-six-vertical" />
|
||||||
|
</span>
|
||||||
|
<div className="qart">
|
||||||
|
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
|
||||||
|
{isCurrent && (
|
||||||
|
<div className="cover-playing">
|
||||||
|
<PlayingIndicator animate={isPlaying} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="qt">
|
||||||
|
<Marquee className="t" text={resolved?.title ?? entry.title} />
|
||||||
|
<Marquee
|
||||||
|
className="r"
|
||||||
|
text={resolved?.artistName ?? entry.artistName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Menu>
|
||||||
|
<MenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t('queue.menu.options')}
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</IconButton>
|
||||||
|
</MenuTrigger>
|
||||||
|
<MenuContent>
|
||||||
|
<MenuItem onSelect={onPlay}>{t('queue.menu.playNow')}</MenuItem>
|
||||||
|
{!isCurrent && (
|
||||||
|
<MenuItem onSelect={onMoveNext}>
|
||||||
|
{t('queue.menu.moveNext')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem onSelect={() => dispatch(openTrackInfo(entry.trackId))}>
|
||||||
|
{t('queue.menu.info')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onSelect={onRemove}>{t('queue.menu.remove')}</MenuItem>
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,38 +1,88 @@
|
|||||||
import { Badge, Tooltip } from '@olly/modern-sk';
|
import { Badge, Tooltip } from '@olly/modern-sk';
|
||||||
|
import { Icon, type IconName } from '../common/Icon';
|
||||||
import type { TrackAvailability } from '../../api/types';
|
import type { TrackAvailability } from '../../api/types';
|
||||||
|
|
||||||
|
/** `TrackAvailability` plus a client-derived state: the backend reports
|
||||||
|
* `server`, but if it's unreachable and the track's audio is already in the
|
||||||
|
* offline cache, we know better — show `local` instead. */
|
||||||
|
export type DisplayAvailability = TrackAvailability | 'local';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
availability: TrackAvailability;
|
availability: DisplayAvailability;
|
||||||
|
/** Render as a small icon + tooltip instead of a labelled badge — used in
|
||||||
|
* dense track lists (library, album, playlist). */
|
||||||
|
iconOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COLOR_VAR: Record<Variant, string> = {
|
||||||
|
lime: 'var(--lime)',
|
||||||
|
ember: 'var(--ember)',
|
||||||
|
neutral: 'var(--fg-3)',
|
||||||
|
outline: 'var(--fg-3)',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
|
||||||
|
|
||||||
const CONFIG: Record<
|
const CONFIG: Record<
|
||||||
TrackAvailability,
|
DisplayAvailability,
|
||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
variant: 'lime' | 'ember' | 'neutral' | 'outline';
|
variant: Variant;
|
||||||
|
icon: IconName;
|
||||||
|
spin?: boolean;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
server: {
|
server: {
|
||||||
label: 'On server',
|
label: 'On server',
|
||||||
variant: 'lime',
|
variant: 'lime',
|
||||||
|
icon: 'cloud',
|
||||||
tooltip: 'File available on server',
|
tooltip: 'File available on server',
|
||||||
},
|
},
|
||||||
|
local: {
|
||||||
|
label: 'Local',
|
||||||
|
variant: 'lime',
|
||||||
|
icon: 'hard-drives',
|
||||||
|
tooltip: 'Cached on this device — playable offline',
|
||||||
|
},
|
||||||
downloading: {
|
downloading: {
|
||||||
label: 'Downloading',
|
label: 'Downloading',
|
||||||
variant: 'neutral',
|
variant: 'neutral',
|
||||||
|
icon: 'arrows-clockwise',
|
||||||
|
spin: true,
|
||||||
tooltip: 'Currently downloading',
|
tooltip: 'Currently downloading',
|
||||||
},
|
},
|
||||||
error: { label: 'Error', variant: 'ember', tooltip: 'Download failed' },
|
error: {
|
||||||
|
label: 'Error',
|
||||||
|
variant: 'ember',
|
||||||
|
icon: 'warning-circle',
|
||||||
|
tooltip: 'Download failed',
|
||||||
|
},
|
||||||
missing: {
|
missing: {
|
||||||
label: 'Missing',
|
label: 'Missing',
|
||||||
variant: 'outline',
|
variant: 'outline',
|
||||||
|
icon: 'cloud-slash',
|
||||||
tooltip: 'File not found on server',
|
tooltip: 'File not found on server',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AvailabilityBadge({ availability }: Props) {
|
export function AvailabilityBadge({ availability, iconOnly }: Props) {
|
||||||
const cfg = CONFIG[availability];
|
const cfg = CONFIG[availability];
|
||||||
|
|
||||||
|
if (iconOnly) {
|
||||||
|
return (
|
||||||
|
<Tooltip content={cfg.tooltip}>
|
||||||
|
<span style={{ display: 'inline-flex' }}>
|
||||||
|
<Icon
|
||||||
|
name={cfg.icon}
|
||||||
|
className={cfg.spin ? 'spin' : undefined}
|
||||||
|
style={{ color: COLOR_VAR[cfg.variant], fontSize: 15 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={cfg.tooltip}>
|
<Tooltip content={cfg.tooltip}>
|
||||||
<Badge variant={cfg.variant} dot>
|
<Badge variant={cfg.variant} dot>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Icon, type IconName } from '../common/Icon';
|
||||||
|
import type { MetadataStatus } from '../../api/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: MetadataStatus;
|
||||||
|
/** Reason shown in the tooltip for a `failed` status. */
|
||||||
|
error?: string;
|
||||||
|
/** When true, render nothing for the normal `enriched` state (keeps dense
|
||||||
|
* track lists quiet; the upload screen sets this false to confirm success). */
|
||||||
|
hideWhenEnriched?: boolean;
|
||||||
|
/** Render as a small icon + tooltip instead of a labelled badge — used in
|
||||||
|
* dense track lists (library, album, playlist). */
|
||||||
|
iconOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
|
||||||
|
|
||||||
|
const VARIANT: Record<MetadataStatus, Variant> = {
|
||||||
|
pending: 'neutral',
|
||||||
|
enriched: 'lime',
|
||||||
|
failed: 'ember',
|
||||||
|
manual: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_VAR: Record<Variant, string> = {
|
||||||
|
lime: 'var(--lime)',
|
||||||
|
ember: 'var(--ember)',
|
||||||
|
neutral: 'var(--fg-3)',
|
||||||
|
outline: 'var(--fg-3)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON: Record<Exclude<MetadataStatus, 'pending'>, IconName> = {
|
||||||
|
enriched: 'check-circle',
|
||||||
|
failed: 'warning-circle',
|
||||||
|
manual: 'push-pin',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a track's metadata-enrichment state (distinct from file availability).
|
||||||
|
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
|
||||||
|
*/
|
||||||
|
export function MetadataStatusBadge({
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
hideWhenEnriched = true,
|
||||||
|
iconOnly,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (status === 'enriched' && hideWhenEnriched) return null;
|
||||||
|
|
||||||
|
const label = t(`metadata.status.${status}`);
|
||||||
|
const tooltip =
|
||||||
|
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`);
|
||||||
|
|
||||||
|
if (iconOnly) {
|
||||||
|
return (
|
||||||
|
<Tooltip content={tooltip}>
|
||||||
|
<span style={{ display: 'inline-flex' }}>
|
||||||
|
{status === 'pending' ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<Icon
|
||||||
|
name={ICON[status]}
|
||||||
|
style={{ color: COLOR_VAR[VARIANT[status]], fontSize: 15 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={tooltip}>
|
||||||
|
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
|
||||||
|
{status === 'pending' ? <Spinner size="sm" /> : null}
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
||||||
import { play } from '../../store/slices/player';
|
import { play } from '../../store/slices/player';
|
||||||
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
import type { Track } from '../../api/types';
|
import type { Track } from '../../api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -42,21 +43,45 @@ export function TrackContextMenu({
|
|||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuTrigger asChild>
|
<MenuTrigger asChild>
|
||||||
<IconButton variant="ghost" size="sm" aria-label={t('track.menu.options')}>
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t('track.menu.options')}
|
||||||
|
>
|
||||||
⋯
|
⋯
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</MenuTrigger>
|
</MenuTrigger>
|
||||||
<MenuContent>
|
<MenuContent>
|
||||||
<MenuItem onSelect={() => { dispatch(play(track.id)); }}>
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(play(track.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('track.menu.playNow')}
|
{t('track.menu.playNow')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onSelect={() => { dispatch(addNextInQueue(entry)); }}>
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(addNextInQueue(entry));
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('track.menu.playNext')}
|
{t('track.menu.playNext')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onSelect={() => { dispatch(addToQueue(entry)); }}>
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(addToQueue(entry));
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('track.menu.addToQueue')}
|
{t('track.menu.addToQueue')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(openTrackInfo(track.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('track.menu.info')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
{onAddToPlaylist && (
|
{onAddToPlaylist && (
|
||||||
<MenuItem onSelect={() => onAddToPlaylist(track)}>
|
<MenuItem onSelect={() => onAddToPlaylist(track)}>
|
||||||
{t('track.menu.addToPlaylist')}
|
{t('track.menu.addToPlaylist')}
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Badge, Button } from '@olly/modern-sk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
import { skipToken } from '@reduxjs/toolkit/query';
|
||||||
|
import { Icon } from '../common/Icon';
|
||||||
|
import { ArtTile } from '../common/ArtTile';
|
||||||
|
import { AvailabilityBadge } from './AvailabilityBadge';
|
||||||
|
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
||||||
|
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||||
|
import { ErrorState } from '../common/ErrorState';
|
||||||
|
import { EmptyState } from '../common/EmptyState';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { closeTrackInfo } from '../../store/slices/ui';
|
||||||
|
import { play } from '../../store/slices/player';
|
||||||
|
import { addToQueue } from '../../store/slices/queue';
|
||||||
|
import {
|
||||||
|
useGetTrackQuery,
|
||||||
|
useGetAlbumQuery,
|
||||||
|
} from '../../api/endpoints/library';
|
||||||
|
import { getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
import {
|
||||||
|
formatDuration,
|
||||||
|
formatFileSize,
|
||||||
|
formatDateTime,
|
||||||
|
} from '../../lib/format';
|
||||||
|
import type { Track } from '../../api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-side "Get Info"-style drawer for a single track. Rendered after the
|
||||||
|
* QueuePanel in AppShell so it sits to the *right* of the queue when both are
|
||||||
|
* open. Open state lives in `ui.trackInfoId`; it reads the live Track (and its
|
||||||
|
* album) from the RTKQ cache so enrichment updates stay in sync.
|
||||||
|
*/
|
||||||
|
export function TrackInfoDrawer() {
|
||||||
|
const trackId = useAppSelector((s) => s.ui.trackInfoId);
|
||||||
|
const isOpen = trackId !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={`tid${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
||||||
|
<div className="tid-inner">
|
||||||
|
{trackId ? <TrackInfoContent trackId={trackId} /> : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackInfoContent({ trackId }: { trackId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: track,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useGetTrackQuery(trackId);
|
||||||
|
// Album record fills in fields the lean TrackOut omits (year especially).
|
||||||
|
const { data: album } = useGetAlbumQuery(track?.albumId ?? skipToken);
|
||||||
|
|
||||||
|
const close = () => dispatch(closeTrackInfo());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="tid-head">
|
||||||
|
<h3>{t('trackInfo.title')}</h3>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="iconbtn sm"
|
||||||
|
onClick={close}
|
||||||
|
title={t('trackInfo.close')}
|
||||||
|
>
|
||||||
|
<Icon name="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tid-scroll">
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSkeleton rows={6} />
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState onRetry={refetch} />
|
||||||
|
) : !track ? (
|
||||||
|
<EmptyState title={t('trackInfo.notFound')} />
|
||||||
|
) : (
|
||||||
|
<TrackInfoBody
|
||||||
|
track={track}
|
||||||
|
albumYear={album?.year}
|
||||||
|
albumTrackCount={album?.trackCount}
|
||||||
|
coverUrl={
|
||||||
|
token
|
||||||
|
? getTrackCoverUrl(track.id, token, track.hasCover)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onPlay={() => dispatch(play(track.id))}
|
||||||
|
onQueue={() =>
|
||||||
|
dispatch(
|
||||||
|
addToQueue({
|
||||||
|
trackId: track.id,
|
||||||
|
title: track.title,
|
||||||
|
artistName: track.artistName,
|
||||||
|
albumTitle: track.albumTitle,
|
||||||
|
durationMs: track.durationMs,
|
||||||
|
albumArtUrl: track.albumArtUrl,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onEdit={() => {
|
||||||
|
navigate(`/tracks/${track.id}/metadata`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackInfoBody({
|
||||||
|
track,
|
||||||
|
albumYear,
|
||||||
|
albumTrackCount,
|
||||||
|
coverUrl,
|
||||||
|
onPlay,
|
||||||
|
onQueue,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
track: Track;
|
||||||
|
albumYear?: number;
|
||||||
|
albumTrackCount?: number;
|
||||||
|
coverUrl?: string;
|
||||||
|
onPlay: () => void;
|
||||||
|
onQueue: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const seedLabel = track.albumTitle || track.title;
|
||||||
|
const year = track.year ?? albumYear;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="tid-cover">
|
||||||
|
{coverUrl ? (
|
||||||
|
<img src={coverUrl} alt={track.albumTitle} />
|
||||||
|
) : (
|
||||||
|
<ArtTile seed={seedLabel} size={256} label={seedLabel} radius={12} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="tid-title">{track.title}</h2>
|
||||||
|
<Link className="tid-sub" to={`/artists/${track.artistId}`}>
|
||||||
|
{track.artistName}
|
||||||
|
</Link>
|
||||||
|
{track.albumId && (
|
||||||
|
<Link className="tid-sub tid-album" to={`/albums/${track.albumId}`}>
|
||||||
|
<Icon name="vinyl-record" />
|
||||||
|
{track.albumTitle}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="tid-actions">
|
||||||
|
<Button variant="primary" size="sm" onClick={onPlay}>
|
||||||
|
<Icon name="play" fill /> {t('trackInfo.play')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onQueue}>
|
||||||
|
<Icon name="queue" /> {t('trackInfo.addToQueue')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||||
|
{t('trackInfo.editMetadata')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InfoSection title={t('trackInfo.sections.status')}>
|
||||||
|
<div className="tid-status">
|
||||||
|
<AvailabilityBadge availability={track.availability} />
|
||||||
|
<MetadataStatusBadge
|
||||||
|
status={track.metadataStatus}
|
||||||
|
error={track.metadataError}
|
||||||
|
hideWhenEnriched={false}
|
||||||
|
/>
|
||||||
|
{track.liked && (
|
||||||
|
<Badge variant="lime" dot>
|
||||||
|
{t('trackInfo.liked')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{track.metadataStatus === 'failed' && track.metadataError && (
|
||||||
|
<p className="tid-error">{track.metadataError}</p>
|
||||||
|
)}
|
||||||
|
</InfoSection>
|
||||||
|
|
||||||
|
<InfoSection title={t('trackInfo.sections.general')}>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.artist')}
|
||||||
|
value={track.artistName}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.album')}
|
||||||
|
value={track.albumTitle || undefined}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.trackNumber')}
|
||||||
|
value={
|
||||||
|
track.trackNumber !== undefined
|
||||||
|
? albumTrackCount
|
||||||
|
? t('trackInfo.trackOf', {
|
||||||
|
n: track.trackNumber,
|
||||||
|
total: albumTrackCount,
|
||||||
|
})
|
||||||
|
: String(track.trackNumber)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.disc')}
|
||||||
|
value={
|
||||||
|
track.discNumber !== undefined
|
||||||
|
? String(track.discNumber)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.year')}
|
||||||
|
value={year !== undefined ? String(year) : undefined}
|
||||||
|
/>
|
||||||
|
<InfoRow label={t('trackInfo.fields.genre')} value={track.genre} />
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.duration')}
|
||||||
|
value={formatDuration(track.durationMs)}
|
||||||
|
/>
|
||||||
|
</InfoSection>
|
||||||
|
|
||||||
|
<InfoSection title={t('trackInfo.sections.file')}>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.format')}
|
||||||
|
value={track.format?.toUpperCase()}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.bitrate')}
|
||||||
|
value={
|
||||||
|
track.bitrate !== undefined
|
||||||
|
? t('trackInfo.kbps', { n: track.bitrate })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.size')}
|
||||||
|
value={
|
||||||
|
track.fileSize !== undefined
|
||||||
|
? formatFileSize(track.fileSize)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.source')}
|
||||||
|
value={track.source}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.added')}
|
||||||
|
value={formatDateTime(track.createdAt)}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.enriched')}
|
||||||
|
value={formatDateTime(track.enrichedAt)}
|
||||||
|
/>
|
||||||
|
</InfoSection>
|
||||||
|
|
||||||
|
<InfoSection title={t('trackInfo.sections.identifiers')}>
|
||||||
|
<InfoRow label={t('trackInfo.fields.trackId')} value={track.id} mono />
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.albumId')}
|
||||||
|
value={track.albumId || undefined}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('trackInfo.fields.artistId')}
|
||||||
|
value={track.artistId}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</InfoSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoSection({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="tid-section">
|
||||||
|
<span className="msk-label tid-section-label">{title}</span>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A label/value row; renders nothing when the value is empty (Finder-style). */
|
||||||
|
function InfoRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div className="tid-row">
|
||||||
|
<span className="tid-row-k">{label}</span>
|
||||||
|
<span className={`tid-row-v${mono ? ' mono' : ''}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
import { Row } from '@olly/modern-sk';
|
import { Row } from '@olly/modern-sk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TrackContextMenu } from './TrackContextMenu';
|
import { TrackContextMenu } from './TrackContextMenu';
|
||||||
import { AvailabilityBadge } from './AvailabilityBadge';
|
import { AvailabilityBadge } from './AvailabilityBadge';
|
||||||
|
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
||||||
|
import { Icon } from '../common/Icon';
|
||||||
|
import { PlayingIndicator } from '../common/PlayingIndicator';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
import { play } from '../../store/slices/player';
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import { useStreamCached } from '../../hooks/useStreamCached';
|
||||||
|
import { playNow } from '../../store/slices/queue';
|
||||||
import type { Track } from '../../api/types';
|
import type { Track } from '../../api/types';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
track: Track;
|
track: Track;
|
||||||
index?: number;
|
index?: number;
|
||||||
showAlbum?: boolean;
|
showAlbum?: boolean;
|
||||||
|
/** Hide cover art and show the track's album position instead — used on
|
||||||
|
* the album detail page, where the album cover is already shown once in
|
||||||
|
* the header and per-track art would be redundant. */
|
||||||
|
hideArt?: boolean;
|
||||||
onAddToPlaylist?: (track: Track) => void;
|
onAddToPlaylist?: (track: Track) => void;
|
||||||
onEditMetadata?: (track: Track) => void;
|
onEditMetadata?: (track: Track) => void;
|
||||||
onDelete?: (track: Track) => void;
|
onDelete?: (track: Track) => void;
|
||||||
@@ -20,56 +30,119 @@ export function TrackRow({
|
|||||||
track,
|
track,
|
||||||
index,
|
index,
|
||||||
showAlbum = false,
|
showAlbum = false,
|
||||||
|
hideArt = false,
|
||||||
onAddToPlaylist,
|
onAddToPlaylist,
|
||||||
onEditMetadata,
|
onEditMetadata,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
||||||
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
const isActive = currentTrackId === track.id;
|
const isActive = currentTrackId === track.id;
|
||||||
const artUrl = getCoverUrl(track.albumArtUrl);
|
// Prefer an explicit album art URL; otherwise serve the track's own cover
|
||||||
|
// (needs the token in the query string — `<img>` can't send a header).
|
||||||
|
const artUrl =
|
||||||
|
getCoverUrl(track.albumArtUrl) ??
|
||||||
|
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
|
||||||
|
|
||||||
|
// The backend reports `server`, but if it's unreachable and this track's
|
||||||
|
// audio is already in the offline cache, show "Local" instead.
|
||||||
|
const offline = useIsOffline();
|
||||||
|
const cached = useStreamCached(offline ? track.id : undefined);
|
||||||
|
const displayAvailability =
|
||||||
|
track.availability === 'server' && offline && cached
|
||||||
|
? 'local'
|
||||||
|
: track.availability;
|
||||||
|
|
||||||
|
const handlePlayNow = () => {
|
||||||
|
dispatch(
|
||||||
|
playNow({
|
||||||
|
trackId: track.id,
|
||||||
|
title: track.title,
|
||||||
|
artistName: track.artistName,
|
||||||
|
albumTitle: track.albumTitle,
|
||||||
|
durationMs: track.durationMs,
|
||||||
|
albumArtUrl: track.albumArtUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<Row
|
||||||
selected={isActive}
|
selected={isActive}
|
||||||
onDoubleClick={() => dispatch(play(track.id))}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '2rem 2.5rem 1fr auto auto',
|
gridTemplateColumns: hideArt
|
||||||
|
? '2.5rem 1fr auto auto'
|
||||||
|
: '2rem 2.5rem 1fr auto auto',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '0.375rem 0.75rem',
|
padding: '0.375rem 0.75rem',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{!hideArt && (
|
||||||
style={{
|
<span
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'var(--color-text-3)',
|
|
||||||
textAlign: 'right',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
|
|
||||||
</span>
|
|
||||||
{artUrl ? (
|
|
||||||
<img
|
|
||||||
src={artUrl}
|
|
||||||
alt=""
|
|
||||||
width={36}
|
|
||||||
height={36}
|
|
||||||
style={{ borderRadius: 4, objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
width: 36,
|
fontSize: '0.75rem',
|
||||||
height: 36,
|
color: 'var(--color-text-3)',
|
||||||
borderRadius: 4,
|
textAlign: 'right',
|
||||||
background: 'var(--color-surface-3)',
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<div className="track-art">
|
||||||
|
{hideArt ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '1.0625rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isActive ? 'var(--color-accent)' : 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{track.trackNumber ?? (index !== undefined ? index + 1 : '')}
|
||||||
|
</div>
|
||||||
|
) : artUrl ? (
|
||||||
|
<img
|
||||||
|
src={artUrl}
|
||||||
|
alt=""
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
style={{ borderRadius: 4, objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'var(--color-surface-3)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isActive && (
|
||||||
|
<div className="cover-playing">
|
||||||
|
<PlayingIndicator animate={isPlaying} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="track-art-play"
|
||||||
|
onClick={handlePlayNow}
|
||||||
|
aria-label={t('track.menu.playNow')}
|
||||||
|
title={t('track.menu.playNow')}
|
||||||
|
>
|
||||||
|
<Icon name="play" fill />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -95,7 +168,14 @@ export function TrackRow({
|
|||||||
{showAlbum && ` · ${track.albumTitle}`}
|
{showAlbum && ` · ${track.albumTitle}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AvailabilityBadge availability={track.availability} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<MetadataStatusBadge
|
||||||
|
status={track.metadataStatus}
|
||||||
|
error={track.metadataError}
|
||||||
|
iconOnly
|
||||||
|
/>
|
||||||
|
<AvailabilityBadge availability={displayAvailability} iconOnly />
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -20,3 +20,22 @@ function runtimeApiBaseUrl(): string | undefined {
|
|||||||
|
|
||||||
export const DEFAULT_API_BASE_URL =
|
export const DEFAULT_API_BASE_URL =
|
||||||
runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
|
runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the public sign-up UI is shown. Same precedence as the base URL:
|
||||||
|
* runtime operator config (injected into `window.__APP_CONFIG__` at container
|
||||||
|
* start) wins over the build-time `PUBLIC_ENABLE_REGISTRATION` env, which
|
||||||
|
* defaults to enabled. This only gates the *UI*; the backend independently
|
||||||
|
* enforces `ALLOW_REGISTRATION` and is the real authority.
|
||||||
|
*/
|
||||||
|
function parseFlag(value: string | undefined): boolean | undefined {
|
||||||
|
if (value == null || value === '') return undefined;
|
||||||
|
return value !== 'false' && value !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REGISTRATION_ENABLED: boolean =
|
||||||
|
(typeof window !== 'undefined'
|
||||||
|
? window.__APP_CONFIG__?.enableRegistration
|
||||||
|
: undefined) ??
|
||||||
|
parseFlag(import.meta.env.PUBLIC_ENABLE_REGISTRATION) ??
|
||||||
|
true;
|
||||||
|
|||||||
+11
-1
@@ -29,8 +29,13 @@ const ACTIVE_KEY = 'mcma:activeInstance';
|
|||||||
const LEGACY_URL_KEY = 'mcma_api_base_url';
|
const LEGACY_URL_KEY = 'mcma_api_base_url';
|
||||||
const LEGACY_AUTH_KEY = 'mcma_auth';
|
const LEGACY_AUTH_KEY = 'mcma_auth';
|
||||||
|
|
||||||
|
// The UI always talks to the `/api/v1` contract, so users only enter the
|
||||||
|
// origin (and optional reverse-proxy prefix). We append the contract path
|
||||||
|
// here, the single choke point for both the base URL and the instance id, so
|
||||||
|
// `domain.com`, `domain.com/`, and `domain.com/api/v1` all converge.
|
||||||
function normalizeUrl(url: string): string {
|
function normalizeUrl(url: string): string {
|
||||||
return url.trim().replace(/\/+$/, '');
|
const trimmed = url.trim().replace(/\/+$/, '');
|
||||||
|
return /\/api\/v1$/.test(trimmed) ? trimmed : `${trimmed}/api/v1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stable, readable id from a base URL — also serves as the storage namespace. */
|
/** Stable, readable id from a base URL — also serves as the storage namespace. */
|
||||||
@@ -93,6 +98,11 @@ export function upsertInstance(url: string, name?: string): Instance {
|
|||||||
return inst;
|
return inst;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clear a backend's stored session without forgetting the instance itself. */
|
||||||
|
export function clearInstanceAuth(id: string): void {
|
||||||
|
localStorage.removeItem(scopedKey('auth', id));
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove a backend and wipe every scoped key it owns. */
|
/** Remove a backend and wipe every scoped key it owns. */
|
||||||
export function removeInstance(id: string): void {
|
export function removeInstance(id: string): void {
|
||||||
writeRegistry(readRegistry().filter((i) => i.id !== id));
|
writeRegistry(readRegistry().filter((i) => i.id !== id));
|
||||||
|
|||||||
Vendored
+2
@@ -1,6 +1,7 @@
|
|||||||
/// <reference types="@rsbuild/core/types" />
|
/// <reference types="@rsbuild/core/types" />
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly PUBLIC_API_BASE_URL?: string;
|
readonly PUBLIC_API_BASE_URL?: string;
|
||||||
|
readonly PUBLIC_ENABLE_REGISTRATION?: string;
|
||||||
}
|
}
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
@@ -11,5 +12,6 @@ interface ImportMeta {
|
|||||||
interface Window {
|
interface Window {
|
||||||
__APP_CONFIG__?: {
|
__APP_CONFIG__?: {
|
||||||
apiBaseUrl?: string;
|
apiBaseUrl?: string;
|
||||||
|
enableRegistration?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
|
import { ScrollArea, IconButton, Button, Callout } from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetAlbumQuery,
|
useGetAlbumQuery,
|
||||||
useGetAlbumTracksQuery,
|
useGetAlbumTracksQuery,
|
||||||
@@ -9,29 +9,56 @@ import { TrackRow } from '../../components/track/TrackRow';
|
|||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalTracks,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
import { setQueue } from '../../store/slices/queue';
|
import { setQueue } from '../../store/slices/queue';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
export function AlbumDetailPage() {
|
export function AlbumDetailPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { albumId } = useParams<{ albumId: string }>();
|
const { albumId } = useParams<{ albumId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
|
||||||
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
|
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
|
||||||
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
||||||
|
|
||||||
if (albumQuery.isLoading || tracksQuery.isLoading) {
|
// Offline fallback: resolve the album + its tracks from the locally-cached
|
||||||
return (
|
// library when the backend is unreachable (same approach as LibraryPage).
|
||||||
<div style={{ padding: '1.5rem' }}>
|
const offline = useIsOffline();
|
||||||
<LoadingSkeleton rows={10} />
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
</div>
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (albumQuery.isError) {
|
const album =
|
||||||
|
albumQuery.data ??
|
||||||
|
(offline ? localAlbums.find((a) => a.id === albumId) : undefined);
|
||||||
|
const tracks =
|
||||||
|
tracksQuery.data ??
|
||||||
|
(offline ? localTracks.filter((tr) => tr.albumId === albumId) : []);
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
if (albumQuery.isLoading && !offline) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<LoadingSkeleton rows={10} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (offline) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="💿"
|
||||||
|
title={t('album.offline.title')}
|
||||||
|
description={t('album.offline.description')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={t('album.error')}
|
message={t('album.error')}
|
||||||
@@ -39,10 +66,13 @@ export function AlbumDetailPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The album record itself carries no cover; fall back to a track's cover.
|
||||||
const album = albumQuery.data;
|
const coverTrack = tracks.find((t) => t.hasCover);
|
||||||
const tracks = tracksQuery.data ?? [];
|
const artUrl =
|
||||||
const artUrl = getCoverUrl(album?.artUrl);
|
getCoverUrl(album?.artUrl) ??
|
||||||
|
(token && coverTrack
|
||||||
|
? getTrackCoverUrl(coverTrack.id, token, true)
|
||||||
|
: undefined);
|
||||||
|
|
||||||
const handlePlayAll = () => {
|
const handlePlayAll = () => {
|
||||||
if (!tracks.length || !album) return;
|
if (!tracks.length || !album) return;
|
||||||
@@ -65,6 +95,11 @@ export function AlbumDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
@@ -161,16 +196,17 @@ export function AlbumDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea style={{ flex: 1 }}>
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
{tracks.length === 0 && !offline && tracksQuery.isLoading && (
|
||||||
{tracksQuery.isError && (
|
<LoadingSkeleton rows={10} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isError && (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={t('album.tracksError')}
|
message={t('album.tracksError')}
|
||||||
onRetry={() => tracksQuery.refetch()}
|
onRetry={() => tracksQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!tracksQuery.isLoading &&
|
{tracks.length === 0 &&
|
||||||
!tracksQuery.isError &&
|
(offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
|
||||||
tracks.length === 0 && (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title={t('album.empty.title')}
|
title={t('album.empty.title')}
|
||||||
@@ -178,7 +214,7 @@ export function AlbumDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracks.map((track, i) => (
|
{tracks.map((track, i) => (
|
||||||
<TrackRow key={track.id} track={track} index={i} />
|
<TrackRow key={track.id} track={track} index={i} hideArt />
|
||||||
))}
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,307 @@
|
|||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Placeholder } from '../../components/common/Placeholder';
|
import { ScrollArea, IconButton, Button, Card, Callout } from '@olly/modern-sk';
|
||||||
|
import {
|
||||||
|
useGetArtistQuery,
|
||||||
|
useGetArtistAlbumsQuery,
|
||||||
|
useGetArtistTracksQuery,
|
||||||
|
} from '../../api/endpoints/library';
|
||||||
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
import { ArtTile } from '../../components/common/ArtTile';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
selectLocalArtists,
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalTracks,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
|
import { setQueue } from '../../store/slices/queue';
|
||||||
|
import { formatDuration } from '../../lib/format';
|
||||||
|
import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
import type { Album } from '../../api/types';
|
||||||
|
|
||||||
/** `/artists/:artistId` — A3 artist detail (discography + similar). Scaffold only. */
|
|
||||||
export function ArtistDetailPage() {
|
export function ArtistDetailPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <Placeholder title={t('pages.artist')} />;
|
const { artistId } = useParams<{ artistId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const artistQuery = useGetArtistQuery(artistId ?? '', { skip: !artistId });
|
||||||
|
const albumsQuery = useGetArtistAlbumsQuery(artistId ?? '', {
|
||||||
|
skip: !artistId,
|
||||||
|
});
|
||||||
|
const tracksQuery = useGetArtistTracksQuery(artistId ?? '', {
|
||||||
|
skip: !artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Offline fallback: resolve the artist + their albums/tracks from the
|
||||||
|
// locally-cached library when the backend is unreachable.
|
||||||
|
const offline = useIsOffline();
|
||||||
|
const localArtists = useAppSelector(selectLocalArtists);
|
||||||
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
|
|
||||||
|
const artist =
|
||||||
|
artistQuery.data ??
|
||||||
|
(offline ? localArtists.find((a) => a.id === artistId) : undefined);
|
||||||
|
const albums =
|
||||||
|
albumsQuery.data ??
|
||||||
|
(offline ? localAlbums.filter((a) => a.artistId === artistId) : []);
|
||||||
|
const tracks =
|
||||||
|
tracksQuery.data ??
|
||||||
|
(offline ? localTracks.filter((tr) => tr.artistId === artistId) : []);
|
||||||
|
|
||||||
|
if (!artist) {
|
||||||
|
if (artistQuery.isLoading && !offline) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<LoadingSkeleton rows={10} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (offline) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="🎤"
|
||||||
|
title={t('artist.offline.title')}
|
||||||
|
description={t('artist.offline.description')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={t('artist.error')}
|
||||||
|
onRetry={() => artistQuery.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlayAll = () => {
|
||||||
|
if (!tracks.length) return;
|
||||||
|
dispatch(
|
||||||
|
setQueue({
|
||||||
|
entries: tracks.map((tr) => ({
|
||||||
|
trackId: tr.id,
|
||||||
|
title: tr.title,
|
||||||
|
artistName: tr.artistName,
|
||||||
|
albumTitle: tr.albumTitle,
|
||||||
|
durationMs: tr.durationMs,
|
||||||
|
albumArtUrl: tr.albumArtUrl,
|
||||||
|
})),
|
||||||
|
source: 'artist',
|
||||||
|
sourceId: artist.id,
|
||||||
|
sourceName: artist.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</IconButton>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArtTile seed={artist.id} label={artist.name} size={96} radius={48} />
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('artist.type')}
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: '0.25rem 0',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{artist.name}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('artist.meta', {
|
||||||
|
albumCount: artist.albumCount,
|
||||||
|
trackCount: artist.trackCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handlePlayAll}
|
||||||
|
disabled={!tracks.length}
|
||||||
|
>
|
||||||
|
{t('artist.play')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
{/* Discography */}
|
||||||
|
<section style={{ padding: '1.25rem 1.5rem 0' }}>
|
||||||
|
<h2
|
||||||
|
style={{ margin: '0 0 0.75rem', fontSize: '1rem', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{t('artist.albums')}
|
||||||
|
</h2>
|
||||||
|
{albums.length === 0 && !offline && albumsQuery.isLoading && (
|
||||||
|
<LoadingSkeleton rows={3} height={72} />
|
||||||
|
)}
|
||||||
|
{albums.length === 0 && !offline && albumsQuery.isError && (
|
||||||
|
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{albums.length === 0 &&
|
||||||
|
(offline || (!albumsQuery.isLoading && !albumsQuery.isError)) && (
|
||||||
|
<p style={{ color: 'var(--color-text-3)', fontSize: '0.875rem' }}>
|
||||||
|
{t('artist.noAlbums')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{albums.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(9rem, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{albums.map((album) => (
|
||||||
|
<AlbumCard
|
||||||
|
key={album.id}
|
||||||
|
album={album}
|
||||||
|
onClick={() => void navigate(`/albums/${album.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* All tracks */}
|
||||||
|
<section style={{ padding: '1.5rem 0 0.5rem' }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: '0 1.5rem 0.5rem',
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('artist.tracks')}
|
||||||
|
</h2>
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isLoading && (
|
||||||
|
<LoadingSkeleton rows={6} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isError && (
|
||||||
|
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 &&
|
||||||
|
(offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
|
||||||
|
<EmptyState
|
||||||
|
icon="♫"
|
||||||
|
title={t('artist.empty.title')}
|
||||||
|
description={t('artist.empty.description')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tracks.map((track, i) => (
|
||||||
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
const artUrl =
|
||||||
|
getCoverUrl(album.artUrl) ??
|
||||||
|
(token ? getAlbumCoverUrl(album.id, token, album.hasCover) : undefined);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0.75rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{artUrl ? (
|
||||||
|
<img
|
||||||
|
src={artUrl}
|
||||||
|
alt={album.title}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '1',
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '100%', aspectRatio: '1' }}>
|
||||||
|
<ArtTile seed={album.id} label={album.title} size={144} radius={6} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{album.year ? `${album.year} · ` : ''}
|
||||||
|
{t('library.albumCard.tracksDuration', {
|
||||||
|
count: album.trackCount,
|
||||||
|
duration: formatDuration(album.totalDurationMs),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,50 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
|
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
|
||||||
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk';
|
import {
|
||||||
|
Card,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Callout,
|
||||||
|
Badge,
|
||||||
|
Dialog,
|
||||||
|
IconButton,
|
||||||
|
} from '@olly/modern-sk';
|
||||||
import { Icon } from '../../components/common/Icon';
|
import { Icon } from '../../components/common/Icon';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
|
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||||
import { setTokens, setUser } from '../../store/slices/auth';
|
import { setTokens, setUser } from '../../store/slices/auth';
|
||||||
import { setApiBaseUrl } from '../../config/runtime-config';
|
import {
|
||||||
import { useLoginMutation } from '../../api/endpoints/auth';
|
useLoginMutation,
|
||||||
|
useRegisterMutation,
|
||||||
|
} from '../../api/endpoints/auth';
|
||||||
|
import { REGISTRATION_ENABLED } from '../../config/env';
|
||||||
import {
|
import {
|
||||||
listInstances,
|
listInstances,
|
||||||
getActiveInstanceId,
|
getActiveInstanceId,
|
||||||
setActiveInstanceId,
|
setActiveInstanceId,
|
||||||
removeInstance,
|
removeInstance,
|
||||||
|
clearInstanceAuth,
|
||||||
|
upsertInstance,
|
||||||
|
type Instance,
|
||||||
} from '../../config/instances';
|
} from '../../config/instances';
|
||||||
|
|
||||||
|
type Mode = 'login' | 'register';
|
||||||
|
|
||||||
|
const HEALTH_VARIANTS = {
|
||||||
|
connected: 'lime',
|
||||||
|
connecting: 'neutral',
|
||||||
|
disconnected: 'ember',
|
||||||
|
error: 'ember',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const HEALTH_KEY = {
|
||||||
|
connected: 'conn.connected',
|
||||||
|
connecting: 'conn.connecting',
|
||||||
|
disconnected: 'conn.disconnected',
|
||||||
|
error: 'conn.error',
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Map an RTKQ login failure to a user-facing i18n key. */
|
/** Map an RTKQ login failure to a user-facing i18n key. */
|
||||||
function resolveLoginError(err: unknown): string {
|
function resolveLoginError(err: unknown): string {
|
||||||
const e = err as FetchBaseQueryError | undefined;
|
const e = err as FetchBaseQueryError | undefined;
|
||||||
@@ -25,6 +56,137 @@ function resolveLoginError(err: unknown): string {
|
|||||||
return 'connect.errors.generic';
|
return 'connect.errors.generic';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map an RTKQ register failure to a user-facing i18n key. */
|
||||||
|
function resolveRegisterError(err: unknown): string {
|
||||||
|
const e = err as FetchBaseQueryError | undefined;
|
||||||
|
if (e && 'status' in e) {
|
||||||
|
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
|
||||||
|
if (e.status === 409) return 'connect.errors.usernameTaken';
|
||||||
|
if (e.status === 422) return 'connect.errors.passwordTooShort';
|
||||||
|
if (e.status === 403) return 'connect.errors.registrationDisabled';
|
||||||
|
}
|
||||||
|
return 'connect.errors.registerFailed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstanceRow({
|
||||||
|
inst,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onLogout,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
inst: Instance;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const status = useConnectionStatus(inst.baseUrl);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.625rem',
|
||||||
|
padding: '0.375rem 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge variant={HEALTH_VARIANTS[status]} dot>
|
||||||
|
{t(HEALTH_KEY[status])}
|
||||||
|
</Badge>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inst.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inst.baseUrl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selected ? (
|
||||||
|
<Badge variant="outline">{t('connect.domains.selected')}</Badge>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onSelect}>
|
||||||
|
{t('connect.domains.use')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
title={t('connect.removeDialog.title')}
|
||||||
|
description={t('connect.removeDialog.description', {
|
||||||
|
name: inst.name,
|
||||||
|
})}
|
||||||
|
trigger={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="iconbtn sm"
|
||||||
|
title={t('connect.domains.forgetTitle')}
|
||||||
|
>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
>
|
||||||
|
{t('connect.removeDialog.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
onLogout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('connect.removeDialog.logout')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ember"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('connect.removeDialog.removeAndLogout')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConnectPage() {
|
export function ConnectPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -32,40 +194,79 @@ export function ConnectPage() {
|
|||||||
|
|
||||||
const [rev, setRev] = useState(0);
|
const [rev, setRev] = useState(0);
|
||||||
const instances = listInstances();
|
const instances = listInstances();
|
||||||
const activeId = getActiveInstanceId();
|
|
||||||
|
|
||||||
const [apiUrl, setApiUrl] = useState('https://');
|
const [selectedId, setSelectedId] = useState<string | null>(() =>
|
||||||
|
getActiveInstanceId(),
|
||||||
|
);
|
||||||
|
const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
|
||||||
|
const [instanceAddShown, setInstanceAddShown] = useState(false);
|
||||||
|
|
||||||
|
const [addUrl, setAddUrl] = useState('');
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<Mode>('login');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [login, { isLoading }] = useLoginMutation();
|
const [login, { isLoading: isLoggingIn }] = useLoginMutation();
|
||||||
|
const [register, { isLoading: isRegistering }] = useRegisterMutation();
|
||||||
|
const isLoading = isLoggingIn || isRegistering;
|
||||||
|
|
||||||
const switchTo = (id: string) => {
|
const switchMode = (next: Mode) => {
|
||||||
|
setMode(next);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switching the active instance and reloading lets the app pick the saved
|
||||||
|
// session for that instance back up (if any); if it has none, ProtectedRoute
|
||||||
|
// bounces back here and `selectedId` defaults to it, surfacing the login card.
|
||||||
|
const selectInstance = (id: string) => {
|
||||||
setActiveInstanceId(id);
|
setActiveInstanceId(id);
|
||||||
window.location.assign('/');
|
window.location.assign('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
const forget = (id: string) => {
|
const handleAdd = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const url = addUrl.trim();
|
||||||
|
if (!url || url === 'https://') return;
|
||||||
|
const inst = upsertInstance(url);
|
||||||
|
setActiveInstanceId(inst.id);
|
||||||
|
setAddUrl('https://');
|
||||||
|
setSelectedId(inst.id);
|
||||||
|
setRev((r) => r + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = (id: string) => {
|
||||||
|
clearInstanceAuth(id);
|
||||||
|
setRev((r) => r + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (id: string) => {
|
||||||
removeInstance(id);
|
removeInstance(id);
|
||||||
|
if (selectedId === id) setSelectedId(getActiveInstanceId());
|
||||||
setRev((r) => r + 1);
|
setRev((r) => r + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!selectedInstance) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
// Point the API layer at this backend *before* logging in — baseQuery reads
|
|
||||||
// the active instance's URL at request time. Auth tokens then persist under
|
|
||||||
// that instance's namespace, never bleeding across servers.
|
|
||||||
setApiBaseUrl(apiUrl);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { user, tokens } = await login({ username, password }).unwrap();
|
const action =
|
||||||
|
mode === 'register'
|
||||||
|
? register({ username, password })
|
||||||
|
: login({ username, password });
|
||||||
|
const { user, tokens } = await action.unwrap();
|
||||||
dispatch(setTokens(tokens));
|
dispatch(setTokens(tokens));
|
||||||
dispatch(setUser(user));
|
dispatch(setUser(user));
|
||||||
void navigate('/');
|
void navigate('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(resolveLoginError(err));
|
setError(
|
||||||
|
mode === 'register'
|
||||||
|
? resolveRegisterError(err)
|
||||||
|
: resolveLoginError(err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,144 +312,172 @@ export function ConnectPage() {
|
|||||||
<Icon name="vinyl-record" fill /> MCMA
|
<Icon name="vinyl-record" fill /> MCMA
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{instances.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '1.25rem 1.5rem',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
|
|
||||||
{t('connect.savedInstances')}
|
|
||||||
</span>
|
|
||||||
{instances.map((inst) => (
|
|
||||||
<div
|
|
||||||
key={inst.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.625rem',
|
|
||||||
padding: '0.375rem 0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`led ${inst.id === activeId ? 'online' : 'offline'}`}
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background:
|
|
||||||
inst.id === activeId ? 'var(--lime)' : 'var(--fg-3)',
|
|
||||||
boxShadow:
|
|
||||||
inst.id === activeId ? '0 0 6px var(--lime)' : 'none',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--color-text-1)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{inst.name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'var(--color-text-3)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{inst.baseUrl}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{inst.id === activeId ? (
|
|
||||||
<Badge variant="lime">{t('connect.active')}</Badge>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => switchTo(inst.id)}
|
|
||||||
>
|
|
||||||
{t('connect.use')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="iconbtn sm"
|
|
||||||
onClick={() => forget(inst.id)}
|
|
||||||
title={t('connect.forgetTitle')}
|
|
||||||
>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<form
|
<div
|
||||||
onSubmit={handleSubmit}
|
|
||||||
style={{
|
style={{
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '1rem',
|
gap: '0.5rem',
|
||||||
padding: '1.5rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="msk-label">{t('connect.form.title')}</span>
|
{instances.length > 0 && (
|
||||||
<div>
|
<>
|
||||||
<label style={labelStyle}>{t('connect.form.serverUrl')}</label>
|
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
|
||||||
<TextField
|
{t('connect.domains.title')}
|
||||||
value={apiUrl}
|
</span>
|
||||||
onChange={(e) => setApiUrl(e.target.value)}
|
{instances.map((inst) => (
|
||||||
placeholder="https://your-server.example.com/api/v1"
|
<InstanceRow
|
||||||
required
|
key={inst.id}
|
||||||
/>
|
inst={inst}
|
||||||
</div>
|
selected={inst.id === selectedId}
|
||||||
<div>
|
onSelect={() => selectInstance(inst.id)}
|
||||||
<label style={labelStyle}>{t('connect.form.username')}</label>
|
onLogout={() => handleLogout(inst.id)}
|
||||||
<TextField
|
onRemove={() => handleRemove(inst.id)}
|
||||||
value={username}
|
/>
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
))}
|
||||||
placeholder="username"
|
</>
|
||||||
autoComplete="username"
|
)}
|
||||||
required
|
<form
|
||||||
/>
|
onSubmit={handleAdd}
|
||||||
</div>
|
style={{
|
||||||
<div>
|
display: 'flex',
|
||||||
<label style={labelStyle}>{t('connect.form.password')}</label>
|
gap: '0.5rem',
|
||||||
<TextField
|
marginTop: instances.length > 0 ? '0.5rem' : 0,
|
||||||
type="password"
|
}}
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <Callout variant="danger">{t(error)}</Callout>}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
disabled={isLoading}
|
|
||||||
style={{ marginTop: '0.5rem' }}
|
|
||||||
>
|
>
|
||||||
{isLoading ? t('connect.form.submitting') : t('connect.form.submit')}
|
{instanceAddShown ? (
|
||||||
</Button>
|
<>
|
||||||
</form>
|
<TextField
|
||||||
|
value={addUrl}
|
||||||
|
onChange={(e) => setAddUrl(e.target.value)}
|
||||||
|
placeholder={t('connect.domains.addPlaceholder')}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton type="submit" variant="primary">
|
||||||
|
<Icon name="plus" />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => setInstanceAddShown(true)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Icon name="plus" /> {t('connect.domains.addButton')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{selectedInstance && (
|
||||||
|
<Card>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="msk-label">
|
||||||
|
{mode === 'register'
|
||||||
|
? t('connect.login.registerTitle', {
|
||||||
|
name: selectedInstance.name,
|
||||||
|
})
|
||||||
|
: t('connect.login.title', { name: selectedInstance.name })}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('connect.login.username')}</label>
|
||||||
|
<TextField
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="username"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('connect.login.password')}</label>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="password"
|
||||||
|
autoComplete={
|
||||||
|
mode === 'register' ? 'new-password' : 'current-password'
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{mode === 'register' && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
marginTop: '0.375rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('connect.login.passwordHint')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <Callout variant="danger">{t(error)}</Callout>}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? mode === 'register'
|
||||||
|
? t('connect.login.registering')
|
||||||
|
: t('connect.login.submitting')
|
||||||
|
: mode === 'register'
|
||||||
|
? t('connect.login.registerSubmit')
|
||||||
|
: t('connect.login.submit')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{REGISTRATION_ENABLED && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'register' ? (
|
||||||
|
<>
|
||||||
|
{t('connect.login.haveAccount')}{' '}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => switchMode('login')}
|
||||||
|
>
|
||||||
|
{t('connect.login.signInLink')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('connect.login.noAccount')}{' '}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => switchMode('register')}
|
||||||
|
>
|
||||||
|
{t('connect.login.registerLink')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,275 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window } from '@olly/modern-sk';
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Callout,
|
||||||
|
Progress,
|
||||||
|
ScrollArea,
|
||||||
|
Spinner,
|
||||||
|
} from '@olly/modern-sk';
|
||||||
|
import {
|
||||||
|
useCancelDownloadMutation,
|
||||||
|
useGetDownloadsQuery,
|
||||||
|
useRetryDownloadMutation,
|
||||||
|
} from '../../api/endpoints/downloads';
|
||||||
|
import { Icon } from '../../components/common/Icon';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { formatDateTime } from '../../lib/format';
|
||||||
|
import type { DownloadJob, DownloadStatus } from '../../api/types';
|
||||||
|
|
||||||
|
const ACTIVE: readonly DownloadStatus[] = [
|
||||||
|
'queued',
|
||||||
|
'downloading',
|
||||||
|
'enriching',
|
||||||
|
];
|
||||||
|
const isActive = (s: DownloadStatus) => ACTIVE.includes(s);
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<
|
||||||
|
DownloadStatus,
|
||||||
|
{ variant: 'lime' | 'ember' | 'neutral' | 'outline'; key: string }
|
||||||
|
> = {
|
||||||
|
queued: { variant: 'neutral', key: 'downloads.status.queued' },
|
||||||
|
downloading: { variant: 'neutral', key: 'downloads.status.downloading' },
|
||||||
|
enriching: { variant: 'outline', key: 'downloads.status.enriching' },
|
||||||
|
done: { variant: 'lime', key: 'downloads.status.done' },
|
||||||
|
failed: { variant: 'ember', key: 'downloads.status.failed' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `/downloads` — A5 download manager: active queue, progress, errors, retries. */
|
||||||
export function DownloadsManagerPage() {
|
export function DownloadsManagerPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Poll while anything is in flight; idle pages stop polling (tag invalidation
|
||||||
|
// from a new download still refetches and re-arms the interval).
|
||||||
|
const [pollMs, setPollMs] = useState(2000);
|
||||||
|
const { data, isLoading, isError, refetch } = useGetDownloadsQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
pollingInterval: pollMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const anyActive = data?.items.some((j) => isActive(j.status)) ?? false;
|
||||||
|
setPollMs(anyActive ? 1500 : 0);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const jobs = data?.items ?? [];
|
||||||
|
const active = jobs.filter((j) => isActive(j.status));
|
||||||
|
const finished = jobs.filter((j) => !isActive(j.status));
|
||||||
|
const failedCount = jobs.filter((j) => j.status === 'failed').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<Window title={t('pages.downloads')}>
|
<header
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
style={{
|
||||||
</Window>
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||||
|
{t('downloads.title')}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.25rem 0 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('downloads.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{active.length > 0 && (
|
||||||
|
<Badge variant="neutral">
|
||||||
|
<Spinner size="sm" />{' '}
|
||||||
|
{t('downloads.activeCount', { count: active.length })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.5rem',
|
||||||
|
maxWidth: 880,
|
||||||
|
margin: '0 auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading && <LoadingSkeleton rows={5} height={64} />}
|
||||||
|
{isError && (
|
||||||
|
<ErrorState
|
||||||
|
message={t('downloads.loadError')}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && jobs.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="arrow-circle-down" />}
|
||||||
|
title={t('downloads.emptyTitle')}
|
||||||
|
description={t('downloads.emptyDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failedCount > 0 && (
|
||||||
|
<Callout variant="warning">
|
||||||
|
{t('downloads.failedBanner', { count: failedCount })}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{active.length > 0 && (
|
||||||
|
<Section title={t('downloads.sectionActive')}>
|
||||||
|
{active.map((job) => (
|
||||||
|
<JobRow key={job.id} job={job} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{finished.length > 0 && (
|
||||||
|
<Section title={t('downloads.sectionHistory')}>
|
||||||
|
{finished.map((job) => (
|
||||||
|
<JobRow key={job.id} job={job} />
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobRow({ job }: { job: DownloadJob }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [cancel, { isLoading: cancelling }] = useCancelDownloadMutation();
|
||||||
|
const [retry, { isLoading: retrying }] = useRetryDownloadMutation();
|
||||||
|
|
||||||
|
const badge = STATUS_BADGE[job.status];
|
||||||
|
const label = job.query || job.sourceId || job.source;
|
||||||
|
const added = formatDateTime(job.createdAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-surface-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{job.source}
|
||||||
|
{job.retryCount > 0 &&
|
||||||
|
` · ${t('downloads.attempt', { count: job.retryCount + 1 })}`}
|
||||||
|
{added && ` · ${added}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge variant={badge.variant} dot>
|
||||||
|
{t(badge.key)}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{job.status === 'done' && job.trackId && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate(`/tracks/${job.trackId}/metadata`)}
|
||||||
|
>
|
||||||
|
{t('downloads.open')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{job.status === 'failed' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
disabled={retrying}
|
||||||
|
onClick={() => void retry(job.id)}
|
||||||
|
>
|
||||||
|
<Icon name="arrows-clockwise" /> {t('downloads.retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isActive(job.status) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
disabled={cancelling}
|
||||||
|
onClick={() => void cancel(job.id)}
|
||||||
|
title={t('downloads.cancel')}
|
||||||
|
>
|
||||||
|
<Icon name="x" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.status === 'downloading' && (
|
||||||
|
<Progress value={Math.round(job.progress * 100)} />
|
||||||
|
)}
|
||||||
|
{job.status === 'failed' && job.errorMessage && (
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{job.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsList,
|
TabsList,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
SearchField,
|
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Card,
|
Card,
|
||||||
|
TextField,
|
||||||
|
Callout,
|
||||||
} from '@olly/modern-sk';
|
} from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetTracksQuery,
|
useGetTracksQuery,
|
||||||
@@ -18,11 +19,32 @@ import { TrackRow } from '../../components/track/TrackRow';
|
|||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
selectLocalTracks,
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalArtists,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
import { setQueue } from '../../store/slices/queue';
|
import { setQueue } from '../../store/slices/queue';
|
||||||
import type { Track, Album, Artist } from '../../api/types';
|
import type { Track, Album, Artist } from '../../api/types';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
|
import { useDebounce } from 'use-debounce';
|
||||||
|
|
||||||
|
/** Case-insensitive substring match used for client-side search while offline
|
||||||
|
* (the server can't run the query, so we filter the locally-cached library). */
|
||||||
|
function matchTrack(tr: Track, q: string): boolean {
|
||||||
|
return (
|
||||||
|
tr.title.toLowerCase().includes(q) ||
|
||||||
|
tr.artistName.toLowerCase().includes(q) ||
|
||||||
|
tr.albumTitle.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const matchAlbum = (a: Album, q: string): boolean =>
|
||||||
|
a.title.toLowerCase().includes(q) || a.artistName.toLowerCase().includes(q);
|
||||||
|
const matchArtist = (a: Artist, q: string): boolean =>
|
||||||
|
a.name.toLowerCase().includes(q);
|
||||||
|
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -30,10 +52,63 @@ export function LibraryPage() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [tab, setTab] = useState('tracks');
|
const [tab, setTab] = useState('tracks');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebounce(search, 300);
|
||||||
|
|
||||||
const tracksQuery = useGetTracksQuery(search ? { search } : undefined);
|
// Poll while any listed track is still being enriched, then stop. Enrichment
|
||||||
const albumsQuery = useGetAlbumsQuery(search ? { search } : undefined);
|
// runs asynchronously in a worker after import/upload; without this the row
|
||||||
const artistsQuery = useGetArtistsQuery(search ? { search } : undefined);
|
// stays stuck on "Identifying metadata…" until something else invalidates the
|
||||||
|
// Track tag. Cleared to 0 once nothing is pending (and while offline).
|
||||||
|
const [tracksPollMs, setTracksPollMs] = useState(0);
|
||||||
|
const tracksQuery = useGetTracksQuery(
|
||||||
|
debouncedSearch ? { search } : undefined,
|
||||||
|
{ pollingInterval: tracksPollMs },
|
||||||
|
);
|
||||||
|
const albumsQuery = useGetAlbumsQuery(
|
||||||
|
debouncedSearch ? { search } : undefined,
|
||||||
|
);
|
||||||
|
const artistsQuery = useGetArtistsQuery(
|
||||||
|
debouncedSearch ? { search } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Offline fallback: when the backend is unreachable, compose the library from
|
||||||
|
// whatever the RTKQ cache holds locally (rehydrated last-seen + this session),
|
||||||
|
// filtering client-side since the server can't run the search.
|
||||||
|
const offline = useIsOffline();
|
||||||
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
|
const localArtists = useAppSelector(selectLocalArtists);
|
||||||
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
|
|
||||||
|
const anyPending =
|
||||||
|
!offline &&
|
||||||
|
(tracksQuery.data?.items.some((tr) => tr.metadataStatus === 'pending') ??
|
||||||
|
false);
|
||||||
|
useEffect(() => {
|
||||||
|
setTracksPollMs(anyPending ? 4000 : 0);
|
||||||
|
}, [anyPending]);
|
||||||
|
|
||||||
|
// Live server data wins; offline we fall back to the locally-composed list.
|
||||||
|
const tracksToShow =
|
||||||
|
tracksQuery.data?.items ??
|
||||||
|
(offline
|
||||||
|
? q
|
||||||
|
? localTracks.filter((tr) => matchTrack(tr, q))
|
||||||
|
: localTracks
|
||||||
|
: undefined);
|
||||||
|
const albumsToShow =
|
||||||
|
albumsQuery.data?.items ??
|
||||||
|
(offline
|
||||||
|
? q
|
||||||
|
? localAlbums.filter((a) => matchAlbum(a, q))
|
||||||
|
: localAlbums
|
||||||
|
: undefined);
|
||||||
|
const artistsToShow =
|
||||||
|
artistsQuery.data?.items ??
|
||||||
|
(offline
|
||||||
|
? q
|
||||||
|
? localArtists.filter((a) => matchArtist(a, q))
|
||||||
|
: localArtists
|
||||||
|
: undefined);
|
||||||
|
|
||||||
const handlePlayAll = (tracks: Track[]) => {
|
const handlePlayAll = (tracks: Track[]) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
@@ -68,15 +143,20 @@ export function LibraryPage() {
|
|||||||
{t('library.title')}
|
{t('library.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ flex: 1, maxWidth: '20rem' }}>
|
<div style={{ flex: 1, maxWidth: '20rem' }}>
|
||||||
<SearchField
|
<TextField
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder={t('library.searchPlaceholder')}
|
placeholder={t('library.searchPlaceholder')}
|
||||||
icon="⌕"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('library.offline.banner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={setTab}
|
onValueChange={setTab}
|
||||||
@@ -105,74 +185,84 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />}
|
{!tracksToShow && tracksQuery.isLoading && (
|
||||||
{tracksQuery.isError && (
|
<LoadingSkeleton rows={12} />
|
||||||
|
)}
|
||||||
|
{!tracksToShow && !offline && tracksQuery.isError && (
|
||||||
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
|
{tracksToShow && tracksToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title={t('library.empty.tracks.title')}
|
title={t(
|
||||||
description={t('library.empty.tracks.description')}
|
offline
|
||||||
|
? 'library.offline.emptyTitle'
|
||||||
|
: 'library.empty.tracks.title',
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
offline
|
||||||
|
? 'library.offline.emptyDesc'
|
||||||
|
: 'library.empty.tracks.description',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracksQuery.data &&
|
{tracksToShow && tracksToShow.length > 0 && (
|
||||||
tracksQuery.data.items.length > 0 &&
|
<div>
|
||||||
(() => {
|
<div
|
||||||
const data = tracksQuery.data!;
|
style={{
|
||||||
return (
|
padding: '0.5rem 0.75rem',
|
||||||
<div>
|
display: 'flex',
|
||||||
<div
|
gap: '0.5rem',
|
||||||
style={{
|
alignItems: 'center',
|
||||||
padding: '0.5rem 0.75rem',
|
borderBottom: '1px solid var(--color-border)',
|
||||||
display: 'flex',
|
}}
|
||||||
gap: '0.5rem',
|
>
|
||||||
alignItems: 'center',
|
<button
|
||||||
borderBottom: '1px solid var(--color-border)',
|
onClick={() => handlePlayAll(tracksToShow)}
|
||||||
}}
|
style={{
|
||||||
>
|
background: 'none',
|
||||||
<button
|
border: 'none',
|
||||||
onClick={() => handlePlayAll(data.items)}
|
cursor: 'pointer',
|
||||||
style={{
|
color: 'var(--color-accent)',
|
||||||
background: 'none',
|
fontSize: '0.875rem',
|
||||||
border: 'none',
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
}}
|
||||||
color: 'var(--color-accent)',
|
>
|
||||||
fontSize: '0.875rem',
|
{t('library.playAll', { count: tracksToShow.length })}
|
||||||
fontWeight: 500,
|
</button>
|
||||||
}}
|
</div>
|
||||||
>
|
{tracksToShow.map((track, i) => (
|
||||||
{t('library.playAll', { count: data.total })}
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
</button>
|
))}
|
||||||
</div>
|
</div>
|
||||||
{data.items.map((track, i) => (
|
)}
|
||||||
<TrackRow
|
|
||||||
key={track.id}
|
|
||||||
track={track}
|
|
||||||
index={i}
|
|
||||||
showAlbum
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />}
|
{!albumsToShow && albumsQuery.isLoading && (
|
||||||
{albumsQuery.isError && (
|
<LoadingSkeleton rows={8} height={72} />
|
||||||
|
)}
|
||||||
|
{!albumsToShow && !offline && albumsQuery.isError && (
|
||||||
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
|
{albumsToShow && albumsToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="💿"
|
icon="💿"
|
||||||
title={t('library.empty.albums.title')}
|
title={t(
|
||||||
description={t('library.empty.albums.description')}
|
offline
|
||||||
|
? 'library.offline.emptyTitle'
|
||||||
|
: 'library.empty.albums.title',
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
offline
|
||||||
|
? 'library.offline.emptyDesc'
|
||||||
|
: 'library.empty.albums.description',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{albumsQuery.data && (
|
{albumsToShow && albumsToShow.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -181,7 +271,7 @@ export function LibraryPage() {
|
|||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{albumsQuery.data.items.map((album) => (
|
{albumsToShow.map((album) => (
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={album.id}
|
key={album.id}
|
||||||
album={album}
|
album={album}
|
||||||
@@ -195,21 +285,35 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />}
|
{!artistsToShow && artistsQuery.isLoading && (
|
||||||
{artistsQuery.isError && (
|
<LoadingSkeleton rows={8} />
|
||||||
|
)}
|
||||||
|
{!artistsToShow && !offline && artistsQuery.isError && (
|
||||||
<ErrorState onRetry={() => artistsQuery.refetch()} />
|
<ErrorState onRetry={() => artistsQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
|
{artistsToShow && artistsToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="🎤"
|
icon="🎤"
|
||||||
title={t('library.empty.artists.title')}
|
title={t(
|
||||||
description={t('library.empty.artists.description')}
|
offline
|
||||||
|
? 'library.offline.emptyTitle'
|
||||||
|
: 'library.empty.artists.title',
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
offline
|
||||||
|
? 'library.offline.emptyDesc'
|
||||||
|
: 'library.empty.artists.description',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{artistsQuery.data && (
|
{artistsToShow && artistsToShow.length > 0 && (
|
||||||
<div style={{ padding: '0.5rem 0' }}>
|
<div style={{ padding: '0.5rem 0' }}>
|
||||||
{artistsQuery.data.items.map((artist) => (
|
{artistsToShow.map((artist) => (
|
||||||
<ArtistRow key={artist.id} artist={artist} />
|
<ArtistRow
|
||||||
|
key={artist.id}
|
||||||
|
artist={artist}
|
||||||
|
onClick={() => void navigate(`/artists/${artist.id}`)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -222,7 +326,12 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const artUrl = getCoverUrl(album.artUrl);
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
// The album record has no cover URL; build one from `hasCover` (served by
|
||||||
|
// GET /albums/{id}/cover, token in the query — <img> can't send a header).
|
||||||
|
const artUrl =
|
||||||
|
getCoverUrl(album.artUrl) ??
|
||||||
|
(token ? getAlbumCoverUrl(album.id, token, album.hasCover) : undefined);
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -295,15 +404,23 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArtistRow({ artist }: { artist: Artist }) {
|
function ArtistRow({
|
||||||
|
artist,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
artist: Artist;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
padding: '0.5rem 1.5rem',
|
padding: '0.5rem 1.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Callout,
|
||||||
|
Card,
|
||||||
|
IconButton,
|
||||||
|
ScrollArea,
|
||||||
|
Spinner,
|
||||||
|
TextField,
|
||||||
|
} from '@olly/modern-sk';
|
||||||
|
import {
|
||||||
|
useApplyMetadataMutation,
|
||||||
|
useEnrichTrackMutation,
|
||||||
|
useGetTrackQuery,
|
||||||
|
useLazyGetMetadataMatchesQuery,
|
||||||
|
} from '../../api/endpoints/library';
|
||||||
|
import type { MetadataMatch } from '../../api/types';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { Placeholder } from '../../components/common/Placeholder';
|
import { Placeholder } from '../../components/common/Placeholder';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -6,13 +27,565 @@ interface Props {
|
|||||||
batch?: boolean;
|
batch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Editable fields, kept as strings while in the form — parsed on save. */
|
||||||
|
interface FormState {
|
||||||
|
title: string;
|
||||||
|
artistName: string;
|
||||||
|
albumTitle: string;
|
||||||
|
year: string;
|
||||||
|
genre: string;
|
||||||
|
trackNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormState = {
|
||||||
|
title: '',
|
||||||
|
artistName: '',
|
||||||
|
albumTitle: '',
|
||||||
|
year: '',
|
||||||
|
genre: '',
|
||||||
|
trackNumber: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: '0.375rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fieldStyle(): React.CSSProperties {
|
||||||
|
return { width: '100%' };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/tracks/:trackId/metadata` (single) and `/metadata/batch` (bulk) — A7
|
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
||||||
* metadata editor with auto-enrichment / diff view. Scaffold only.
|
* match picker with a current-vs-proposed diff. `/metadata/batch` is deferred.
|
||||||
*/
|
*/
|
||||||
export function MetadataEditorPage({ batch = false }: Props) {
|
export function MetadataEditorPage({ batch = false }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (batch) {
|
||||||
|
return <Placeholder title={t('pages.metadataBatch')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SingleTrackEditor />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SingleTrackEditor() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { trackId } = useParams<{ trackId: string }>();
|
||||||
|
|
||||||
|
const trackQuery = useGetTrackQuery(trackId ?? '', { skip: !trackId });
|
||||||
|
const [findMatches, matchesResult] = useLazyGetMetadataMatchesQuery();
|
||||||
|
const [applyMetadata, applyResult] = useApplyMetadataMutation();
|
||||||
|
const [enrichTrack, enrichResult] = useEnrichTrackMutation();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [selectedMatch, setSelectedMatch] = useState<MetadataMatch | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed the form from the loaded track exactly once — afterwards it's the
|
||||||
|
// user's edit buffer and shouldn't be clobbered by refetches.
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialized || !trackQuery.data) return;
|
||||||
|
const track = trackQuery.data;
|
||||||
|
setForm({
|
||||||
|
title: track.title,
|
||||||
|
artistName: track.artistName,
|
||||||
|
albumTitle: track.albumTitle,
|
||||||
|
year: track.year != null ? String(track.year) : '',
|
||||||
|
genre: track.genre ?? '',
|
||||||
|
trackNumber: track.trackNumber != null ? String(track.trackNumber) : '',
|
||||||
|
});
|
||||||
|
setInitialized(true);
|
||||||
|
}, [initialized, trackQuery.data]);
|
||||||
|
|
||||||
|
if (!trackId) {
|
||||||
|
return <ErrorState message={t('metadataEditor.error')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackQuery.isLoading || !initialized) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<LoadingSkeleton rows={6} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackQuery.isError || !trackQuery.data) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={t('metadataEditor.error')}
|
||||||
|
onRetry={() => trackQuery.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = trackQuery.data;
|
||||||
|
|
||||||
|
const updateField = (key: keyof FormState) => (value: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const applyMatch = (match: MetadataMatch) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
title: match.title ?? prev.title,
|
||||||
|
artistName: match.artist ?? prev.artistName,
|
||||||
|
albumTitle: match.album ?? prev.albumTitle,
|
||||||
|
year: match.year != null ? String(match.year) : prev.year,
|
||||||
|
}));
|
||||||
|
setSelectedMatch(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await applyMetadata({
|
||||||
|
trackId,
|
||||||
|
edit: {
|
||||||
|
title: form.title.trim() || undefined,
|
||||||
|
artistName: form.artistName.trim() || undefined,
|
||||||
|
albumTitle: form.albumTitle.trim() || undefined,
|
||||||
|
year: form.year.trim() ? Number(form.year) : undefined,
|
||||||
|
genre: form.genre.trim() || undefined,
|
||||||
|
trackNumber: form.trackNumber.trim()
|
||||||
|
? Number(form.trackNumber)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}).unwrap();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Placeholder title={batch ? t('pages.metadataBatch') : t('pages.metadata')} />
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</IconButton>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||||
|
{t('pages.metadata')}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.125rem 0 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{track.artistName} · {track.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
maxWidth: 640,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{applyResult.isSuccess && (
|
||||||
|
<Callout variant="success">{t('metadataEditor.saved')}</Callout>
|
||||||
|
)}
|
||||||
|
{applyResult.isError && (
|
||||||
|
<Callout variant="danger">{t('metadataEditor.saveError')}</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.title')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => updateField('title')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.artist')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.artistName}
|
||||||
|
onChange={(e) => updateField('artistName')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.album')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.albumTitle}
|
||||||
|
onChange={(e) => updateField('albumTitle')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.year')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
type="number"
|
||||||
|
value={form.year}
|
||||||
|
onChange={(e) => updateField('year')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.trackNumber')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
type="number"
|
||||||
|
value={form.trackNumber}
|
||||||
|
onChange={(e) => updateField('trackNumber')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.genre')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.genre}
|
||||||
|
onChange={(e) => updateField('genre')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={applyResult.isLoading}
|
||||||
|
>
|
||||||
|
{applyResult.isLoading ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataEditor.save')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||||
|
{t('metadataEditor.autoEnrich.title')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('metadataEditor.autoEnrich.hint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void enrichTrack(trackId)}
|
||||||
|
disabled={enrichResult.isLoading}
|
||||||
|
>
|
||||||
|
{enrichResult.isLoading ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataEditor.autoEnrich.reEnrich')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void findMatches(trackId)}
|
||||||
|
disabled={matchesResult.isFetching}
|
||||||
|
>
|
||||||
|
{matchesResult.isFetching ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataEditor.autoEnrich.findMatches')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enrichResult.isSuccess && (
|
||||||
|
<Callout variant="info">
|
||||||
|
{t('metadataEditor.autoEnrich.enqueued')}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matchesResult.isError && (
|
||||||
|
<Callout variant="danger">
|
||||||
|
{t('metadataEditor.autoEnrich.error')}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matchesResult.isSuccess &&
|
||||||
|
matchesResult.data &&
|
||||||
|
(matchesResult.data.length === 0 ? (
|
||||||
|
<Callout variant="warning">
|
||||||
|
{t('metadataEditor.autoEnrich.noMatches')}
|
||||||
|
</Callout>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matchesResult.data.map((match) => (
|
||||||
|
<MatchRow
|
||||||
|
key={match.acoustid}
|
||||||
|
match={match}
|
||||||
|
onUse={() => setSelectedMatch(match)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{selectedMatch && (
|
||||||
|
<DiffView
|
||||||
|
current={form}
|
||||||
|
proposed={selectedMatch}
|
||||||
|
onApply={() => applyMatch(selectedMatch)}
|
||||||
|
onCancel={() => setSelectedMatch(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchRow({
|
||||||
|
match,
|
||||||
|
onUse,
|
||||||
|
}: {
|
||||||
|
match: MetadataMatch;
|
||||||
|
onUse: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pct = Math.round(match.score * 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '0.625rem 0.75rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-surface-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge variant={pct >= 80 ? 'lime' : pct >= 50 ? 'outline' : 'neutral'}>
|
||||||
|
{pct}%
|
||||||
|
</Badge>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{match.title ?? t('metadataEditor.matches.unknownTitle')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[match.artist, match.album, match.year]
|
||||||
|
.filter((v) => v !== undefined && v !== null && v !== '')
|
||||||
|
.join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onUse}>
|
||||||
|
{t('metadataEditor.matches.use')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffRowDef {
|
||||||
|
key: 'title' | 'artistName' | 'albumTitle' | 'year';
|
||||||
|
label: string;
|
||||||
|
current: string;
|
||||||
|
proposed?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffView({
|
||||||
|
current,
|
||||||
|
proposed,
|
||||||
|
onApply,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
current: FormState;
|
||||||
|
proposed: MetadataMatch;
|
||||||
|
onApply: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const rows: DiffRowDef[] = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
label: t('metadataEditor.fields.title'),
|
||||||
|
current: current.title,
|
||||||
|
proposed: proposed.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'artistName',
|
||||||
|
label: t('metadataEditor.fields.artist'),
|
||||||
|
current: current.artistName,
|
||||||
|
proposed: proposed.artist,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'albumTitle',
|
||||||
|
label: t('metadataEditor.fields.album'),
|
||||||
|
current: current.albumTitle,
|
||||||
|
proposed: proposed.album,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'year',
|
||||||
|
label: t('metadataEditor.fields.year'),
|
||||||
|
current: current.year,
|
||||||
|
proposed: proposed.year != null ? String(proposed.year) : undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const changed = rows.filter(
|
||||||
|
(row) => row.proposed !== undefined && row.proposed !== row.current,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '0.875rem 1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.625rem',
|
||||||
|
background: 'var(--color-surface-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>
|
||||||
|
{t('metadataEditor.diff.title')}
|
||||||
|
</div>
|
||||||
|
{changed.length === 0 ? (
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('metadataEditor.diff.noChanges')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}
|
||||||
|
>
|
||||||
|
{changed.map((row) => (
|
||||||
|
<div key={row.key} style={{ fontSize: '0.8125rem' }}>
|
||||||
|
<span style={{ color: 'var(--color-text-3)' }}>
|
||||||
|
{row.label}:{' '}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.current || '—'}
|
||||||
|
</span>
|
||||||
|
{' → '}
|
||||||
|
<span style={{ color: 'var(--color-accent)', fontWeight: 600 }}>
|
||||||
|
{row.proposed || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||||
|
{t('metadataEditor.diff.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onApply}
|
||||||
|
disabled={changed.length === 0}
|
||||||
|
>
|
||||||
|
{t('metadataEditor.diff.apply')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,358 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window } from '@olly/modern-sk';
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Callout,
|
||||||
|
ScrollArea,
|
||||||
|
SearchField,
|
||||||
|
SegmentedControl,
|
||||||
|
Spinner,
|
||||||
|
} from '@olly/modern-sk';
|
||||||
|
import {
|
||||||
|
useGetSourcesQuery,
|
||||||
|
useLazySearchExternalQuery,
|
||||||
|
} from '../../api/endpoints/search';
|
||||||
|
import { useCreateDownloadMutation } from '../../api/endpoints/downloads';
|
||||||
|
import { Icon } from '../../components/common/Icon';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { formatDuration } from '../../lib/format';
|
||||||
|
import type { ExternalSearchResult } from '../../api/types';
|
||||||
|
|
||||||
|
const ALL = '__all__';
|
||||||
|
|
||||||
|
/** Per-result download outcome, keyed by `${source}:${sourceId}`. */
|
||||||
|
type RowState = 'idle' | 'pending' | 'queued' | 'inLibrary' | 'error';
|
||||||
|
|
||||||
|
const rowKey = (r: ExternalSearchResult) => `${r.source}:${r.sourceId}`;
|
||||||
|
|
||||||
|
/** `/discover` — A4: search external sources and download into the library. */
|
||||||
export function SearchDownloadPage() {
|
export function SearchDownloadPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const sourcesQuery = useGetSourcesQuery();
|
||||||
|
const fetchSources = (sourcesQuery.data ?? []).filter(
|
||||||
|
(s) => s.kind === 'fetch',
|
||||||
|
);
|
||||||
|
const hasFetchSource = fetchSources.length > 0;
|
||||||
|
|
||||||
|
const [term, setTerm] = useState('');
|
||||||
|
const [source, setSource] = useState(ALL);
|
||||||
|
const [search, result] = useLazySearchExternalQuery();
|
||||||
|
const [createDownload] = useCreateDownloadMutation();
|
||||||
|
|
||||||
|
const [rowStates, setRowStates] = useState<Record<string, RowState>>({});
|
||||||
|
const [queuedAny, setQueuedAny] = useState(false);
|
||||||
|
|
||||||
|
const runSearch = (q: string) => {
|
||||||
|
if (!q) return;
|
||||||
|
setRowStates({});
|
||||||
|
void search({ q, source: source === ALL ? undefined : source, limit: 25 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
runSearch(term.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async (r: ExternalSearchResult) => {
|
||||||
|
const key = rowKey(r);
|
||||||
|
setRowStates((s) => ({ ...s, [key]: 'pending' }));
|
||||||
|
try {
|
||||||
|
const res = await createDownload({
|
||||||
|
source: r.source,
|
||||||
|
sourceId: r.sourceId,
|
||||||
|
query: term.trim() || undefined,
|
||||||
|
}).unwrap();
|
||||||
|
setRowStates((s) => ({
|
||||||
|
...s,
|
||||||
|
[key]: res.alreadyInLibrary ? 'inLibrary' : 'queued',
|
||||||
|
}));
|
||||||
|
if (!res.alreadyInLibrary) setQueuedAny(true);
|
||||||
|
} catch {
|
||||||
|
setRowStates((s) => ({ ...s, [key]: 'error' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickerItems = [
|
||||||
|
{ value: ALL, label: t('discover.allSources') },
|
||||||
|
...fetchSources.map((s) => ({ value: s.name, label: s.label })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<Window title={t('pages.search')}>
|
<header
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
style={{
|
||||||
</Window>
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||||
|
{t('discover.title')}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.25rem 0 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('discover.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{queuedAny && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate('/downloads')}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-circle-down" /> {t('discover.viewDownloads')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
style={{ display: 'flex', gap: '0.625rem', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<SearchField
|
||||||
|
icon={<Icon name="magnifying-glass" />}
|
||||||
|
placeholder={t('discover.searchPlaceholder')}
|
||||||
|
value={term}
|
||||||
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
disabled={!hasFetchSource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasFetchSource || !term.trim()}
|
||||||
|
>
|
||||||
|
{t('discover.searchButton')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{fetchSources.length > 1 && (
|
||||||
|
<SegmentedControl
|
||||||
|
value={source}
|
||||||
|
onValueChange={setSource}
|
||||||
|
items={pickerItems}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
<div style={{ padding: '1.5rem', maxWidth: 880, margin: '0 auto' }}>
|
||||||
|
{!sourcesQuery.isLoading && !hasFetchSource && (
|
||||||
|
<Callout variant="warning">{t('discover.noSources')}</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.isFetching && <LoadingSkeleton rows={6} height={64} />}
|
||||||
|
|
||||||
|
{result.isError && !result.isFetching && (
|
||||||
|
<ErrorState
|
||||||
|
message={t('discover.searchError')}
|
||||||
|
onRetry={() => runSearch(term.trim())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!result.isFetching &&
|
||||||
|
!result.isError &&
|
||||||
|
result.data &&
|
||||||
|
result.data.results.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="magnifying-glass" />}
|
||||||
|
title={t('discover.emptyTitle')}
|
||||||
|
description={t('discover.emptyDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.isUninitialized && hasFetchSource && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="magnifying-glass" />}
|
||||||
|
title={t('discover.startTitle')}
|
||||||
|
description={t('discover.startDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!result.isFetching &&
|
||||||
|
result.data &&
|
||||||
|
result.data.results.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.data.results.map((r) => (
|
||||||
|
<ResultRow
|
||||||
|
key={rowKey(r)}
|
||||||
|
result={r}
|
||||||
|
state={rowStates[rowKey(r)] ?? 'idle'}
|
||||||
|
onDownload={() => void download(r)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultRow({
|
||||||
|
result,
|
||||||
|
state,
|
||||||
|
onDownload,
|
||||||
|
}: {
|
||||||
|
result: ExternalSearchResult;
|
||||||
|
state: RowState;
|
||||||
|
onDownload: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.875rem',
|
||||||
|
padding: '0.625rem 0.75rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-surface-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Thumb url={result.thumbnailUrl} />
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={result.title}
|
||||||
|
>
|
||||||
|
{result.title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[result.artist, result.album].filter(Boolean).join(' · ') ||
|
||||||
|
result.source}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.durationMs != null && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDuration(result.durationMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DownloadControl state={state} onDownload={onDownload} t={t} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadControl({
|
||||||
|
state,
|
||||||
|
onDownload,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
state: RowState;
|
||||||
|
onDownload: () => void;
|
||||||
|
t: (k: string) => string;
|
||||||
|
}) {
|
||||||
|
if (state === 'inLibrary') {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" dot>
|
||||||
|
{t('discover.inLibrary')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'queued') {
|
||||||
|
return (
|
||||||
|
<Badge variant="lime" dot>
|
||||||
|
{t('discover.queued')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'pending') {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" type="button" disabled>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button variant="primary" size="sm" type="button" onClick={onDownload}>
|
||||||
|
<Icon name="arrow-circle-down" />
|
||||||
|
{state === 'error' ? t('discover.retryDownload') : t('discover.download')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumb({ url }: { url?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 6,
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--color-surface-2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url ? (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
width={44}
|
||||||
|
height={44}
|
||||||
|
style={{ objectFit: 'cover', width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon name="vinyl-record" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { Window, SegmentedControl, useTheme } from '@olly/modern-sk';
|
|||||||
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
||||||
|
|
||||||
/** Labelled settings row: caption on the left, control on the right. */
|
/** Labelled settings row: caption on the left, control on the right. */
|
||||||
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
|
function SettingRow({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,13 +1,679 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window } from '@olly/modern-sk';
|
import { Window, Card, Badge, Callout } from '@olly/modern-sk';
|
||||||
|
import { useGetStorageStatsQuery } from '../../api/endpoints/storage';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { Icon, type IconName } from '../../components/common/Icon';
|
||||||
|
import { useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import { useAudioCacheStats } from '../../hooks/useAudioCacheStats';
|
||||||
|
import {
|
||||||
|
selectLocalTracks,
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalArtists,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
|
import type { AudioCacheStats } from '../../lib/sw';
|
||||||
|
import {
|
||||||
|
formatFileSize,
|
||||||
|
formatCount,
|
||||||
|
formatLongDuration,
|
||||||
|
formatDateTime,
|
||||||
|
} from '../../lib/format';
|
||||||
|
import type { StorageStats } from '../../api/types';
|
||||||
|
|
||||||
|
// modern-sk Badge variants we map metadata-health buckets onto.
|
||||||
|
const STATUS_VARIANT: Record<string, 'lime' | 'ember' | 'neutral' | 'outline'> =
|
||||||
|
{
|
||||||
|
enriched: 'lime',
|
||||||
|
manual: 'outline',
|
||||||
|
pending: 'neutral',
|
||||||
|
failed: 'ember',
|
||||||
|
};
|
||||||
|
|
||||||
export function StoragePage() {
|
export function StoragePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
|
||||||
|
const offline = useIsOffline();
|
||||||
|
|
||||||
|
// Tier-3 audio cache + the locally-cached library metadata = "this device".
|
||||||
|
const audio = useAudioCacheStats();
|
||||||
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
|
const localArtists = useAppSelector(selectLocalArtists);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||||
<Window title={t('pages.storage')}>
|
<Window title={t('pages.storage')}>
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
<p style={{ color: 'var(--color-text-2)', marginTop: 0 }}>
|
||||||
|
{t('storage.subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}
|
||||||
|
>
|
||||||
|
{/* ── On this device (local + cached) ───────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle icon="hard-drives">
|
||||||
|
{t('storage.device')}
|
||||||
|
</SectionTitle>
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<LocalStoragePanel
|
||||||
|
audio={audio}
|
||||||
|
trackCount={localTracks.length}
|
||||||
|
albumCount={localAlbums.length}
|
||||||
|
artistCount={localArtists.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── On the server (remote) ────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle icon="cloud">{t('storage.server')}</SectionTitle>
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
{isLoading && <LoadingSkeleton rows={6} height={72} />}
|
||||||
|
{isError && offline && (
|
||||||
|
<Callout variant="info">
|
||||||
|
{t('storage.serverUnreachable')}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
{isError && !offline && (
|
||||||
|
<ErrorState
|
||||||
|
message={t('common.error')}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data && data.totalTracks === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="hard-drives" />}
|
||||||
|
title={t('storage.emptyTitle')}
|
||||||
|
description={t('storage.emptyDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data && data.totalTracks > 0 && (
|
||||||
|
<StorageDashboard stats={data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Window>
|
</Window>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "On this device": the SW audio cache (downloaded audio) + the offline
|
||||||
|
* library metadata we can browse without the server. */
|
||||||
|
function LocalStoragePanel({
|
||||||
|
audio,
|
||||||
|
trackCount,
|
||||||
|
albumCount,
|
||||||
|
artistCount,
|
||||||
|
}: {
|
||||||
|
audio: AudioCacheStats | null;
|
||||||
|
trackCount: number;
|
||||||
|
albumCount: number;
|
||||||
|
artistCount: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="arrow-circle-down">
|
||||||
|
{t('storage.audioCache')}
|
||||||
|
</SectionTitle>
|
||||||
|
{audio ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--color-surface-3)',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${audio.maxBytes > 0 ? Math.min((audio.bytes / audio.maxBytes) * 100, 100) : 0}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: 'var(--color-accent)',
|
||||||
|
transition: 'width 0.5s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.6rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.audioCacheUsage', {
|
||||||
|
used: formatFileSize(audio.bytes),
|
||||||
|
max: formatFileSize(audio.maxBytes),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.25rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.cachedTracks', { n: audio.count })}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.75rem 0 0',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.audioCacheUnavailable')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="vinyl-record">
|
||||||
|
{t('storage.offlineLibrary')}
|
||||||
|
</SectionTitle>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.75rem 0 0',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCount(trackCount)}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.35rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.offlineLibraryMeta', {
|
||||||
|
tracks: formatCount(trackCount),
|
||||||
|
albums: formatCount(albumCount),
|
||||||
|
artists: formatCount(artistCount),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StorageDashboard({ stats }: { stats: StorageStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
|
{stats.disk && (
|
||||||
|
<DiskGauge disk={stats.disk} libraryBytes={stats.totalSize} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatTile
|
||||||
|
icon="vinyl-record"
|
||||||
|
label={t('storage.tracks')}
|
||||||
|
value={formatCount(stats.totalTracks)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon="vinyl-record"
|
||||||
|
label={t('storage.artists')}
|
||||||
|
value={formatCount(stats.totalArtists)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon="vinyl-record"
|
||||||
|
label={t('storage.albums')}
|
||||||
|
value={formatCount(stats.totalAlbums)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon="play"
|
||||||
|
label={t('storage.playtime')}
|
||||||
|
value={formatLongDuration(stats.totalDurationSeconds)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon="hard-drives"
|
||||||
|
label={t('storage.footprint')}
|
||||||
|
value={formatFileSize(stats.totalSize)}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
icon="hard-drives"
|
||||||
|
label={t('storage.avgTrackSize')}
|
||||||
|
value={formatFileSize(avgSize)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.byFormat.length > 0 && <FormatBars stats={stats} />}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MetadataHealth stats={stats} />
|
||||||
|
<Sources stats={stats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.topGenres.length > 0 && <TopGenres stats={stats} />}
|
||||||
|
|
||||||
|
<FunFacts stats={stats} avgSize={avgSize} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiskGauge({
|
||||||
|
disk,
|
||||||
|
libraryBytes,
|
||||||
|
}: {
|
||||||
|
disk: NonNullable<StorageStats['disk']>;
|
||||||
|
libraryBytes: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pct = (n: number) => (disk.total > 0 ? (n / disk.total) * 100 : 0);
|
||||||
|
// The library is a slice of "used"; the rest of used is everything else on
|
||||||
|
// the volume. Clamp so a slightly-stale library total never overflows.
|
||||||
|
const libShare = Math.min(libraryBytes, disk.used);
|
||||||
|
const otherUsed = Math.max(disk.used - libShare, 0);
|
||||||
|
const libPercentOfDisk = pct(libraryBytes).toFixed(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="hard-drives">{t('storage.disk')}</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--color-surface-3)',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pct(libShare)}%`,
|
||||||
|
background: 'var(--color-accent)',
|
||||||
|
transition: 'width 0.5s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${pct(otherUsed)}%`,
|
||||||
|
background: 'var(--color-text-3)',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginTop: '0.6rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Dot color="var(--color-accent)" /> {formatFileSize(libraryBytes)}{' '}
|
||||||
|
{t('storage.footprint').toLowerCase()}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('storage.diskUsage', {
|
||||||
|
used: formatFileSize(disk.used),
|
||||||
|
total: formatFileSize(disk.total),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('storage.diskFree', { free: formatFileSize(disk.free) })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.5rem 0 0',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.diskLibraryShare', { percent: libPercentOfDisk })}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormatBars({ stats }: { stats: StorageStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const max = Math.max(...stats.byFormat.map((f) => f.totalSize), 1);
|
||||||
|
return (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="vinyl-record">{t('storage.formats')}</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.7rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats.byFormat.map((f) => (
|
||||||
|
<div key={f.fileFormat}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.fileFormat}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--color-text-2)' }}>
|
||||||
|
{formatCount(f.trackCount)} · {formatFileSize(f.totalSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--color-surface-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${(f.totalSize / max) * 100}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: 'var(--color-accent)',
|
||||||
|
transition: 'width 0.5s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetadataHealth({ stats }: { stats: StorageStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const entries = Object.entries(stats.byMetadataStatus).filter(
|
||||||
|
([, n]) => n > 0,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="check-circle">
|
||||||
|
{t('storage.metadataHealth')}
|
||||||
|
</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entries.map(([status, count]) => (
|
||||||
|
<Badge key={status} variant={STATUS_VARIANT[status] ?? 'neutral'}>
|
||||||
|
{t(`storage.status.${status}`, { defaultValue: status })} ·{' '}
|
||||||
|
{formatCount(count)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sources({ stats }: { stats: StorageStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const entries = Object.entries(stats.bySource).sort((a, b) => b[1] - a[1]);
|
||||||
|
return (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="arrow-circle-down">
|
||||||
|
{t('storage.sources')}
|
||||||
|
</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.4rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entries.map(([source, count]) => (
|
||||||
|
<div
|
||||||
|
key={source}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--color-text-1)' }}>{source}</span>
|
||||||
|
<span style={{ color: 'var(--color-text-2)' }}>
|
||||||
|
{formatCount(count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopGenres({ stats }: { stats: StorageStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const max = Math.max(...stats.topGenres.map((g) => g.trackCount), 1);
|
||||||
|
return (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="vinyl-record">{t('storage.topGenres')}</SectionTitle>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats.topGenres.map((g) => {
|
||||||
|
// Scale chip emphasis by popularity for a tag-cloud feel.
|
||||||
|
const weight = 0.55 + (g.trackCount / max) * 0.45;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={g.genre}
|
||||||
|
style={{
|
||||||
|
padding: '0.3rem 0.7rem',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: `color-mix(in srgb, var(--color-accent) ${Math.round(
|
||||||
|
weight * 18,
|
||||||
|
)}%, transparent)`,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.genre}{' '}
|
||||||
|
<span style={{ color: 'var(--color-text-3)' }}>
|
||||||
|
{formatCount(g.trackCount)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunFacts({
|
||||||
|
stats,
|
||||||
|
avgSize,
|
||||||
|
}: {
|
||||||
|
stats: StorageStats;
|
||||||
|
avgSize: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const facts: string[] = [];
|
||||||
|
if (stats.totalDurationSeconds > 0)
|
||||||
|
facts.push(
|
||||||
|
t('storage.factPlaytime', {
|
||||||
|
duration: formatLongDuration(stats.totalDurationSeconds),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
facts.push(
|
||||||
|
t('storage.factFootprint', {
|
||||||
|
size: formatFileSize(stats.totalSize),
|
||||||
|
tracks: formatCount(stats.totalTracks),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (stats.topGenres[0])
|
||||||
|
facts.push(
|
||||||
|
t('storage.factGenre', {
|
||||||
|
genre: stats.topGenres[0].genre,
|
||||||
|
count: stats.topGenres[0].trackCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
facts.push(t('storage.factAvg', { size: formatFileSize(avgSize) }));
|
||||||
|
const since = formatDateTime(stats.earliestAdded);
|
||||||
|
if (since) facts.push(t('storage.factSince', { date: since }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="info">{t('storage.funFacts')}</SectionTitle>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: '0.75rem 0 0',
|
||||||
|
paddingLeft: '1.1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.4rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{facts.map((f) => (
|
||||||
|
<li key={f}>{f}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- small shared bits --------------------------------------------------------
|
||||||
|
|
||||||
|
function StatTile({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: IconName;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.35rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-accent)',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={icon} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-2)' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: IconName;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--color-accent)' }}>
|
||||||
|
<Icon name={icon} />
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot({ color }: { color: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: color,
|
||||||
|
marginRight: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
|
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
|
||||||
@@ -6,6 +6,23 @@ import {
|
|||||||
buildUploadFormData,
|
buildUploadFormData,
|
||||||
useUploadTrackMutation,
|
useUploadTrackMutation,
|
||||||
} from '../../api/endpoints/upload';
|
} from '../../api/endpoints/upload';
|
||||||
|
import {
|
||||||
|
useGetTrackQuery,
|
||||||
|
useGetTracksQuery,
|
||||||
|
} from '../../api/endpoints/library';
|
||||||
|
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
|
||||||
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
|
||||||
|
/** A8 "Recently uploaded": server-backed list (source=upload, newest first) so
|
||||||
|
* it survives a page refresh — unlike the transient client-side queue above. */
|
||||||
|
const RECENT_UPLOADS = {
|
||||||
|
source: 'upload',
|
||||||
|
sortBy: 'dateAdded',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
pageSize: 20,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Pure client-side state — this is a transient upload queue, never server data. */
|
/** Pure client-side state — this is a transient upload queue, never server data. */
|
||||||
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
||||||
@@ -23,7 +40,10 @@ const MAX_CONCURRENCY = 3;
|
|||||||
|
|
||||||
function extractError(err: unknown): string {
|
function extractError(err: unknown): string {
|
||||||
if (typeof err === 'object' && err !== null) {
|
if (typeof err === 'object' && err !== null) {
|
||||||
const e = err as { data?: { message?: string; detail?: string }; error?: string };
|
const e = err as {
|
||||||
|
data?: { message?: string; detail?: string };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
|
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
|
||||||
}
|
}
|
||||||
return 'Upload failed';
|
return 'Upload failed';
|
||||||
@@ -38,18 +58,27 @@ export function UploadPage() {
|
|||||||
const [items, setItems] = useState<QueueItem[]>([]);
|
const [items, setItems] = useState<QueueItem[]>([]);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
// Persisted view of past uploads. Auto-refreshes after each upload because the
|
||||||
|
// upload mutation invalidates the `Track` tag this query provides.
|
||||||
|
const recentQuery = useGetTracksQuery(RECENT_UPLOADS);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const idCounter = useRef(0);
|
const idCounter = useRef(0);
|
||||||
const activeCount = useRef(0);
|
const activeCount = useRef(0);
|
||||||
const pending = useRef<QueueItem[]>([]);
|
const pending = useRef<QueueItem[]>([]);
|
||||||
|
|
||||||
const patchItem = (id: string, patch: Partial<QueueItem>) =>
|
const patchItem = (id: string, patch: Partial<QueueItem>) =>
|
||||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)));
|
setItems((prev) =>
|
||||||
|
prev.map((it) => (it.id === id ? { ...it, ...patch } : it)),
|
||||||
|
);
|
||||||
|
|
||||||
// Ref-based concurrency pump: refs (not state) so it is safe to call from
|
// Ref-based concurrency pump: refs (not state) so it is safe to call from
|
||||||
// async callbacks without stale closures over the queue.
|
// async callbacks without stale closures over the queue.
|
||||||
const pump = () => {
|
const pump = () => {
|
||||||
while (activeCount.current < MAX_CONCURRENCY && pending.current.length > 0) {
|
while (
|
||||||
|
activeCount.current < MAX_CONCURRENCY &&
|
||||||
|
pending.current.length > 0
|
||||||
|
) {
|
||||||
const item = pending.current.shift()!;
|
const item = pending.current.shift()!;
|
||||||
activeCount.current += 1;
|
activeCount.current += 1;
|
||||||
patchItem(item.id, { status: 'uploading', error: undefined });
|
patchItem(item.id, { status: 'uploading', error: undefined });
|
||||||
@@ -161,7 +190,9 @@ export function UploadPage() {
|
|||||||
>
|
>
|
||||||
<div style={{ fontSize: '2rem' }}>⬆</div>
|
<div style={{ fontSize: '2rem' }}>⬆</div>
|
||||||
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
|
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
|
||||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
<div
|
||||||
|
style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}
|
||||||
|
>
|
||||||
{t('upload.dropzone.hint')}
|
{t('upload.dropzone.hint')}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" size="sm" type="button">
|
<Button variant="primary" size="sm" type="button">
|
||||||
@@ -181,7 +212,11 @@ export function UploadPage() {
|
|||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -226,6 +261,32 @@ export function UploadPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>
|
||||||
|
{t('upload.recent.title')}
|
||||||
|
</span>
|
||||||
|
{recentQuery.isLoading && <LoadingSkeleton rows={4} />}
|
||||||
|
{recentQuery.isError && (
|
||||||
|
<ErrorState onRetry={() => recentQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{recentQuery.data && recentQuery.data.items.length === 0 && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('upload.recent.empty')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{recentQuery.data?.items.map((track, i) => (
|
||||||
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,11 +334,7 @@ function UploadRow({
|
|||||||
{item.error}
|
{item.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{done && (
|
{done && item.trackId && <EnrichmentStatus trackId={item.trackId} />}
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
|
||||||
{t('upload.unknownArtist')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusBadge status={item.status} />
|
<StatusBadge status={item.status} />
|
||||||
@@ -301,6 +358,59 @@ function UploadRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls a just-uploaded track until enrichment settles, then shows the outcome.
|
||||||
|
* Metadata enrichment runs asynchronously in a worker after the upload response
|
||||||
|
* returns, so without polling the row would never reflect the resolved title/
|
||||||
|
* artist or a failure reason. Polling stops (interval → 0) once the status
|
||||||
|
* leaves `pending`.
|
||||||
|
*/
|
||||||
|
function EnrichmentStatus({ trackId }: { trackId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [pollMs, setPollMs] = useState(2500);
|
||||||
|
const { data } = useGetTrackQuery(trackId, { pollingInterval: pollMs });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && data.metadataStatus !== 'pending') setPollMs(0);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const status = data?.metadataStatus ?? 'pending';
|
||||||
|
const resolved =
|
||||||
|
data && data.metadataStatus === 'enriched'
|
||||||
|
? `${data.artistName} · ${data.title}`
|
||||||
|
: t(`metadata.statusHint.${status}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginTop: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MetadataStatusBadge
|
||||||
|
status={status}
|
||||||
|
error={data?.metadataError}
|
||||||
|
hideWhenEnriched={false}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'failed' && data?.metadataError
|
||||||
|
? data.metadataError
|
||||||
|
: resolved}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: ItemStatus }) {
|
function StatusBadge({ status }: { status: ItemStatus }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (status === 'uploading') {
|
if (status === 'uploading') {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getAudioCacheStats, type AudioCacheStats } from '../lib/sw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the service-worker audio offline cache stats (Tier 3 — the audio
|
||||||
|
* actually stored on *this device*). Returns `null` until resolved, or when no
|
||||||
|
* controlling service worker is present (insecure origin, first load, …).
|
||||||
|
* `bump` forces a re-read after the cache is mutated (e.g. cleared).
|
||||||
|
*/
|
||||||
|
export function useAudioCacheStats(bump = 0): AudioCacheStats | null {
|
||||||
|
const [stats, setStats] = useState<AudioCacheStats | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void getAudioCacheStats().then((s) => {
|
||||||
|
if (!cancelled) setStats(s);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [bump]);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ export function useAudioPlayer() {
|
|||||||
const queue = useAppSelector((s) => s.queue);
|
const queue = useAppSelector((s) => s.queue);
|
||||||
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
||||||
const isSetup = useRef(false);
|
const isSetup = useRef(false);
|
||||||
|
// `ended` is registered once below; read the latest loop flag through a ref
|
||||||
|
// so the listener doesn't need to be re-bound on every queue change.
|
||||||
|
const loopRef = useRef(queue.loop);
|
||||||
|
loopRef.current = queue.loop;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSetup.current) return;
|
if (isSetup.current) return;
|
||||||
@@ -41,7 +45,12 @@ export function useAudioPlayer() {
|
|||||||
dispatch(setDuration(audio.duration || 0));
|
dispatch(setDuration(audio.duration || 0));
|
||||||
});
|
});
|
||||||
audio.addEventListener('ended', () => {
|
audio.addEventListener('ended', () => {
|
||||||
dispatch(nextTrack());
|
if (loopRef.current) {
|
||||||
|
audio.currentTime = 0;
|
||||||
|
void audio.play();
|
||||||
|
} else {
|
||||||
|
dispatch(nextTrack());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
audio.addEventListener('pause', () => {
|
audio.addEventListener('pause', () => {
|
||||||
dispatch(pause());
|
dispatch(pause());
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getApiBaseUrl } from '../config/runtime-config';
|
import { getApiBaseUrl } from '../config/runtime-config';
|
||||||
|
import { useAppDispatch, useAppSelector } from './useAppDispatch';
|
||||||
|
import {
|
||||||
|
setConnectionStatus,
|
||||||
|
type ConnectionStatus,
|
||||||
|
} from '../store/slices/connection';
|
||||||
|
|
||||||
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
export type { ConnectionStatus };
|
||||||
|
|
||||||
export function useConnectionStatus() {
|
/** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */
|
||||||
|
export function useConnectionStatus(baseUrl?: string) {
|
||||||
|
const url = baseUrl ?? getApiBaseUrl();
|
||||||
const [status, setStatus] = useState<ConnectionStatus>('connecting');
|
const [status, setStatus] = useState<ConnectionStatus>('connecting');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -13,7 +20,7 @@ export function useConnectionStatus() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setStatus('connecting');
|
setStatus('connecting');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getApiBaseUrl()}/health`, {
|
const res = await fetch(`${url}/health`, {
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
|
if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
|
||||||
@@ -30,7 +37,30 @@ export function useConnectionStatus() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [url]);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `useConnectionStatus`, but also mirrors the result into the
|
||||||
|
* `connection` slice so other components can read the active instance's
|
||||||
|
* reachability via `useIsOffline` without running their own poller.
|
||||||
|
* Mount once (in `Sidebar`, which lives for the app's whole lifetime).
|
||||||
|
*/
|
||||||
|
export function useConnectionStatusSync(): ConnectionStatus {
|
||||||
|
const status = useConnectionStatus();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(setConnectionStatus(status));
|
||||||
|
}, [status, dispatch]);
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the active backend instance is currently unreachable. */
|
||||||
|
export function useIsOffline(): boolean {
|
||||||
|
const status = useAppSelector((s) => s.connection.status);
|
||||||
|
return status === 'disconnected' || status === 'error';
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { skipToken } from '@reduxjs/toolkit/query';
|
||||||
|
import { useGetTrackQuery } from '../api/endpoints/library';
|
||||||
|
import type { QueueEntry } from '../store/slices/queue';
|
||||||
|
|
||||||
|
export interface ResolvedQueueEntry {
|
||||||
|
trackId: string;
|
||||||
|
title: string;
|
||||||
|
artistName: string;
|
||||||
|
albumTitle: string;
|
||||||
|
durationMs: number;
|
||||||
|
hasCover: boolean;
|
||||||
|
albumArtUrl?: string;
|
||||||
|
format?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a queue entry's play-time snapshot with the live `Track` cache.
|
||||||
|
*
|
||||||
|
* The queue slice stores denormalized display fields (title/artist/…) captured
|
||||||
|
* when a track was queued, so they go stale after metadata enrichment updates
|
||||||
|
* the track. This reads through to the RTKQ `Track` cache — invalidated by the
|
||||||
|
* same tags that refresh the library — and prefers its fresh values, falling
|
||||||
|
* back to the snapshot for instant render and offline. Returns undefined when
|
||||||
|
* there is no current entry.
|
||||||
|
*/
|
||||||
|
export function useResolvedQueueEntry(
|
||||||
|
entry: QueueEntry | undefined,
|
||||||
|
): ResolvedQueueEntry | undefined {
|
||||||
|
const { data } = useGetTrackQuery(entry?.trackId ?? skipToken);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
return {
|
||||||
|
trackId: entry.trackId,
|
||||||
|
title: data?.title ?? entry.title,
|
||||||
|
artistName: data?.artistName ?? entry.artistName,
|
||||||
|
albumTitle: data?.albumTitle ?? entry.albumTitle,
|
||||||
|
durationMs: data?.durationMs ?? entry.durationMs,
|
||||||
|
hasCover: data?.hasCover ?? false,
|
||||||
|
albumArtUrl: data?.albumArtUrl ?? entry.albumArtUrl,
|
||||||
|
format: data?.format,
|
||||||
|
};
|
||||||
|
}
|
||||||
+256
-17
@@ -25,22 +25,46 @@ const en = {
|
|||||||
signOut: 'Sign out',
|
signOut: 'Sign out',
|
||||||
},
|
},
|
||||||
connect: {
|
connect: {
|
||||||
savedInstances: 'Saved instances',
|
domains: {
|
||||||
active: 'active',
|
title: 'Saved instances',
|
||||||
use: 'Use',
|
addPlaceholder: 'https://your-server.example.com',
|
||||||
forgetTitle: 'Forget this instance',
|
addButton: 'Add instance',
|
||||||
form: {
|
selected: 'Selected',
|
||||||
title: 'Connect to a backend',
|
use: 'Use',
|
||||||
serverUrl: 'Server URL',
|
forgetTitle: 'Remove this instance',
|
||||||
|
},
|
||||||
|
removeDialog: {
|
||||||
|
title: 'Remove cached data?',
|
||||||
|
description:
|
||||||
|
'This removes "{{name}}" from your saved instances and clears its cached data on this device.',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
logout: 'Just log out',
|
||||||
|
removeAndLogout: 'Remove data & log out',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Log in to {{name}}',
|
||||||
|
registerTitle: 'Sign up for {{name}}',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
submit: 'Connect',
|
passwordHint: 'At least 8 characters.',
|
||||||
submitting: 'Connecting…',
|
submit: 'Log in',
|
||||||
|
submitting: 'Logging in…',
|
||||||
|
registerSubmit: 'Sign up',
|
||||||
|
registering: 'Signing up…',
|
||||||
|
noAccount: "Don't have an account?",
|
||||||
|
registerLink: 'Sign up',
|
||||||
|
haveAccount: 'Already have an account?',
|
||||||
|
signInLink: 'Log in',
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
unreachable: "Can't reach this server. Check the URL and that it's online.",
|
unreachable:
|
||||||
|
"Can't reach this server. Check the URL and that it's online.",
|
||||||
badCredentials: 'Incorrect username or password.',
|
badCredentials: 'Incorrect username or password.',
|
||||||
generic: 'Sign-in failed. Please try again.',
|
generic: 'Sign-in failed. Please try again.',
|
||||||
|
usernameTaken: 'That username is already taken.',
|
||||||
|
passwordTooShort: 'Password must be at least 8 characters.',
|
||||||
|
registrationDisabled: 'Registration is disabled on this server.',
|
||||||
|
registerFailed: 'Could not create the account. Please try again.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
library: {
|
library: {
|
||||||
@@ -73,6 +97,13 @@ const en = {
|
|||||||
artistRow: {
|
artistRow: {
|
||||||
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
banner:
|
||||||
|
"You're offline — showing the library available locally. It may be incomplete and is read-only until the server is back.",
|
||||||
|
emptyTitle: 'Nothing available offline',
|
||||||
|
emptyDesc:
|
||||||
|
'No library data is cached on this device yet. Connect to the server once to browse offline.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
album: {
|
album: {
|
||||||
type: 'Album',
|
type: 'Album',
|
||||||
@@ -83,6 +114,28 @@ const en = {
|
|||||||
title: 'No tracks',
|
title: 'No tracks',
|
||||||
description: 'This album has no tracks.',
|
description: 'This album has no tracks.',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Album not available offline',
|
||||||
|
description: "You're offline and this album isn't cached on this device.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artist: {
|
||||||
|
type: 'Artist',
|
||||||
|
play: '▶ Play all',
|
||||||
|
error: 'Failed to load artist',
|
||||||
|
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
||||||
|
albums: 'Albums',
|
||||||
|
tracks: 'Tracks',
|
||||||
|
noAlbums: 'No albums yet.',
|
||||||
|
empty: {
|
||||||
|
title: 'No tracks',
|
||||||
|
description: 'This artist has no tracks.',
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Artist not available offline',
|
||||||
|
description:
|
||||||
|
"You're offline and this artist isn't cached on this device.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
type: 'Playlist',
|
type: 'Playlist',
|
||||||
@@ -96,27 +149,25 @@ const en = {
|
|||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
nothingPlaying: 'Nothing playing',
|
nothingPlaying: 'Nothing playing',
|
||||||
shuffle: 'Shuffle',
|
|
||||||
previous: 'Previous',
|
previous: 'Previous',
|
||||||
next: 'Next',
|
next: 'Next',
|
||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
play: 'Play',
|
play: 'Play',
|
||||||
repeat: 'Repeat: {{mode}}',
|
streaming: 'Streaming',
|
||||||
streaming: 'Streaming · 320 kbps',
|
local: 'Local',
|
||||||
local: 'Local · FLAC',
|
|
||||||
queue: 'Play queue',
|
queue: 'Play queue',
|
||||||
mute: 'Mute',
|
mute: 'Mute',
|
||||||
unmute: 'Unmute',
|
unmute: 'Unmute',
|
||||||
},
|
},
|
||||||
queue: {
|
queue: {
|
||||||
title: 'Play queue',
|
title: 'Play queue',
|
||||||
|
shuffle: 'Shuffle queue',
|
||||||
|
loop: 'Repeat current track',
|
||||||
clear: 'Clear queue',
|
clear: 'Clear queue',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
from: 'From {{source}}',
|
from: 'From {{source}}',
|
||||||
radio: 'Radio · {{source}}',
|
radio: 'Radio · {{source}}',
|
||||||
nowPlaying: 'Now playing',
|
|
||||||
nextUp: 'Next up',
|
nextUp: 'Next up',
|
||||||
nothingNext: 'Nothing queued next',
|
|
||||||
empty: 'Queue is empty',
|
empty: 'Queue is empty',
|
||||||
radioActive: 'Radio active',
|
radioActive: 'Radio active',
|
||||||
mixing: '∞ mixing',
|
mixing: '∞ mixing',
|
||||||
@@ -125,6 +176,13 @@ const en = {
|
|||||||
loadingMore: 'Loading more from radio…',
|
loadingMore: 'Loading more from radio…',
|
||||||
doubleClickPlay: 'Double-click to play',
|
doubleClickPlay: 'Double-click to play',
|
||||||
removeFromQueue: 'Remove from queue',
|
removeFromQueue: 'Remove from queue',
|
||||||
|
menu: {
|
||||||
|
options: 'Track options',
|
||||||
|
playNow: 'Play now',
|
||||||
|
moveNext: 'Move next',
|
||||||
|
info: 'Track info',
|
||||||
|
remove: 'Remove from queue',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
track: {
|
track: {
|
||||||
menu: {
|
menu: {
|
||||||
@@ -132,17 +190,103 @@ const en = {
|
|||||||
playNow: 'Play now',
|
playNow: 'Play now',
|
||||||
playNext: 'Play next',
|
playNext: 'Play next',
|
||||||
addToQueue: 'Add to queue',
|
addToQueue: 'Add to queue',
|
||||||
|
info: 'Track info',
|
||||||
addToPlaylist: 'Add to playlist…',
|
addToPlaylist: 'Add to playlist…',
|
||||||
editMetadata: 'Edit metadata',
|
editMetadata: 'Edit metadata',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
trackInfo: {
|
||||||
|
title: 'Track info',
|
||||||
|
open: 'View track info',
|
||||||
|
close: 'Close',
|
||||||
|
notFound: 'Track not found',
|
||||||
|
play: 'Play',
|
||||||
|
addToQueue: 'Queue',
|
||||||
|
editMetadata: 'Edit metadata',
|
||||||
|
liked: 'Liked',
|
||||||
|
trackOf: 'No. {{n}} of {{total}}',
|
||||||
|
kbps: '{{n}} kbps',
|
||||||
|
sections: {
|
||||||
|
status: 'Status',
|
||||||
|
general: 'General',
|
||||||
|
file: 'File',
|
||||||
|
identifiers: 'Identifiers',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
artist: 'Artist',
|
||||||
|
album: 'Album',
|
||||||
|
trackNumber: 'Track',
|
||||||
|
disc: 'Disc',
|
||||||
|
year: 'Year',
|
||||||
|
genre: 'Genre',
|
||||||
|
duration: 'Duration',
|
||||||
|
format: 'Format',
|
||||||
|
bitrate: 'Bitrate',
|
||||||
|
size: 'Size',
|
||||||
|
source: 'Source',
|
||||||
|
added: 'Added',
|
||||||
|
enriched: 'Enriched',
|
||||||
|
trackId: 'Track ID',
|
||||||
|
albumId: 'Album ID',
|
||||||
|
artistId: 'Artist ID',
|
||||||
|
},
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
error: 'Something went wrong',
|
error: 'Something went wrong',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
comingSoon: 'Coming soon',
|
comingSoon: 'Coming soon',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
|
offlineBanner:
|
||||||
|
"You're offline — showing locally available data, read-only.",
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
subtitle: 'Everything this instance has tucked away',
|
||||||
|
device: 'On this device',
|
||||||
|
server: 'On the server',
|
||||||
|
audioCache: 'Cached audio',
|
||||||
|
audioCacheUsage: '{{used}} of {{max}} used',
|
||||||
|
cachedTracks: '{{n}} tracks cached for offline',
|
||||||
|
audioCacheUnavailable:
|
||||||
|
'Offline audio cache unavailable (service worker not active).',
|
||||||
|
offlineLibrary: 'Offline library',
|
||||||
|
offlineLibraryMeta:
|
||||||
|
'{{tracks}} tracks · {{albums}} albums · {{artists}} artists browsable offline',
|
||||||
|
serverUnreachable: 'Server unreachable — showing this device only.',
|
||||||
|
emptyTitle: 'Nothing stored yet',
|
||||||
|
emptyDesc:
|
||||||
|
'Download or upload some music and your library stats will appear here.',
|
||||||
|
disk: 'Disk',
|
||||||
|
diskUsage: '{{used}} of {{total}} used',
|
||||||
|
diskFree: '{{free}} free',
|
||||||
|
diskLibraryShare: 'This library is {{percent}}% of the whole disk',
|
||||||
|
diskUnknown: 'Object storage — no fixed disk to report',
|
||||||
|
footprint: 'Library footprint',
|
||||||
|
tracks: 'Tracks',
|
||||||
|
artists: 'Artists',
|
||||||
|
albums: 'Albums',
|
||||||
|
playtime: 'Total playtime',
|
||||||
|
avgTrackSize: 'Avg. track size',
|
||||||
|
largestTrack: 'Largest track',
|
||||||
|
formats: 'Formats',
|
||||||
|
sources: 'Where it came from',
|
||||||
|
metadataHealth: 'Metadata health',
|
||||||
|
topGenres: 'Top genres',
|
||||||
|
noGenres: 'No genres tagged yet',
|
||||||
|
funFacts: 'Fun facts',
|
||||||
|
factPlaytime:
|
||||||
|
'Hit play and walk away — this library runs for {{duration}} non-stop.',
|
||||||
|
factFootprint: '{{size}} of music across {{tracks}} tracks.',
|
||||||
|
factGenre: 'Your most-tagged genre is {{genre}} ({{count}} tracks).',
|
||||||
|
factAvg: 'The average track weighs in at {{size}}.',
|
||||||
|
factSince: 'Collecting since {{date}}.',
|
||||||
|
status: {
|
||||||
|
enriched: 'Enriched',
|
||||||
|
manual: 'Manual',
|
||||||
|
pending: 'Pending',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
@@ -195,6 +339,10 @@ const en = {
|
|||||||
clearCompleted: 'Clear completed',
|
clearCompleted: 'Clear completed',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
editMetadata: 'Edit metadata',
|
editMetadata: 'Edit metadata',
|
||||||
|
recent: {
|
||||||
|
title: 'Recently uploaded',
|
||||||
|
empty: 'Nothing uploaded yet.',
|
||||||
|
},
|
||||||
metadataPending:
|
metadataPending:
|
||||||
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
|
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
|
||||||
unknownArtist: 'Unknown Artist · metadata pending',
|
unknownArtist: 'Unknown Artist · metadata pending',
|
||||||
@@ -206,11 +354,102 @@ const en = {
|
|||||||
error: 'Failed',
|
error: 'Failed',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
discover: {
|
||||||
|
title: 'Search & download',
|
||||||
|
subtitle: 'Find music on connected sources and add it to your library.',
|
||||||
|
searchPlaceholder: 'Search for a song, artist, or album…',
|
||||||
|
searchButton: 'Search',
|
||||||
|
allSources: 'All sources',
|
||||||
|
noSources:
|
||||||
|
'No download sources are configured. Enable a source (e.g. YouTube Music) on the server to search and download.',
|
||||||
|
startTitle: 'Search to get started',
|
||||||
|
startDesc: 'Results from your connected sources will appear here.',
|
||||||
|
emptyTitle: 'No results',
|
||||||
|
emptyDesc: 'Try a different search term or another source.',
|
||||||
|
searchError: "Couldn't search right now. Try again.",
|
||||||
|
download: 'Download',
|
||||||
|
retryDownload: 'Retry',
|
||||||
|
queued: 'Queued',
|
||||||
|
inLibrary: 'In library',
|
||||||
|
viewDownloads: 'View downloads',
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
title: 'Downloads',
|
||||||
|
subtitle: 'Track downloads in progress, completed, and failed.',
|
||||||
|
activeCount: '{{count}} active',
|
||||||
|
sectionActive: 'In progress',
|
||||||
|
sectionHistory: 'History',
|
||||||
|
emptyTitle: 'No downloads yet',
|
||||||
|
emptyDesc: 'Find music in Search & download to queue it here.',
|
||||||
|
loadError: "Couldn't load downloads.",
|
||||||
|
failedBanner:
|
||||||
|
'{{count}} download(s) failed. Retry them, or check the source on the server.',
|
||||||
|
attempt: 'Attempt {{count}}',
|
||||||
|
open: 'Open',
|
||||||
|
retry: 'Retry',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
status: {
|
||||||
|
queued: 'Queued',
|
||||||
|
downloading: 'Downloading',
|
||||||
|
enriching: 'Enriching',
|
||||||
|
done: 'Done',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
status: {
|
||||||
|
pending: 'Enriching…',
|
||||||
|
enriched: 'Enriched',
|
||||||
|
failed: 'No match',
|
||||||
|
manual: 'Manual',
|
||||||
|
},
|
||||||
|
statusHint: {
|
||||||
|
pending: 'Identifying metadata…',
|
||||||
|
enriched: 'Metadata identified',
|
||||||
|
failed: 'Metadata could not be identified',
|
||||||
|
manual: 'Edited manually — not auto-updated',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadataEditor: {
|
||||||
|
error: 'Failed to load track',
|
||||||
|
saved: 'Metadata saved.',
|
||||||
|
saveError: 'Failed to save metadata.',
|
||||||
|
save: 'Save',
|
||||||
|
fields: {
|
||||||
|
title: 'Title',
|
||||||
|
artist: 'Artist',
|
||||||
|
album: 'Album',
|
||||||
|
year: 'Year',
|
||||||
|
genre: 'Genre',
|
||||||
|
trackNumber: 'Track number',
|
||||||
|
},
|
||||||
|
autoEnrich: {
|
||||||
|
title: 'AcoustID lookup',
|
||||||
|
hint: 'Identify this track by audio fingerprint.',
|
||||||
|
findMatches: 'Find matches',
|
||||||
|
reEnrich: 'Re-run enrichment',
|
||||||
|
enqueued: 'Enrichment queued — refresh in a moment.',
|
||||||
|
error: 'Could not look up matches.',
|
||||||
|
noMatches: 'No matches found.',
|
||||||
|
},
|
||||||
|
matches: {
|
||||||
|
use: 'Use',
|
||||||
|
unknownTitle: 'Unknown title',
|
||||||
|
},
|
||||||
|
diff: {
|
||||||
|
title: 'Apply this match?',
|
||||||
|
noChanges: 'No changes from current values.',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
apply: 'Apply',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|
||||||
type DeepString<T> = {
|
type DeepString<T> = {
|
||||||
[K in keyof T]: T[K] extends Record<string, unknown> ? DeepString<T[K]> : string;
|
[K in keyof T]: T[K] extends Record<string, unknown>
|
||||||
|
? DeepString<T[K]>
|
||||||
|
: string;
|
||||||
};
|
};
|
||||||
export type Translations = DeepString<typeof en>;
|
export type Translations = DeepString<typeof en>;
|
||||||
|
|||||||
+251
-15
@@ -27,23 +27,46 @@ const ru: Translations = {
|
|||||||
signOut: 'Выйти',
|
signOut: 'Выйти',
|
||||||
},
|
},
|
||||||
connect: {
|
connect: {
|
||||||
savedInstances: 'Сохранённые серверы',
|
domains: {
|
||||||
active: 'активный',
|
title: 'Сохранённые серверы',
|
||||||
use: 'Выбрать',
|
addPlaceholder: 'https://your-server.example.com',
|
||||||
forgetTitle: 'Забыть этот сервер',
|
addButton: 'Добавить сервер',
|
||||||
form: {
|
selected: 'Выбран',
|
||||||
title: 'Подключиться к серверу',
|
use: 'Выбрать',
|
||||||
serverUrl: 'URL сервера',
|
forgetTitle: 'Удалить этот сервер',
|
||||||
|
},
|
||||||
|
removeDialog: {
|
||||||
|
title: 'Удалить локальные данные?',
|
||||||
|
description:
|
||||||
|
'Сервер «{{name}}» будет удалён из сохранённых, а его данные на этом устройстве — очищены.',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
logout: 'Просто выйти',
|
||||||
|
removeAndLogout: 'Удалить данные и выйти',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Вход в {{name}}',
|
||||||
|
registerTitle: 'Регистрация на {{name}}',
|
||||||
username: 'Имя пользователя',
|
username: 'Имя пользователя',
|
||||||
password: 'Пароль',
|
password: 'Пароль',
|
||||||
submit: 'Подключиться',
|
passwordHint: 'Не менее 8 символов.',
|
||||||
submitting: 'Подключение…',
|
submit: 'Войти',
|
||||||
|
submitting: 'Вход…',
|
||||||
|
registerSubmit: 'Зарегистрироваться',
|
||||||
|
registering: 'Регистрация…',
|
||||||
|
noAccount: 'Нет аккаунта?',
|
||||||
|
registerLink: 'Зарегистрироваться',
|
||||||
|
haveAccount: 'Уже есть аккаунт?',
|
||||||
|
signInLink: 'Войти',
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
unreachable:
|
unreachable:
|
||||||
'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
|
'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
|
||||||
badCredentials: 'Неверное имя пользователя или пароль.',
|
badCredentials: 'Неверное имя пользователя или пароль.',
|
||||||
generic: 'Не удалось войти. Попробуйте ещё раз.',
|
generic: 'Не удалось войти. Попробуйте ещё раз.',
|
||||||
|
usernameTaken: 'Это имя пользователя уже занято.',
|
||||||
|
passwordTooShort: 'Пароль должен содержать не менее 8 символов.',
|
||||||
|
registrationDisabled: 'Регистрация на этом сервере отключена.',
|
||||||
|
registerFailed: 'Не удалось создать аккаунт. Попробуйте ещё раз.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
library: {
|
library: {
|
||||||
@@ -76,6 +99,13 @@ const ru: Translations = {
|
|||||||
artistRow: {
|
artistRow: {
|
||||||
meta: '{{albumCount}} альб. · {{trackCount}} треков',
|
meta: '{{albumCount}} альб. · {{trackCount}} треков',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
banner:
|
||||||
|
'Нет связи с сервером — показана локально доступная библиотека. Она может быть неполной и доступна только для чтения, пока сервер недоступен.',
|
||||||
|
emptyTitle: 'Офлайн ничего нет',
|
||||||
|
emptyDesc:
|
||||||
|
'На этом устройстве ещё нет кэша библиотеки. Подключитесь к серверу хотя бы раз, чтобы просматривать офлайн.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
album: {
|
album: {
|
||||||
type: 'Альбом',
|
type: 'Альбом',
|
||||||
@@ -86,6 +116,27 @@ const ru: Translations = {
|
|||||||
title: 'Нет треков',
|
title: 'Нет треков',
|
||||||
description: 'В этом альбоме нет треков.',
|
description: 'В этом альбоме нет треков.',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Альбом недоступен офлайн',
|
||||||
|
description: 'Нет связи, а этот альбом не сохранён на устройстве.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artist: {
|
||||||
|
type: 'Исполнитель',
|
||||||
|
play: '▶ Слушать всё',
|
||||||
|
error: 'Не удалось загрузить исполнителя',
|
||||||
|
meta: '{{albumCount}} альбомов · {{trackCount}} треков',
|
||||||
|
albums: 'Альбомы',
|
||||||
|
tracks: 'Треки',
|
||||||
|
noAlbums: 'Пока нет альбомов.',
|
||||||
|
empty: {
|
||||||
|
title: 'Нет треков',
|
||||||
|
description: 'У этого исполнителя нет треков.',
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Исполнитель недоступен офлайн',
|
||||||
|
description: 'Нет связи, а этот исполнитель не сохранён на устройстве.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
type: 'Плейлист',
|
type: 'Плейлист',
|
||||||
@@ -99,27 +150,25 @@ const ru: Translations = {
|
|||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
nothingPlaying: 'Ничего не играет',
|
nothingPlaying: 'Ничего не играет',
|
||||||
shuffle: 'Перемешать',
|
|
||||||
previous: 'Назад',
|
previous: 'Назад',
|
||||||
next: 'Вперёд',
|
next: 'Вперёд',
|
||||||
pause: 'Пауза',
|
pause: 'Пауза',
|
||||||
play: 'Воспроизвести',
|
play: 'Воспроизвести',
|
||||||
repeat: 'Повтор: {{mode}}',
|
streaming: 'Стриминг',
|
||||||
streaming: 'Стриминг · 320 kbps',
|
local: 'Локально',
|
||||||
local: 'Локально · FLAC',
|
|
||||||
queue: 'Очередь',
|
queue: 'Очередь',
|
||||||
mute: 'Выключить звук',
|
mute: 'Выключить звук',
|
||||||
unmute: 'Включить звук',
|
unmute: 'Включить звук',
|
||||||
},
|
},
|
||||||
queue: {
|
queue: {
|
||||||
title: 'Очередь воспроизведения',
|
title: 'Очередь воспроизведения',
|
||||||
|
shuffle: 'Перемешать очередь',
|
||||||
|
loop: 'Повторять текущий трек',
|
||||||
clear: 'Очистить очередь',
|
clear: 'Очистить очередь',
|
||||||
close: 'Закрыть',
|
close: 'Закрыть',
|
||||||
from: 'Из: {{source}}',
|
from: 'Из: {{source}}',
|
||||||
radio: 'Радио · {{source}}',
|
radio: 'Радио · {{source}}',
|
||||||
nowPlaying: 'Сейчас играет',
|
|
||||||
nextUp: 'Далее',
|
nextUp: 'Далее',
|
||||||
nothingNext: 'Очередь пуста',
|
|
||||||
empty: 'Очередь пуста',
|
empty: 'Очередь пуста',
|
||||||
radioActive: 'Радио активно',
|
radioActive: 'Радио активно',
|
||||||
mixing: '∞ микс',
|
mixing: '∞ микс',
|
||||||
@@ -128,6 +177,13 @@ const ru: Translations = {
|
|||||||
loadingMore: 'Загрузка радио…',
|
loadingMore: 'Загрузка радио…',
|
||||||
doubleClickPlay: 'Двойной клик для воспроизведения',
|
doubleClickPlay: 'Двойной клик для воспроизведения',
|
||||||
removeFromQueue: 'Убрать из очереди',
|
removeFromQueue: 'Убрать из очереди',
|
||||||
|
menu: {
|
||||||
|
options: 'Параметры трека',
|
||||||
|
playNow: 'Воспроизвести сейчас',
|
||||||
|
moveNext: 'Сделать следующим',
|
||||||
|
info: 'Информация о треке',
|
||||||
|
remove: 'Убрать из очереди',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
track: {
|
track: {
|
||||||
menu: {
|
menu: {
|
||||||
@@ -135,17 +191,103 @@ const ru: Translations = {
|
|||||||
playNow: 'Играть сейчас',
|
playNow: 'Играть сейчас',
|
||||||
playNext: 'Следующим',
|
playNext: 'Следующим',
|
||||||
addToQueue: 'Добавить в очередь',
|
addToQueue: 'Добавить в очередь',
|
||||||
|
info: 'Информация о треке',
|
||||||
addToPlaylist: 'Добавить в плейлист…',
|
addToPlaylist: 'Добавить в плейлист…',
|
||||||
editMetadata: 'Редактировать метаданные',
|
editMetadata: 'Редактировать метаданные',
|
||||||
download: 'Скачать',
|
download: 'Скачать',
|
||||||
delete: 'Удалить',
|
delete: 'Удалить',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
trackInfo: {
|
||||||
|
title: 'О треке',
|
||||||
|
open: 'Информация о треке',
|
||||||
|
close: 'Закрыть',
|
||||||
|
notFound: 'Трек не найден',
|
||||||
|
play: 'Играть',
|
||||||
|
addToQueue: 'В очередь',
|
||||||
|
editMetadata: 'Метаданные',
|
||||||
|
liked: 'В избранном',
|
||||||
|
trackOf: '№ {{n}} из {{total}}',
|
||||||
|
kbps: '{{n}} кбит/с',
|
||||||
|
sections: {
|
||||||
|
status: 'Статус',
|
||||||
|
general: 'Основное',
|
||||||
|
file: 'Файл',
|
||||||
|
identifiers: 'Идентификаторы',
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
artist: 'Исполнитель',
|
||||||
|
album: 'Альбом',
|
||||||
|
trackNumber: 'Трек',
|
||||||
|
disc: 'Диск',
|
||||||
|
year: 'Год',
|
||||||
|
genre: 'Жанр',
|
||||||
|
duration: 'Длительность',
|
||||||
|
format: 'Формат',
|
||||||
|
bitrate: 'Битрейт',
|
||||||
|
size: 'Размер',
|
||||||
|
source: 'Источник',
|
||||||
|
added: 'Добавлен',
|
||||||
|
enriched: 'Обогащён',
|
||||||
|
trackId: 'ID трека',
|
||||||
|
albumId: 'ID альбома',
|
||||||
|
artistId: 'ID исполнителя',
|
||||||
|
},
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
error: 'Что-то пошло не так',
|
error: 'Что-то пошло не так',
|
||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
comingSoon: 'Скоро',
|
comingSoon: 'Скоро',
|
||||||
back: 'Назад',
|
back: 'Назад',
|
||||||
|
offlineBanner:
|
||||||
|
'Нет связи с сервером — показаны локально доступные данные, только для чтения.',
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
subtitle: 'Всё, что хранит этот инстанс',
|
||||||
|
device: 'На этом устройстве',
|
||||||
|
server: 'На сервере',
|
||||||
|
audioCache: 'Кэш аудио',
|
||||||
|
audioCacheUsage: 'Занято {{used}} из {{max}}',
|
||||||
|
cachedTracks: '{{n}} треков сохранено офлайн',
|
||||||
|
audioCacheUnavailable:
|
||||||
|
'Офлайн-кэш аудио недоступен (service worker не активен).',
|
||||||
|
offlineLibrary: 'Офлайн-библиотека',
|
||||||
|
offlineLibraryMeta:
|
||||||
|
'{{tracks}} треков · {{albums}} альбомов · {{artists}} исполнителей доступно офлайн',
|
||||||
|
serverUnreachable: 'Сервер недоступен — показано только это устройство.',
|
||||||
|
emptyTitle: 'Пока ничего не сохранено',
|
||||||
|
emptyDesc:
|
||||||
|
'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',
|
||||||
|
disk: 'Диск',
|
||||||
|
diskUsage: 'Занято {{used}} из {{total}}',
|
||||||
|
diskFree: 'Свободно {{free}}',
|
||||||
|
diskLibraryShare: 'Библиотека занимает {{percent}}% всего диска',
|
||||||
|
diskUnknown: 'Объектное хранилище — фиксированного диска нет',
|
||||||
|
footprint: 'Объём библиотеки',
|
||||||
|
tracks: 'Треки',
|
||||||
|
artists: 'Исполнители',
|
||||||
|
albums: 'Альбомы',
|
||||||
|
playtime: 'Общая длительность',
|
||||||
|
avgTrackSize: 'Средний размер трека',
|
||||||
|
largestTrack: 'Самый большой трек',
|
||||||
|
formats: 'Форматы',
|
||||||
|
sources: 'Откуда взято',
|
||||||
|
metadataHealth: 'Состояние метаданных',
|
||||||
|
topGenres: 'Топ жанров',
|
||||||
|
noGenres: 'Жанры пока не указаны',
|
||||||
|
funFacts: 'Интересные факты',
|
||||||
|
factPlaytime:
|
||||||
|
'Нажмите play и уходите — библиотека играет {{duration}} без остановки.',
|
||||||
|
factFootprint: '{{size}} музыки в {{tracks}} треках.',
|
||||||
|
factGenre: 'Чаще всего встречается жанр {{genre}} ({{count}} треков).',
|
||||||
|
factAvg: 'Средний трек весит {{size}}.',
|
||||||
|
factSince: 'Коллекция собирается с {{date}}.',
|
||||||
|
status: {
|
||||||
|
enriched: 'Обогащено',
|
||||||
|
manual: 'Вручную',
|
||||||
|
pending: 'В ожидании',
|
||||||
|
failed: 'Ошибка',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
admin: 'Администрирование',
|
admin: 'Администрирование',
|
||||||
@@ -198,6 +340,10 @@ const ru: Translations = {
|
|||||||
clearCompleted: 'Убрать завершённые',
|
clearCompleted: 'Убрать завершённые',
|
||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
editMetadata: 'Изменить метаданные',
|
editMetadata: 'Изменить метаданные',
|
||||||
|
recent: {
|
||||||
|
title: 'Недавно загруженные',
|
||||||
|
empty: 'Пока ничего не загружено.',
|
||||||
|
},
|
||||||
metadataPending:
|
metadataPending:
|
||||||
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
|
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
|
||||||
unknownArtist: 'Unknown Artist · метаданные в ожидании',
|
unknownArtist: 'Unknown Artist · метаданные в ожидании',
|
||||||
@@ -209,6 +355,96 @@ const ru: Translations = {
|
|||||||
error: 'Ошибка',
|
error: 'Ошибка',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
discover: {
|
||||||
|
title: 'Поиск и скачивание',
|
||||||
|
subtitle:
|
||||||
|
'Находите музыку в подключённых источниках и добавляйте в библиотеку.',
|
||||||
|
searchPlaceholder: 'Найти трек, исполнителя или альбом…',
|
||||||
|
searchButton: 'Найти',
|
||||||
|
allSources: 'Все источники',
|
||||||
|
noSources:
|
||||||
|
'Источники скачивания не настроены. Включите источник (например, YouTube Music) на сервере, чтобы искать и скачивать.',
|
||||||
|
startTitle: 'Начните с поиска',
|
||||||
|
startDesc: 'Здесь появятся результаты из подключённых источников.',
|
||||||
|
emptyTitle: 'Ничего не найдено',
|
||||||
|
emptyDesc: 'Попробуйте другой запрос или другой источник.',
|
||||||
|
searchError: 'Не удалось выполнить поиск. Попробуйте ещё раз.',
|
||||||
|
download: 'Скачать',
|
||||||
|
retryDownload: 'Повторить',
|
||||||
|
queued: 'В очереди',
|
||||||
|
inLibrary: 'В библиотеке',
|
||||||
|
viewDownloads: 'К загрузкам',
|
||||||
|
},
|
||||||
|
downloads: {
|
||||||
|
title: 'Загрузки',
|
||||||
|
subtitle: 'Активные, завершённые и неуспешные скачивания.',
|
||||||
|
activeCount: 'Активных: {{count}}',
|
||||||
|
sectionActive: 'В процессе',
|
||||||
|
sectionHistory: 'История',
|
||||||
|
emptyTitle: 'Пока нет загрузок',
|
||||||
|
emptyDesc: 'Найдите музыку в разделе «Поиск и скачивание».',
|
||||||
|
loadError: 'Не удалось загрузить список.',
|
||||||
|
failedBanner:
|
||||||
|
'Неуспешных скачиваний: {{count}}. Повторите их или проверьте источник на сервере.',
|
||||||
|
attempt: 'Попытка {{count}}',
|
||||||
|
open: 'Открыть',
|
||||||
|
retry: 'Повторить',
|
||||||
|
cancel: 'Отменить',
|
||||||
|
status: {
|
||||||
|
queued: 'В очереди',
|
||||||
|
downloading: 'Скачивание',
|
||||||
|
enriching: 'Обработка',
|
||||||
|
done: 'Готово',
|
||||||
|
failed: 'Ошибка',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
status: {
|
||||||
|
pending: 'Обработка…',
|
||||||
|
enriched: 'Готово',
|
||||||
|
failed: 'Нет совпадения',
|
||||||
|
manual: 'Вручную',
|
||||||
|
},
|
||||||
|
statusHint: {
|
||||||
|
pending: 'Определяем метаданные…',
|
||||||
|
enriched: 'Метаданные определены',
|
||||||
|
failed: 'Не удалось определить метаданные',
|
||||||
|
manual: 'Изменено вручную — не обновляется автоматически',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadataEditor: {
|
||||||
|
error: 'Не удалось загрузить трек',
|
||||||
|
saved: 'Метаданные сохранены.',
|
||||||
|
saveError: 'Не удалось сохранить метаданные.',
|
||||||
|
save: 'Сохранить',
|
||||||
|
fields: {
|
||||||
|
title: 'Название',
|
||||||
|
artist: 'Исполнитель',
|
||||||
|
album: 'Альбом',
|
||||||
|
year: 'Год',
|
||||||
|
genre: 'Жанр',
|
||||||
|
trackNumber: 'Номер трека',
|
||||||
|
},
|
||||||
|
autoEnrich: {
|
||||||
|
title: 'Поиск по AcoustID',
|
||||||
|
hint: 'Определить трек по аудио-отпечатку.',
|
||||||
|
findMatches: 'Найти совпадения',
|
||||||
|
reEnrich: 'Повторить обогащение',
|
||||||
|
enqueued: 'Обогащение запущено — обновите через момент.',
|
||||||
|
error: 'Не удалось найти совпадения.',
|
||||||
|
noMatches: 'Совпадений не найдено.',
|
||||||
|
},
|
||||||
|
matches: {
|
||||||
|
use: 'Использовать',
|
||||||
|
unknownTitle: 'Неизвестное название',
|
||||||
|
},
|
||||||
|
diff: {
|
||||||
|
title: 'Применить это совпадение?',
|
||||||
|
noChanges: 'Нет изменений относительно текущих значений.',
|
||||||
|
cancel: 'Отмена',
|
||||||
|
apply: 'Применить',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ru;
|
export default ru;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import './api/endpoints/auth';
|
|||||||
import './api/endpoints/library';
|
import './api/endpoints/library';
|
||||||
import './api/endpoints/playlists';
|
import './api/endpoints/playlists';
|
||||||
import './api/endpoints/downloads';
|
import './api/endpoints/downloads';
|
||||||
|
import './api/endpoints/search';
|
||||||
import './api/endpoints/likes';
|
import './api/endpoints/likes';
|
||||||
import './api/endpoints/storage';
|
import './api/endpoints/storage';
|
||||||
import './api/endpoints/admin';
|
import './api/endpoints/admin';
|
||||||
|
|||||||
@@ -16,6 +16,30 @@ export function formatFileSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string | undefined): string | undefined {
|
||||||
|
if (!iso) return undefined;
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return undefined;
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human "X days Y hours" style for big spans (e.g. total library playtime).
|
||||||
|
* Shows the two most-significant non-zero units; falls back to "0m". */
|
||||||
|
export function formatLongDuration(seconds: number): string {
|
||||||
|
if (seconds <= 0) return '0m';
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (days) parts.push(`${days}d`);
|
||||||
|
if (hours) parts.push(`${hours}h`);
|
||||||
|
if (mins && parts.length < 2) parts.push(`${mins}m`);
|
||||||
|
return parts.slice(0, 2).join(' ') || '0m';
|
||||||
|
}
|
||||||
|
|
||||||
export function formatCount(n: number): string {
|
export function formatCount(n: number): string {
|
||||||
if (n < 1000) return String(n);
|
if (n < 1000) return String(n);
|
||||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ const DownloadsManagerPage = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const UploadPage = lazy(() =>
|
const UploadPage = lazy(() =>
|
||||||
import('../features/upload/UploadPage').then((m) => ({ default: m.UploadPage })),
|
import('../features/upload/UploadPage').then((m) => ({
|
||||||
|
default: m.UploadPage,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
const MetadataEditorPage = lazy(() =>
|
const MetadataEditorPage = lazy(() =>
|
||||||
import('../features/metadata-editor/MetadataEditorPage').then((m) => ({
|
import('../features/metadata-editor/MetadataEditorPage').then((m) => ({
|
||||||
@@ -126,7 +128,10 @@ export function AppRoutes() {
|
|||||||
|
|
||||||
{/* Storage */}
|
{/* Storage */}
|
||||||
<Route path="/storage" element={<StoragePage />} />
|
<Route path="/storage" element={<StoragePage />} />
|
||||||
<Route path="/storage/maintenance" element={<StorageMaintenancePage />} />
|
<Route
|
||||||
|
path="/storage/maintenance"
|
||||||
|
element={<StorageMaintenancePage />}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Queue (narrow viewports) */}
|
{/* Queue (narrow viewports) */}
|
||||||
<Route path="/queue" element={<QueuePage />} />
|
<Route path="/queue" element={<QueuePage />} />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import { setupListeners } from '@reduxjs/toolkit/query';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import authReducer from './slices/auth';
|
import authReducer from './slices/auth';
|
||||||
|
import connectionReducer from './slices/connection';
|
||||||
import playerReducer from './slices/player';
|
import playerReducer from './slices/player';
|
||||||
import queueReducer from './slices/queue';
|
import queueReducer from './slices/queue';
|
||||||
import uiReducer from './slices/ui';
|
import uiReducer from './slices/ui';
|
||||||
@@ -11,6 +13,7 @@ export const store = configureStore({
|
|||||||
reducer: {
|
reducer: {
|
||||||
[api.reducerPath]: api.reducer,
|
[api.reducerPath]: api.reducer,
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
|
connection: connectionReducer,
|
||||||
player: playerReducer,
|
player: playerReducer,
|
||||||
queue: queueReducer,
|
queue: queueReducer,
|
||||||
ui: uiReducer,
|
ui: uiReducer,
|
||||||
@@ -25,6 +28,10 @@ export const store = configureStore({
|
|||||||
getDefaultMiddleware().concat(api.middleware),
|
getDefaultMiddleware().concat(api.middleware),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enable refetchOnReconnect / refetchOnFocus by dispatching the browser's
|
||||||
|
// online + focus events into RTKQ (no-op without the api flags set in api/index).
|
||||||
|
setupListeners(store.dispatch);
|
||||||
|
|
||||||
// Flush queue/player changes back to localStorage (throttled).
|
// Flush queue/player changes back to localStorage (throttled).
|
||||||
startPersistence(store);
|
startPersistence(store);
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -8,14 +8,8 @@
|
|||||||
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
|
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
|
||||||
*/
|
*/
|
||||||
import { instanceStorage } from '../config/instances';
|
import { instanceStorage } from '../config/instances';
|
||||||
import {
|
import { queueInitialState, type QueueState } from './slices/queue';
|
||||||
queueInitialState,
|
import { playerInitialState, type PlayerState } from './slices/player';
|
||||||
type QueueState,
|
|
||||||
} from './slices/queue';
|
|
||||||
import {
|
|
||||||
playerInitialState,
|
|
||||||
type PlayerState,
|
|
||||||
} from './slices/player';
|
|
||||||
import type { RootState } from './index';
|
import type { RootState } from './index';
|
||||||
|
|
||||||
const QUEUE_KEY = 'queue';
|
const QUEUE_KEY = 'queue';
|
||||||
@@ -26,11 +20,17 @@ const PLAYER_KEY = 'player';
|
|||||||
// transient UI, so they are intentionally left out.
|
// transient UI, so they are intentionally left out.
|
||||||
type PersistedQueue = Pick<
|
type PersistedQueue = Pick<
|
||||||
QueueState,
|
QueueState,
|
||||||
'entries' | 'currentIndex' | 'source' | 'sourceId' | 'sourceName'
|
| 'entries'
|
||||||
|
| 'currentIndex'
|
||||||
|
| 'source'
|
||||||
|
| 'sourceId'
|
||||||
|
| 'sourceName'
|
||||||
|
| 'shuffle'
|
||||||
|
| 'loop'
|
||||||
>;
|
>;
|
||||||
type PersistedPlayer = Pick<
|
type PersistedPlayer = Pick<
|
||||||
PlayerState,
|
PlayerState,
|
||||||
'currentTrackId' | 'position' | 'volume' | 'muted' | 'repeat' | 'shuffle'
|
'currentTrackId' | 'position' | 'volume' | 'muted'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function pickQueue(state: QueueState): PersistedQueue {
|
function pickQueue(state: QueueState): PersistedQueue {
|
||||||
@@ -40,6 +40,8 @@ function pickQueue(state: QueueState): PersistedQueue {
|
|||||||
source: state.source,
|
source: state.source,
|
||||||
sourceId: state.sourceId,
|
sourceId: state.sourceId,
|
||||||
sourceName: state.sourceName,
|
sourceName: state.sourceName,
|
||||||
|
shuffle: state.shuffle,
|
||||||
|
loop: state.loop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +51,6 @@ function pickPlayer(state: PlayerState): PersistedPlayer {
|
|||||||
position: state.position,
|
position: state.position,
|
||||||
volume: state.volume,
|
volume: state.volume,
|
||||||
muted: state.muted,
|
muted: state.muted,
|
||||||
repeat: state.repeat,
|
|
||||||
shuffle: state.shuffle,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,21 @@ type QueryEntry = ApiState['queries'][string];
|
|||||||
* carry no usable data and subscriptions are rebuilt by components on mount.
|
* carry no usable data and subscriptions are rebuilt by components on mount.
|
||||||
* Mutation results are never restored.
|
* Mutation results are never restored.
|
||||||
*/
|
*/
|
||||||
|
const EMPTY_PROVIDED = { tags: {}, keys: {} };
|
||||||
|
|
||||||
function snapshot(apiState: ApiState): RehydrateApiPayload {
|
function snapshot(apiState: ApiState): RehydrateApiPayload {
|
||||||
const queries: Record<string, unknown> = {};
|
const queries: Record<string, unknown> = {};
|
||||||
for (const [key, entry] of Object.entries(apiState.queries)) {
|
for (const [key, entry] of Object.entries(apiState.queries)) {
|
||||||
const q = entry as QueryEntry | undefined;
|
const q = entry as QueryEntry | undefined;
|
||||||
if (q && q.status === 'fulfilled') queries[key] = q;
|
if (q && q.status === 'fulfilled') queries[key] = q;
|
||||||
}
|
}
|
||||||
return { queries, mutations: {} };
|
// Carry `provided` along so RTKQ can re-register invalidation tags for the
|
||||||
|
// restored entries; it is also required structurally (see RehydrateApiPayload).
|
||||||
|
return {
|
||||||
|
queries,
|
||||||
|
mutations: {},
|
||||||
|
provided: apiState.provided ?? EMPTY_PROVIDED,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(): RehydrateApiPayload | null {
|
function load(): RehydrateApiPayload | null {
|
||||||
@@ -37,7 +45,13 @@ function load(): RehydrateApiPayload | null {
|
|||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
|
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
|
||||||
if (!parsed.queries) return null;
|
if (!parsed.queries) return null;
|
||||||
return { queries: parsed.queries, mutations: {} };
|
// `provided` may be absent in snapshots written before this field existed —
|
||||||
|
// default it so the invalidation slice doesn't crash on `provided.tags`.
|
||||||
|
return {
|
||||||
|
queries: parsed.queries,
|
||||||
|
mutations: {},
|
||||||
|
provided: parsed.provided ?? EMPTY_PROVIDED,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* Offline library composition. When the active backend is unreachable, a single
|
||||||
|
* `getTracks` query may be `rejected` (or never matched a rehydrated arg), so we
|
||||||
|
* can't rely on it to render the library. Instead we compose the "locally
|
||||||
|
* available" library from *every* fulfilled entry in the RTK Query cache —
|
||||||
|
* last-seen lists rehydrated from localStorage (Tier 2) plus anything fetched
|
||||||
|
* this session. This is read-only derived data, not a server-data slice copy:
|
||||||
|
* it reads straight from the RTKQ cache the architecture already owns.
|
||||||
|
*/
|
||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import type { RootState } from '../index';
|
||||||
|
import type { Album, Artist, PaginatedResponse, Track } from '../../api/types';
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
status: string;
|
||||||
|
endpointName?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectQueries = (state: RootState): Record<string, unknown> =>
|
||||||
|
state.api.queries;
|
||||||
|
|
||||||
|
function fulfilled(queries: Record<string, unknown>): CacheEntry[] {
|
||||||
|
const out: CacheEntry[] = [];
|
||||||
|
for (const entry of Object.values(queries)) {
|
||||||
|
const e = entry as CacheEntry | undefined;
|
||||||
|
if (e && e.status === 'fulfilled' && e.data != null) out.push(e);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every track known locally, deduped by id (last write wins). */
|
||||||
|
export const selectLocalTracks = createSelector(
|
||||||
|
selectQueries,
|
||||||
|
(queries): Track[] => {
|
||||||
|
const byId = new Map<string, Track>();
|
||||||
|
for (const e of fulfilled(queries)) {
|
||||||
|
switch (e.endpointName) {
|
||||||
|
case 'getTracks':
|
||||||
|
for (const t of (e.data as PaginatedResponse<Track>).items)
|
||||||
|
byId.set(t.id, t);
|
||||||
|
break;
|
||||||
|
case 'getAlbumTracks':
|
||||||
|
case 'getArtistTracks':
|
||||||
|
for (const t of e.data as Track[]) byId.set(t.id, t);
|
||||||
|
break;
|
||||||
|
case 'getTrack':
|
||||||
|
byId.set((e.data as Track).id, e.data as Track);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Every album known locally, deduped by id. */
|
||||||
|
export const selectLocalAlbums = createSelector(
|
||||||
|
selectQueries,
|
||||||
|
(queries): Album[] => {
|
||||||
|
const byId = new Map<string, Album>();
|
||||||
|
for (const e of fulfilled(queries)) {
|
||||||
|
switch (e.endpointName) {
|
||||||
|
case 'getAlbums':
|
||||||
|
for (const a of (e.data as PaginatedResponse<Album>).items)
|
||||||
|
byId.set(a.id, a);
|
||||||
|
break;
|
||||||
|
case 'getArtistAlbums':
|
||||||
|
for (const a of e.data as Album[]) byId.set(a.id, a);
|
||||||
|
break;
|
||||||
|
case 'getAlbum':
|
||||||
|
byId.set((e.data as Album).id, e.data as Album);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Every artist known locally, deduped by id. */
|
||||||
|
export const selectLocalArtists = createSelector(
|
||||||
|
selectQueries,
|
||||||
|
(queries): Artist[] => {
|
||||||
|
const byId = new Map<string, Artist>();
|
||||||
|
for (const e of fulfilled(queries)) {
|
||||||
|
switch (e.endpointName) {
|
||||||
|
case 'getArtists':
|
||||||
|
for (const a of (e.data as PaginatedResponse<Artist>).items)
|
||||||
|
byId.set(a.id, a);
|
||||||
|
break;
|
||||||
|
case 'getArtist':
|
||||||
|
byId.set((e.data as Artist).id, e.data as Artist);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export type ConnectionStatus =
|
||||||
|
| 'connected'
|
||||||
|
| 'connecting'
|
||||||
|
| 'disconnected'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export interface ConnectionState {
|
||||||
|
status: ConnectionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const connectionInitialState: ConnectionState = {
|
||||||
|
status: 'connecting',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const connectionSlice = createSlice({
|
||||||
|
name: 'connection',
|
||||||
|
initialState: connectionInitialState,
|
||||||
|
reducers: {
|
||||||
|
setConnectionStatus(state, action: PayloadAction<ConnectionStatus>) {
|
||||||
|
state.status = action.payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setConnectionStatus } = connectionSlice.actions;
|
||||||
|
export default connectionSlice.reducer;
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export type RepeatMode = 'none' | 'one' | 'all';
|
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
currentTrackId: string | null;
|
currentTrackId: string | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -9,9 +7,6 @@ export interface PlayerState {
|
|||||||
duration: number;
|
duration: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
repeat: RepeatMode;
|
|
||||||
shuffle: boolean;
|
|
||||||
isNowPlayingOpen: boolean;
|
|
||||||
isQueueOpen: boolean;
|
isQueueOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,9 +17,6 @@ export const playerInitialState: PlayerState = {
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
volume: 0.78,
|
volume: 0.78,
|
||||||
muted: false,
|
muted: false,
|
||||||
repeat: 'none',
|
|
||||||
shuffle: false,
|
|
||||||
isNowPlayingOpen: false,
|
|
||||||
isQueueOpen: false,
|
isQueueOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,15 +52,6 @@ export const playerSlice = createSlice({
|
|||||||
toggleMute(state) {
|
toggleMute(state) {
|
||||||
state.muted = !state.muted;
|
state.muted = !state.muted;
|
||||||
},
|
},
|
||||||
setRepeat(state, action: PayloadAction<RepeatMode>) {
|
|
||||||
state.repeat = action.payload;
|
|
||||||
},
|
|
||||||
toggleShuffle(state) {
|
|
||||||
state.shuffle = !state.shuffle;
|
|
||||||
},
|
|
||||||
toggleNowPlaying(state) {
|
|
||||||
state.isNowPlayingOpen = !state.isNowPlayingOpen;
|
|
||||||
},
|
|
||||||
toggleQueue(state) {
|
toggleQueue(state) {
|
||||||
state.isQueueOpen = !state.isQueueOpen;
|
state.isQueueOpen = !state.isQueueOpen;
|
||||||
},
|
},
|
||||||
@@ -84,9 +67,6 @@ export const {
|
|||||||
setDuration,
|
setDuration,
|
||||||
setVolume,
|
setVolume,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
setRepeat,
|
|
||||||
toggleShuffle,
|
|
||||||
toggleNowPlaying,
|
|
||||||
toggleQueue,
|
toggleQueue,
|
||||||
} = playerSlice.actions;
|
} = playerSlice.actions;
|
||||||
export default playerSlice.reducer;
|
export default playerSlice.reducer;
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface QueueState {
|
|||||||
source: QueueSource;
|
source: QueueSource;
|
||||||
sourceId: string | null;
|
sourceId: string | null;
|
||||||
sourceName: string | null;
|
sourceName: string | null;
|
||||||
|
shuffle: boolean;
|
||||||
|
loop: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const queueInitialState: QueueState = {
|
export const queueInitialState: QueueState = {
|
||||||
@@ -31,6 +33,8 @@ export const queueInitialState: QueueState = {
|
|||||||
source: 'manual',
|
source: 'manual',
|
||||||
sourceId: null,
|
sourceId: null,
|
||||||
sourceName: null,
|
sourceName: null,
|
||||||
|
shuffle: false,
|
||||||
|
loop: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queueSlice = createSlice({
|
export const queueSlice = createSlice({
|
||||||
@@ -59,6 +63,11 @@ export const queueSlice = createSlice({
|
|||||||
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
|
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
|
||||||
state.entries.splice(state.currentIndex + 1, 0, action.payload);
|
state.entries.splice(state.currentIndex + 1, 0, action.payload);
|
||||||
},
|
},
|
||||||
|
playNow(state, action: PayloadAction<QueueEntry>) {
|
||||||
|
const insertAt = state.currentIndex + 1;
|
||||||
|
state.entries.splice(insertAt, 0, action.payload);
|
||||||
|
state.currentIndex = insertAt;
|
||||||
|
},
|
||||||
removeFromQueue(state, action: PayloadAction<number>) {
|
removeFromQueue(state, action: PayloadAction<number>) {
|
||||||
state.entries.splice(action.payload, 1);
|
state.entries.splice(action.payload, 1);
|
||||||
if (action.payload < state.currentIndex) state.currentIndex--;
|
if (action.payload < state.currentIndex) state.currentIndex--;
|
||||||
@@ -77,7 +86,15 @@ export const queueSlice = createSlice({
|
|||||||
state.currentIndex = action.payload;
|
state.currentIndex = action.payload;
|
||||||
},
|
},
|
||||||
nextTrack(state) {
|
nextTrack(state) {
|
||||||
if (state.currentIndex < state.entries.length - 1) state.currentIndex++;
|
if (state.shuffle && state.entries.length > 1) {
|
||||||
|
let next = state.currentIndex;
|
||||||
|
while (next === state.currentIndex) {
|
||||||
|
next = Math.floor(Math.random() * state.entries.length);
|
||||||
|
}
|
||||||
|
state.currentIndex = next;
|
||||||
|
} else if (state.currentIndex < state.entries.length - 1) {
|
||||||
|
state.currentIndex++;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
prevTrack(state) {
|
prevTrack(state) {
|
||||||
if (state.currentIndex > 0) state.currentIndex--;
|
if (state.currentIndex > 0) state.currentIndex--;
|
||||||
@@ -86,6 +103,12 @@ export const queueSlice = createSlice({
|
|||||||
state.entries = [];
|
state.entries = [];
|
||||||
state.currentIndex = -1;
|
state.currentIndex = -1;
|
||||||
},
|
},
|
||||||
|
toggleShuffle(state) {
|
||||||
|
state.shuffle = !state.shuffle;
|
||||||
|
},
|
||||||
|
toggleLoop(state) {
|
||||||
|
state.loop = !state.loop;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,11 +116,14 @@ export const {
|
|||||||
setQueue,
|
setQueue,
|
||||||
addToQueue,
|
addToQueue,
|
||||||
addNextInQueue,
|
addNextInQueue,
|
||||||
|
playNow,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
moveInQueue,
|
moveInQueue,
|
||||||
goToIndex,
|
goToIndex,
|
||||||
nextTrack,
|
nextTrack,
|
||||||
prevTrack,
|
prevTrack,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
|
toggleShuffle,
|
||||||
|
toggleLoop,
|
||||||
} = queueSlice.actions;
|
} = queueSlice.actions;
|
||||||
export default queueSlice.reducer;
|
export default queueSlice.reducer;
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ interface UiState {
|
|||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
activeModal: string | null;
|
activeModal: string | null;
|
||||||
activeTrackContextMenuId: string | null;
|
activeTrackContextMenuId: string | null;
|
||||||
|
/** Track whose info drawer is open (rightmost drawer); null = closed. */
|
||||||
|
trackInfoId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UiState = {
|
const initialState: UiState = {
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
activeModal: null,
|
activeModal: null,
|
||||||
activeTrackContextMenuId: null,
|
activeTrackContextMenuId: null,
|
||||||
|
trackInfoId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uiSlice = createSlice({
|
export const uiSlice = createSlice({
|
||||||
@@ -31,6 +34,12 @@ export const uiSlice = createSlice({
|
|||||||
setActiveContextMenu(state, action: PayloadAction<string | null>) {
|
setActiveContextMenu(state, action: PayloadAction<string | null>) {
|
||||||
state.activeTrackContextMenuId = action.payload;
|
state.activeTrackContextMenuId = action.payload;
|
||||||
},
|
},
|
||||||
|
openTrackInfo(state, action: PayloadAction<string>) {
|
||||||
|
state.trackInfoId = action.payload;
|
||||||
|
},
|
||||||
|
closeTrackInfo(state) {
|
||||||
|
state.trackInfoId = null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,5 +49,7 @@ export const {
|
|||||||
openModal,
|
openModal,
|
||||||
closeModal,
|
closeModal,
|
||||||
setActiveContextMenu,
|
setActiveContextMenu,
|
||||||
|
openTrackInfo,
|
||||||
|
closeTrackInfo,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
export default uiSlice.reducer;
|
export default uiSlice.reducer;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
color: var(--fg-1);
|
color: var(--fg-1);
|
||||||
|
/* Paint the themed background immediately. The inline theme script in
|
||||||
|
index.html (see rsbuild.config.ts) sets [data-theme] before first paint, so
|
||||||
|
--color-bg resolves to the right value here before React mounts #root and
|
||||||
|
layers the .modern-sk-felt grain on top — no flash of white. */
|
||||||
|
background: var(--color-bg);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+318
-42
@@ -404,6 +404,55 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- playing indicator ("hopping bars" equalizer, YTM-style) ---- */
|
||||||
|
.playing-bars {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.playing-bars span {
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--lime);
|
||||||
|
border-radius: 1px;
|
||||||
|
height: 30%;
|
||||||
|
animation: playing-bar-bounce 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.playing-bars span:nth-child(1) {
|
||||||
|
animation-delay: -0.9s;
|
||||||
|
}
|
||||||
|
.playing-bars span:nth-child(2) {
|
||||||
|
animation-delay: -0.3s;
|
||||||
|
}
|
||||||
|
.playing-bars span:nth-child(3) {
|
||||||
|
animation-delay: -0.6s;
|
||||||
|
}
|
||||||
|
.playing-bars.paused span {
|
||||||
|
animation-play-state: paused;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
animation: spin 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes playing-bar-bounce {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
height: 30%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
PLAYER BAR
|
PLAYER BAR
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -602,38 +651,9 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
padding: 12px 12px 18px;
|
padding: 12px 12px 18px;
|
||||||
}
|
}
|
||||||
.qd-now {
|
|
||||||
display: flex;
|
|
||||||
gap: 11px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: var(--r-md);
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(190, 242, 100, 0.13),
|
|
||||||
rgba(190, 242, 100, 0.05)
|
|
||||||
);
|
|
||||||
border: 1px solid rgba(190, 242, 100, 0.2);
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.qd-now .qt {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.qd-now .qt .t {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--fg-1);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.qd-now .qt .r {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--fg-3);
|
|
||||||
}
|
|
||||||
.qrow {
|
.qrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
@@ -649,6 +669,24 @@
|
|||||||
.qrow:hover {
|
.qrow:hover {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
.qrow.current {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(190, 242, 100, 0.13),
|
||||||
|
rgba(190, 242, 100, 0.05)
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 0 1px rgba(190, 242, 100, 0.35) inset;
|
||||||
|
}
|
||||||
|
.qrow.dragging {
|
||||||
|
z-index: 1;
|
||||||
|
cursor: grabbing;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.qrow.current .qt .t {
|
||||||
|
color: var(--lime);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.qrow .grip {
|
.qrow .grip {
|
||||||
color: var(--fg-3);
|
color: var(--fg-3);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -663,23 +701,46 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fg-1);
|
color: var(--fg-1);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
.qrow .qt .r {
|
.qrow .qt .r {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--fg-3);
|
color: var(--fg-3);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
.qrow .qt .r .ph {
|
|
||||||
color: var(--lime);
|
/* News-ticker text: clips by default, ping-pong scrolls only when it overflows
|
||||||
font-size: 11px;
|
(the .on class is set by the Marquee component after measuring). */
|
||||||
|
.marquee {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.marquee-inner {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
.marquee.on .marquee-inner {
|
||||||
|
max-width: none;
|
||||||
|
animation: marquee-pingpong 9s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes marquee-pingpong {
|
||||||
|
0%,
|
||||||
|
12% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
88%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(var(--mq-shift, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.marquee.on .marquee-inner {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.qd-radio {
|
.qd-radio {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
@@ -724,6 +785,164 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
TRACK INFO DRAWER (rightmost — sits right of the queue drawer)
|
||||||
|
============================================================ */
|
||||||
|
/* Same width-collapse pattern as .qd. Rendered after QueuePanel in AppShell so
|
||||||
|
when both are open this is the rightmost panel. */
|
||||||
|
.tid {
|
||||||
|
width: 360px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-left: 1px solid var(--hair);
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.24));
|
||||||
|
transition:
|
||||||
|
width 0.24s var(--ease-out),
|
||||||
|
border-left-color 0.24s var(--ease-out);
|
||||||
|
}
|
||||||
|
.tid.closed {
|
||||||
|
width: 0;
|
||||||
|
border-left-color: transparent;
|
||||||
|
}
|
||||||
|
.tid-inner {
|
||||||
|
width: 360px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.tid-head {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
border-bottom: 1px solid var(--hair);
|
||||||
|
}
|
||||||
|
.tid-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
.tid-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.tid-cover {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--steel-900);
|
||||||
|
box-shadow: var(--shadow-raised, 0 8px 24px rgba(0, 0, 0, 0.4));
|
||||||
|
}
|
||||||
|
.tid-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.tid-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--fg-1);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.tid-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg-2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tid-sub:hover {
|
||||||
|
color: var(--lime);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.tid-album {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
.tid-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
}
|
||||||
|
.tid-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--hair);
|
||||||
|
}
|
||||||
|
.tid-section-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.tid-status {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tid-error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ember, #e9572b);
|
||||||
|
}
|
||||||
|
.tid-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 5px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tid-row-k {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 96px;
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
.tid-row-v {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--fg-1);
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.tid-row-v.mono {
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On narrower viewports the drawer overlays the content instead of pushing it,
|
||||||
|
so the queue + info drawers don't squeeze the main screen. */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.app-body {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tid {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 360px;
|
||||||
|
z-index: 30;
|
||||||
|
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.5);
|
||||||
|
transition: transform 0.24s var(--ease-out);
|
||||||
|
}
|
||||||
|
.tid.closed {
|
||||||
|
width: 360px;
|
||||||
|
transform: translateX(100%);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
PAGE HEADER + SECONDARY NAV (Settings, Admin)
|
PAGE HEADER + SECONDARY NAV (Settings, Admin)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -764,3 +983,60 @@
|
|||||||
.sb-sec-link.active {
|
.sb-sec-link.active {
|
||||||
color: var(--fg-1);
|
color: var(--fg-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
TRACK ROW — cover art play overlay
|
||||||
|
============================================================ */
|
||||||
|
.track-art {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.track-art-play {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: var(--fg-1);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--dur-quick);
|
||||||
|
}
|
||||||
|
.track-art:hover .track-art-play {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.track-art-play:hover {
|
||||||
|
color: var(--lime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Now-playing overlay shown on a cover when its track is the active one
|
||||||
|
(track lists and the queue panel both use this). */
|
||||||
|
.cover-playing {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.track-art:hover .cover-playing {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Queue row cover-art wrapper, sized to match the 36px ArtTile */
|
||||||
|
.qart {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { expect, test } from '@rstest/core';
|
||||||
|
import {
|
||||||
|
selectLocalTracks,
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalArtists,
|
||||||
|
} from '../src/store/selectors/localLibrary';
|
||||||
|
import type { RootState } from '../src/store/index';
|
||||||
|
|
||||||
|
function stateWith(queries: Record<string, unknown>): RootState {
|
||||||
|
return { api: { queries } } as unknown as RootState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = (id: string, over: Record<string, unknown> = {}) => ({
|
||||||
|
id,
|
||||||
|
title: `Track ${id}`,
|
||||||
|
artistName: 'A',
|
||||||
|
albumTitle: 'Alb',
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectLocalTracks unions getTracks pages, list endpoints and single tracks', () => {
|
||||||
|
const state = stateWith({
|
||||||
|
'getTracks(undefined)': {
|
||||||
|
status: 'fulfilled',
|
||||||
|
endpointName: 'getTracks',
|
||||||
|
data: { items: [track('1'), track('2')], total: 2 },
|
||||||
|
},
|
||||||
|
'getArtistTracks("x")': {
|
||||||
|
status: 'fulfilled',
|
||||||
|
endpointName: 'getArtistTracks',
|
||||||
|
data: [track('2'), track('3')], // 2 is a dupe
|
||||||
|
},
|
||||||
|
'getTrack("4")': {
|
||||||
|
status: 'fulfilled',
|
||||||
|
endpointName: 'getTrack',
|
||||||
|
data: track('4'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = selectLocalTracks(state)
|
||||||
|
.map((t) => t.id)
|
||||||
|
.sort();
|
||||||
|
expect(ids).toEqual(['1', '2', '3', '4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectLocalTracks ignores pending/rejected and null-data entries', () => {
|
||||||
|
const state = stateWith({
|
||||||
|
'getTracks(a)': {
|
||||||
|
status: 'rejected',
|
||||||
|
endpointName: 'getTracks',
|
||||||
|
data: undefined,
|
||||||
|
},
|
||||||
|
'getTracks(b)': { status: 'pending', endpointName: 'getTracks' },
|
||||||
|
'getTracks(c)': {
|
||||||
|
status: 'fulfilled',
|
||||||
|
endpointName: 'getTracks',
|
||||||
|
data: { items: [track('9')], total: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(selectLocalTracks(state).map((t) => t.id)).toEqual(['9']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectLocalAlbums and selectLocalArtists compose and dedupe', () => {
|
||||||
|
const state = stateWith({
|
||||||
|
'getAlbums(undefined)': {
|
||||||
|
status: 'fulfilled',
|
||||||
|
endpointName: 'getAlbums',
|
||||||
|
data: { items: [{ id: 'al1' }, { id: 'al2' }], total: 2 },
|
||||||
|
},
|
||||||
|
'getArtistAlbums("x")': {
|
||||||
|
status: 'fulfilled',
|
||||||
|
endpointName: 'getArtistAlbums',
|
||||||
|
data: [{ id: 'al2' }], // dupe
|
||||||
|
},
|
||||||
|
'getArtists(undefined)': {
|
||||||
|
status: 'fulfilled',
|
||||||
|
endpointName: 'getArtists',
|
||||||
|
data: { items: [{ id: 'ar1' }], total: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
selectLocalAlbums(state)
|
||||||
|
.map((a) => a.id)
|
||||||
|
.sort(),
|
||||||
|
).toEqual(['al1', 'al2']);
|
||||||
|
expect(selectLocalArtists(state).map((a) => a.id)).toEqual(['ar1']);
|
||||||
|
});
|
||||||
@@ -20,7 +20,13 @@ beforeEach(() => {
|
|||||||
|
|
||||||
function apiStateWith(queries: Record<string, unknown>) {
|
function apiStateWith(queries: Record<string, unknown>) {
|
||||||
return {
|
return {
|
||||||
api: { queries, mutations: {}, provided: {}, subscriptions: {}, config: {} },
|
api: {
|
||||||
|
queries,
|
||||||
|
mutations: {},
|
||||||
|
provided: {},
|
||||||
|
subscriptions: {},
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
} as unknown as RootState;
|
} as unknown as RootState;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +55,23 @@ test('rehydrateApiCache replays a stored cache as a rehydrate action', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rehydrate payload always carries `provided` (regression: RTKQ reads provided.tags)', () => {
|
||||||
|
// A snapshot persisted before `provided` existed must not crash RTKQ's
|
||||||
|
// invalidation slice, which does `Object.entries(provided.tags ?? {})`.
|
||||||
|
instanceStorage.set(
|
||||||
|
'rtkq',
|
||||||
|
JSON.stringify({
|
||||||
|
queries: { 'getLibrary(undefined)': { status: 'fulfilled', data: [] } },
|
||||||
|
mutations: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const dispatched: Array<{ payload: { provided?: unknown } }> = [];
|
||||||
|
rehydrateApiCache((a) =>
|
||||||
|
dispatched.push(a as { payload: { provided?: unknown } }),
|
||||||
|
);
|
||||||
|
expect(dispatched[0].payload.provided).toEqual({ tags: {}, keys: {} });
|
||||||
|
});
|
||||||
|
|
||||||
test('startApiPersistence saves only fulfilled queries after throttle', () => {
|
test('startApiPersistence saves only fulfilled queries after throttle', () => {
|
||||||
rstest.useFakeTimers();
|
rstest.useFakeTimers();
|
||||||
let state = apiStateWith({});
|
let state = apiStateWith({});
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
} from '../public/sw-core.js';
|
} from '../public/sw-core.js';
|
||||||
|
|
||||||
test('trackIdFromUrl extracts the content id from a stream URL', () => {
|
test('trackIdFromUrl extracts the content id from a stream URL', () => {
|
||||||
expect(
|
expect(trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz')).toBe(
|
||||||
trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz'),
|
'abc123',
|
||||||
).toBe('abc123');
|
);
|
||||||
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
|
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user