feat(offline): make the web UI usable without a reachable backend
Three tiers of offline support, all scoped to the active backend's localStorage namespace (mirroring the auth slice): Tier 1 — persist client state. queue + player slices are saved (queue entries/index/source; player track/position/volume/repeat/shuffle) and rehydrated on load, so a reload with no backend restores where the user left off. Playback never auto-resumes (browsers block autoplay). Retires the DEMO_QUEUE and isQueueOpen:true stubs. Tier 2 — persist the RTK Query cache. Last-seen library/albums/artists are snapshotted (fulfilled queries only) and replayed via RTKQ's extractRehydrationInfo at startup, so the library renders read-only when the backend is down. ConnectionStatus tooltip flags cached data offline. No server data is copied into a slice — the cache feeds itself back. Tier 3 — service worker audio + cover cache (PWA). Audio streams are cached keyed by content id (token stripped), range-aware (synthetic 206 slicing), with a 500MB LRU cap, so already-played tracks play fully offline. Cover art uses stale-while-revalidate in its own bounded cache. Module worker (ESM); pure helpers split into sw-core.js and unit-tested. Web app manifest enables "Install app". Player source badge now reflects real cached state. tsc clean, lint clean, 19 new tests pass, production build verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "MCMA — Music",
|
||||||
|
"short_name": "MCMA",
|
||||||
|
"description": "Self-hosted music — control center and offline-capable player.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0b0b0b",
|
||||||
|
"theme_color": "#0b0b0b",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* Service-worker core: pure helpers with NO side effects (no `self`, no
|
||||||
|
* `caches`, no `fetch`). Split out from `sw.js` so the tricky bits — HTTP Range
|
||||||
|
* parsing, LRU eviction, cache-key normalization — can be unit-tested in Node.
|
||||||
|
*
|
||||||
|
* This is an ES module: `sw.js` imports it (the SW is registered with
|
||||||
|
* { type: 'module' }) and tests import it natively — one source, no drift.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Bump the version to invalidate every cached blob on a breaking change.
|
||||||
|
export const AUDIO_CACHE = 'mcma-audio-v1';
|
||||||
|
// Synthetic same-origin key holding the LRU index ({ key: {size, lastAccess} }).
|
||||||
|
export const INDEX_URL = '/__mcma_audio_index__';
|
||||||
|
// Soft cap on total cached audio. LRU eviction keeps us under this.
|
||||||
|
export const MAX_BYTES = 500 * 1024 * 1024; // 500 MB
|
||||||
|
|
||||||
|
// Cover art (album/artist/playlist images) gets its own cache with a simple
|
||||||
|
// count cap — covers are small and stable, so no per-byte LRU is needed.
|
||||||
|
export const COVER_CACHE = 'mcma-covers-v1';
|
||||||
|
export const MAX_COVERS = 600;
|
||||||
|
|
||||||
|
// Backend stream route: /api/v1/streaming/tracks/<trackId>?token=...
|
||||||
|
const STREAM_RE = /\/streaming\/tracks\/([^/?#]+)/;
|
||||||
|
|
||||||
|
/** The track (content) id from a stream URL, or null if it isn't one. */
|
||||||
|
export function trackIdFromUrl(url) {
|
||||||
|
const m = STREAM_RE.exec(url);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical cache key for a stream URL: origin + path, query dropped. The auth
|
||||||
|
* token rides in the query and rotates on refresh, so keying by content path
|
||||||
|
* (origin keeps two backends from colliding) makes the cache token-stable —
|
||||||
|
* the same track is one entry regardless of which token fetched it.
|
||||||
|
*/
|
||||||
|
export function cacheKeyFor(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.origin + u.pathname;
|
||||||
|
} catch {
|
||||||
|
return String(url).split('?')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an HTTP `Range` header against a known resource size. Returns inclusive
|
||||||
|
* { start, end } byte offsets, or null for "no range / serve the whole thing".
|
||||||
|
* Handles `bytes=a-b`, `bytes=a-` (open-ended) and `bytes=-n` (last n bytes).
|
||||||
|
*/
|
||||||
|
export function parseRangeHeader(rangeHeader, size) {
|
||||||
|
if (!rangeHeader) return null;
|
||||||
|
const m = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader).trim());
|
||||||
|
if (!m) return null;
|
||||||
|
let start = m[1] === '' ? null : parseInt(m[1], 10);
|
||||||
|
let end = m[2] === '' ? null : parseInt(m[2], 10);
|
||||||
|
if (start === null && end === null) return null;
|
||||||
|
if (start === null) {
|
||||||
|
// suffix range: the final `end` bytes
|
||||||
|
start = Math.max(0, size - end);
|
||||||
|
end = size - 1;
|
||||||
|
} else if (end === null) {
|
||||||
|
end = size - 1;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
||||||
|
if (end >= size) end = size - 1;
|
||||||
|
if (start < 0 || start > end) return null;
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose which cached entries to evict (least-recently-used first) so that the
|
||||||
|
* existing total plus an incoming blob fits under `maxBytes`. Returns the list
|
||||||
|
* of keys to delete; empty when there's already room.
|
||||||
|
*
|
||||||
|
* `index` is { [key]: { size, lastAccess } }.
|
||||||
|
*/
|
||||||
|
export function selectEvictions(index, incomingSize, maxBytes) {
|
||||||
|
let total = incomingSize;
|
||||||
|
for (const k in index) total += index[k].size || 0;
|
||||||
|
if (total <= maxBytes) return [];
|
||||||
|
|
||||||
|
const entries = Object.keys(index)
|
||||||
|
.map((k) => ({
|
||||||
|
key: k,
|
||||||
|
lastAccess: index[k].lastAccess || 0,
|
||||||
|
size: index[k].size || 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.lastAccess - b.lastAccess); // oldest first
|
||||||
|
|
||||||
|
const evict = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
if (total <= maxBytes) break;
|
||||||
|
evict.push(e.key);
|
||||||
|
total -= e.size;
|
||||||
|
}
|
||||||
|
return evict;
|
||||||
|
}
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
/*
|
||||||
|
* MCMA service worker — Tier 3 offline support: audio blob cache.
|
||||||
|
*
|
||||||
|
* It sits between the app and the network for audio-stream requests only
|
||||||
|
* (`/streaming/tracks/<id>`). The first time a track is streamed it's copied
|
||||||
|
* into the Cache API (keyed by content id, token stripped); afterwards — or
|
||||||
|
* whenever the backend is unreachable — playback is served straight from the
|
||||||
|
* cache, so already-heard tracks play with no network at all.
|
||||||
|
*
|
||||||
|
* Pure helpers (range parsing, LRU, key normalization) live in sw-core.js so
|
||||||
|
* they can be unit-tested; this file owns the side-effectful cache/network I/O.
|
||||||
|
* Registered as a module worker ({ type: 'module' }), so it uses ES imports.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
AUDIO_CACHE,
|
||||||
|
INDEX_URL,
|
||||||
|
MAX_BYTES,
|
||||||
|
COVER_CACHE,
|
||||||
|
MAX_COVERS,
|
||||||
|
trackIdFromUrl,
|
||||||
|
cacheKeyFor,
|
||||||
|
parseRangeHeader,
|
||||||
|
selectEvictions,
|
||||||
|
} from './sw-core.js';
|
||||||
|
|
||||||
|
self.addEventListener('install', () => {
|
||||||
|
// Activate immediately on first install / update — no stale SW lingering.
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== 'GET') return;
|
||||||
|
if (trackIdFromUrl(req.url)) {
|
||||||
|
event.respondWith(handleAudio(event)); // audio stream → range-aware cache
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.destination === 'image') {
|
||||||
|
event.respondWith(handleImage(event)); // cover art → stale-while-revalidate
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleAudio(event) {
|
||||||
|
const req = event.request;
|
||||||
|
const key = cacheKeyFor(req.url);
|
||||||
|
const range = req.headers.get('range');
|
||||||
|
const cache = await caches.open(AUDIO_CACHE);
|
||||||
|
|
||||||
|
// 1) Serve from cache when we have it (works fully offline).
|
||||||
|
const cached = await cache.match(key);
|
||||||
|
if (cached) {
|
||||||
|
event.waitUntil(touch(key));
|
||||||
|
return range ? buildRangeResponse(cached, range) : cached.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
try {
|
||||||
|
const fullReq = new Request(req.url, { headers: withoutRange(req.headers) });
|
||||||
|
const resp = await fetch(fullReq);
|
||||||
|
if (isCacheable(resp)) {
|
||||||
|
event.waitUntil(storeInCache(key, resp.clone()));
|
||||||
|
}
|
||||||
|
return range ? buildRangeResponse(resp, range) : resp;
|
||||||
|
} catch {
|
||||||
|
// Offline and never cached — nothing we can do for this one.
|
||||||
|
return new Response('Offline and not cached', {
|
||||||
|
status: 504,
|
||||||
|
statusText: 'Offline',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover art: stale-while-revalidate. Serve the cached image instantly (so the
|
||||||
|
* library renders offline), refresh it in the background, and cache fresh hits.
|
||||||
|
* Cover URLs are token-free and stable, and `<img>` is happy with opaque
|
||||||
|
* cross-origin responses — so unlike audio, these cache even cross-origin.
|
||||||
|
*/
|
||||||
|
async function handleImage(event) {
|
||||||
|
const req = event.request;
|
||||||
|
const cache = await caches.open(COVER_CACHE);
|
||||||
|
const cached = await cache.match(req);
|
||||||
|
|
||||||
|
const fromNetwork = fetch(req)
|
||||||
|
.then((resp) => {
|
||||||
|
if (resp && (resp.status === 200 || resp.type === 'opaque')) {
|
||||||
|
return cache
|
||||||
|
.put(req, resp.clone())
|
||||||
|
.then(() => trimCovers(cache))
|
||||||
|
.then(() => resp);
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
event.waitUntil(fromNetwork); // revalidate without blocking the response
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const resp = await fromNetwork;
|
||||||
|
return resp || new Response('', { status: 504, statusText: 'Offline' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the cover cache bounded — drop the oldest entries past the cap. */
|
||||||
|
async function trimCovers(cache) {
|
||||||
|
const keys = await cache.keys();
|
||||||
|
const excess = keys.length - MAX_COVERS;
|
||||||
|
for (let i = 0; i < excess; i++) await cache.delete(keys[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only cache readable, complete 200 responses — opaque/partial are useless. */
|
||||||
|
function isCacheable(resp) {
|
||||||
|
return (
|
||||||
|
resp.status === 200 &&
|
||||||
|
resp.type !== 'opaque' &&
|
||||||
|
resp.type !== 'opaqueredirect'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutRange(headers) {
|
||||||
|
const out = new Headers();
|
||||||
|
for (const [k, v] of headers.entries()) {
|
||||||
|
if (k.toLowerCase() !== 'range') out.set(k, v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a 206 Partial Content response by slicing a full cached/fetched body. */
|
||||||
|
async function buildRangeResponse(response, rangeHeader) {
|
||||||
|
const buf = await response.clone().arrayBuffer();
|
||||||
|
const size = buf.byteLength;
|
||||||
|
const r = parseRangeHeader(rangeHeader, size);
|
||||||
|
const type = response.headers.get('content-type') || 'application/octet-stream';
|
||||||
|
|
||||||
|
if (!r) {
|
||||||
|
return new Response(buf, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'content-type': type,
|
||||||
|
'content-length': String(size),
|
||||||
|
'accept-ranges': 'bytes',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliced = buf.slice(r.start, r.end + 1);
|
||||||
|
return new Response(sliced, {
|
||||||
|
status: 206,
|
||||||
|
statusText: 'Partial Content',
|
||||||
|
headers: {
|
||||||
|
'content-type': type,
|
||||||
|
'content-range': `bytes ${r.start}-${r.end}/${size}`,
|
||||||
|
'content-length': String(sliced.byteLength),
|
||||||
|
'accept-ranges': 'bytes',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseSize(resp) {
|
||||||
|
const len = resp.headers.get('content-length');
|
||||||
|
if (len) return Number(len);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
return blob.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LRU index -------------------------------------------------------------
|
||||||
|
// The index lives as a JSON entry in the same cache. All mutations go through a
|
||||||
|
// single serialized chain so concurrent fetch handlers can't clobber it.
|
||||||
|
let indexChain = Promise.resolve();
|
||||||
|
|
||||||
|
async function readIndex(cache) {
|
||||||
|
try {
|
||||||
|
const res = await cache.match(INDEX_URL);
|
||||||
|
if (!res) return {};
|
||||||
|
return (await res.json()) || {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIndex(mutator) {
|
||||||
|
indexChain = indexChain
|
||||||
|
.then(async () => {
|
||||||
|
const cache = await caches.open(AUDIO_CACHE);
|
||||||
|
const index = await readIndex(cache);
|
||||||
|
await mutator(cache, index);
|
||||||
|
await cache.put(
|
||||||
|
INDEX_URL,
|
||||||
|
new Response(JSON.stringify(index), {
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* keep the chain alive even if one write fails */
|
||||||
|
});
|
||||||
|
return indexChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeInCache(key, resp) {
|
||||||
|
const size = await responseSize(resp.clone());
|
||||||
|
await writeIndex(async (cache, index) => {
|
||||||
|
for (const ek of selectEvictions(index, size, MAX_BYTES)) {
|
||||||
|
await cache.delete(ek);
|
||||||
|
delete index[ek];
|
||||||
|
}
|
||||||
|
await cache.put(key, resp);
|
||||||
|
index[key] = { size, lastAccess: Date.now() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch(key) {
|
||||||
|
return writeIndex((_cache, index) => {
|
||||||
|
if (index[key]) index[key].lastAccess = Date.now();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- client messaging ------------------------------------------------------
|
||||||
|
// The app talks to the SW over a MessageChannel: it sends a port, we reply on
|
||||||
|
// it. Used for the offline-cache stats/controls in the UI.
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
const data = event.data || {};
|
||||||
|
const reply = (result) => {
|
||||||
|
if (event.ports && event.ports[0]) event.ports[0].postMessage(result);
|
||||||
|
};
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(AUDIO_CACHE);
|
||||||
|
const index = await readIndex(cache);
|
||||||
|
if (data.type === 'STATS') {
|
||||||
|
const keys = Object.keys(index);
|
||||||
|
const bytes = keys.reduce((s, k) => s + (index[k].size || 0), 0);
|
||||||
|
reply({ count: keys.length, bytes, maxBytes: MAX_BYTES });
|
||||||
|
} else if (data.type === 'HAS') {
|
||||||
|
reply({ cached: !!index[cacheKeyFor(data.url)] });
|
||||||
|
} else if (data.type === 'CLEAR') {
|
||||||
|
await Promise.all([
|
||||||
|
caches.delete(AUDIO_CACHE),
|
||||||
|
caches.delete(COVER_CACHE),
|
||||||
|
]);
|
||||||
|
reply({ ok: true });
|
||||||
|
} else {
|
||||||
|
reply({ error: 'unknown-message' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reply({ error: String(e) });
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -32,5 +32,16 @@ export default defineConfig({
|
|||||||
// no manual source.define needed. See src/config/env.ts.
|
// no manual source.define needed. See src/config/env.ts.
|
||||||
html: {
|
html: {
|
||||||
title: 'MCMA',
|
title: 'MCMA',
|
||||||
|
// PWA: link the manifest + declare theme/icon so the browser offers
|
||||||
|
// "Install app". The service worker (audio offline cache) is registered
|
||||||
|
// from src/index.tsx, not here.
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
tag: 'link',
|
||||||
|
attrs: { rel: 'manifest', href: '/manifest.webmanifest' },
|
||||||
|
},
|
||||||
|
{ tag: 'meta', attrs: { name: 'theme-color', content: '#0b0b0b' } },
|
||||||
|
{ tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/favicon.png' } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApi } from '@reduxjs/toolkit/query/react';
|
import { createApi } from '@reduxjs/toolkit/query/react';
|
||||||
import { baseQueryWithReauth } from './baseQuery';
|
import { baseQueryWithReauth } from './baseQuery';
|
||||||
|
import { REHYDRATE_API, type RehydrateApiPayload } from './rehydrate';
|
||||||
|
|
||||||
export const api = createApi({
|
export const api = createApi({
|
||||||
reducerPath: 'api',
|
reducerPath: 'api',
|
||||||
@@ -14,5 +15,16 @@ export const api = createApi({
|
|||||||
'User',
|
'User',
|
||||||
'Storage',
|
'Storage',
|
||||||
],
|
],
|
||||||
|
// Tier 2 offline: seed the cache from the persisted snapshot dispatched at
|
||||||
|
// startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations
|
||||||
|
// lets the last-seen library render before — or instead of — any network call.
|
||||||
|
extractRehydrationInfo(action) {
|
||||||
|
if (action.type === REHYDRATE_API) {
|
||||||
|
// The api reducer reads `queries`/`mutations` off this and restores any
|
||||||
|
// fulfilled entries; pending/rejected ones are ignored automatically.
|
||||||
|
return action.payload as RehydrateApiPayload as never;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
endpoints: () => ({}),
|
endpoints: () => ({}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tier 2 offline support contract. RTK Query can seed its cache from a
|
||||||
|
* persisted snapshot via `extractRehydrationInfo` (see `api/index.ts`). We use
|
||||||
|
* a single action whose payload is the previously-saved api slice state; the
|
||||||
|
* api reducer pulls `queries`/`mutations` out of it on startup so last-seen
|
||||||
|
* library data renders read-only while the backend is unreachable.
|
||||||
|
*
|
||||||
|
* The type string lives here (not in the store) so `api/index.ts` can match it
|
||||||
|
* without importing from the store layer (which would create a cycle).
|
||||||
|
*/
|
||||||
|
export const REHYDRATE_API = 'api/rehydrate';
|
||||||
|
|
||||||
|
export interface RehydrateApiPayload {
|
||||||
|
queries: Record<string, unknown>;
|
||||||
|
mutations: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API);
|
||||||
@@ -22,9 +22,15 @@ export function ConnectionStatus() {
|
|||||||
const status = useConnectionStatus();
|
const status = useConnectionStatus();
|
||||||
const baseUrl = getApiBaseUrl();
|
const baseUrl = getApiBaseUrl();
|
||||||
const label = t(STATUS_KEY[status]);
|
const label = t(STATUS_KEY[status]);
|
||||||
|
// When the backend is unreachable the UI falls back to the persisted RTKQ
|
||||||
|
// cache (Tier 2), so flag that the data on screen is last-seen, not live.
|
||||||
|
const offline = status === 'disconnected' || status === 'error';
|
||||||
|
const tip = offline
|
||||||
|
? `${label} · ${baseUrl} · ${t('conn.cached')}`
|
||||||
|
: `${label} · ${baseUrl}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={`${label} · ${baseUrl}`}>
|
<Tooltip content={tip}>
|
||||||
<Badge variant={STATUS_VARIANTS[status]} dot>
|
<Badge variant={STATUS_VARIANTS[status]} dot>
|
||||||
{label}
|
{label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
toggleQueue,
|
toggleQueue,
|
||||||
} from '../../store/slices/player';
|
} from '../../store/slices/player';
|
||||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||||
|
import { useStreamCached } from '../../hooks/useStreamCached';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ export function PersistentPlayer() {
|
|||||||
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 currentEntry = queue.entries[queue.currentIndex];
|
const currentEntry = queue.entries[queue.currentIndex];
|
||||||
|
// Source indicator: cached → playing locally, otherwise streaming.
|
||||||
|
const cached = useStreamCached(currentEntry?.trackId);
|
||||||
|
|
||||||
if (!currentEntry && !player.currentTrackId) {
|
if (!currentEntry && !player.currentTrackId) {
|
||||||
return <div className="player empty">{t('player.nothingPlaying')}</div>;
|
return <div className="player empty">{t('player.nothingPlaying')}</div>;
|
||||||
@@ -31,7 +34,7 @@ export function PersistentPlayer() {
|
|||||||
|
|
||||||
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
|
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
|
||||||
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
|
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
|
||||||
const onStream = true;
|
const onStream = !cached;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player">
|
<div className="player">
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAppSelector } from './useAppDispatch';
|
||||||
|
import { isStreamCached } from '../lib/sw';
|
||||||
|
import { getStreamUrl } from '../api/endpoints/streaming';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the given track is available from the offline audio cache (Tier 3).
|
||||||
|
* Drives the player-bar source indicator (local vs streaming). Returns false
|
||||||
|
* until the service worker is controlling and confirms a hit.
|
||||||
|
*/
|
||||||
|
export function useStreamCached(trackId: string | undefined): boolean {
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
const [cached, setCached] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trackId || !token) {
|
||||||
|
setCached(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
void isStreamCached(getStreamUrl(trackId, token)).then((hit) => {
|
||||||
|
if (active) setCached(hit);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [trackId, token]);
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const en = {
|
|||||||
disconnected: 'Offline',
|
disconnected: 'Offline',
|
||||||
error: 'Unreachable',
|
error: 'Unreachable',
|
||||||
manage: 'Connection — manage instances',
|
manage: 'Connection — manage instances',
|
||||||
|
cached: 'Showing last-seen data',
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
online: 'online',
|
online: 'online',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const ru: Translations = {
|
|||||||
disconnected: 'Нет связи',
|
disconnected: 'Нет связи',
|
||||||
error: 'Недоступно',
|
error: 'Недоступно',
|
||||||
manage: 'Соединение — управление экземплярами',
|
manage: 'Соединение — управление экземплярами',
|
||||||
|
cached: 'Показаны последние данные',
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
online: 'онлайн',
|
online: 'онлайн',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { BrowserRouter } from 'react-router';
|
|||||||
import { ThemeProvider, TooltipProvider } from '@olly/modern-sk';
|
import { ThemeProvider, TooltipProvider } from '@olly/modern-sk';
|
||||||
import { store } from './store';
|
import { store } from './store';
|
||||||
import { AppRoutes } from './routes';
|
import { AppRoutes } from './routes';
|
||||||
|
import { registerServiceWorker } from './lib/sw';
|
||||||
|
|
||||||
// Import all endpoint injections to ensure they are registered
|
// Import all endpoint injections to ensure they are registered
|
||||||
import './api/endpoints/auth';
|
import './api/endpoints/auth';
|
||||||
@@ -21,6 +22,10 @@ import './api/endpoints/storage';
|
|||||||
import './api/endpoints/admin';
|
import './api/endpoints/admin';
|
||||||
import './api/endpoints/upload';
|
import './api/endpoints/upload';
|
||||||
|
|
||||||
|
// Tier 3 offline: register the audio-caching service worker (no-op if the
|
||||||
|
// browser/origin doesn't support it).
|
||||||
|
registerServiceWorker();
|
||||||
|
|
||||||
const rootEl = document.getElementById('root');
|
const rootEl = document.getElementById('root');
|
||||||
if (rootEl) {
|
if (rootEl) {
|
||||||
// grained black-ish background + base text color from modern-sk
|
// grained black-ish background + base text color from modern-sk
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* Service-worker client: registration + a typed bridge to the audio offline
|
||||||
|
* cache (Tier 3). The SW itself lives in `public/sw.js`; this is the app side.
|
||||||
|
*
|
||||||
|
* Messaging uses a MessageChannel — we hand the SW a port and await its reply —
|
||||||
|
* so each call resolves with that request's result rather than a global event.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AudioCacheStats {
|
||||||
|
count: number;
|
||||||
|
bytes: number;
|
||||||
|
maxBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the service worker. No-op when unsupported (e.g. plain http host). */
|
||||||
|
export function registerServiceWorker(): void {
|
||||||
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
// Module worker: sw.js uses ES imports (see public/sw.js + sw-core.js).
|
||||||
|
navigator.serviceWorker.register('/sw.js', { type: 'module' }).catch(() => {
|
||||||
|
/* SW unavailable (insecure origin, blocked, …) — app still works online */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function controller(): ServiceWorker | null {
|
||||||
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return navigator.serviceWorker.controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round-trip a message to the SW; rejects if no controlling SW is present. */
|
||||||
|
function send<T>(message: Record<string, unknown>): Promise<T> {
|
||||||
|
const sw = controller();
|
||||||
|
if (!sw) return Promise.reject(new Error('no-service-worker'));
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
channel.port1.onmessage = (event) => resolve(event.data as T);
|
||||||
|
try {
|
||||||
|
sw.postMessage(message, [channel.port2]);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err as Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total size + count of cached audio, or null when the SW isn't controlling. */
|
||||||
|
export async function getAudioCacheStats(): Promise<AudioCacheStats | null> {
|
||||||
|
try {
|
||||||
|
return await send<AudioCacheStats>({ type: 'STATS' });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a given stream URL is already cached for offline playback. */
|
||||||
|
export async function isStreamCached(streamUrl: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { cached } = await send<{ cached: boolean }>({
|
||||||
|
type: 'HAS',
|
||||||
|
url: streamUrl,
|
||||||
|
});
|
||||||
|
return cached;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the entire audio cache. Resolves false if the SW isn't controlling. */
|
||||||
|
export async function clearAudioCache(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const { ok } = await send<{ ok: boolean }>({ type: 'CLEAR' });
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import authReducer from './slices/auth';
|
|||||||
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';
|
||||||
|
import { loadPlayerState, loadQueueState, startPersistence } from './persist';
|
||||||
|
import { rehydrateApiCache, startApiPersistence } from './rtkqPersist';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@@ -13,9 +15,23 @@ export const store = configureStore({
|
|||||||
queue: queueReducer,
|
queue: queueReducer,
|
||||||
ui: uiReducer,
|
ui: uiReducer,
|
||||||
},
|
},
|
||||||
|
// Tier 1 offline: rehydrate queue/player from the active backend's namespace
|
||||||
|
// so a reload (even with no backend) restores exactly where the user left off.
|
||||||
|
preloadedState: {
|
||||||
|
queue: loadQueueState(),
|
||||||
|
player: loadPlayerState(),
|
||||||
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware().concat(api.middleware),
|
getDefaultMiddleware().concat(api.middleware),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Flush queue/player changes back to localStorage (throttled).
|
||||||
|
startPersistence(store);
|
||||||
|
|
||||||
|
// Tier 2 offline: replay the last-seen RTKQ cache, then keep snapshotting it.
|
||||||
|
// Rehydrate first so cached server data is present before any component mounts.
|
||||||
|
rehydrateApiCache(store.dispatch);
|
||||||
|
startApiPersistence(store);
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>;
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
export type AppDispatch = typeof store.dispatch;
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
* Tier 1 offline support: persist client state (queue + player) to the active
|
||||||
|
* backend's localStorage namespace, mirroring the auth slice. This is what lets
|
||||||
|
* the UI come back exactly as the user left it after a reload with no backend
|
||||||
|
* reachable — no server data is duplicated (the queue stores track IDs + minimal
|
||||||
|
* display fields only; full track records still live in the RTKQ cache).
|
||||||
|
*
|
||||||
|
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
|
||||||
|
*/
|
||||||
|
import { instanceStorage } from '../config/instances';
|
||||||
|
import {
|
||||||
|
queueInitialState,
|
||||||
|
type QueueState,
|
||||||
|
} from './slices/queue';
|
||||||
|
import {
|
||||||
|
playerInitialState,
|
||||||
|
type PlayerState,
|
||||||
|
} from './slices/player';
|
||||||
|
import type { RootState } from './index';
|
||||||
|
|
||||||
|
const QUEUE_KEY = 'queue';
|
||||||
|
const PLAYER_KEY = 'player';
|
||||||
|
|
||||||
|
// Only persist fields that make sense to restore. `duration`/`isPlaying` are
|
||||||
|
// derived from the <audio> element on next load, and the panel toggles are
|
||||||
|
// transient UI, so they are intentionally left out.
|
||||||
|
type PersistedQueue = Pick<
|
||||||
|
QueueState,
|
||||||
|
'entries' | 'currentIndex' | 'source' | 'sourceId' | 'sourceName'
|
||||||
|
>;
|
||||||
|
type PersistedPlayer = Pick<
|
||||||
|
PlayerState,
|
||||||
|
'currentTrackId' | 'position' | 'volume' | 'muted' | 'repeat' | 'shuffle'
|
||||||
|
>;
|
||||||
|
|
||||||
|
function pickQueue(state: QueueState): PersistedQueue {
|
||||||
|
return {
|
||||||
|
entries: state.entries,
|
||||||
|
currentIndex: state.currentIndex,
|
||||||
|
source: state.source,
|
||||||
|
sourceId: state.sourceId,
|
||||||
|
sourceName: state.sourceName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPlayer(state: PlayerState): PersistedPlayer {
|
||||||
|
return {
|
||||||
|
currentTrackId: state.currentTrackId,
|
||||||
|
position: state.position,
|
||||||
|
volume: state.volume,
|
||||||
|
muted: state.muted,
|
||||||
|
repeat: state.repeat,
|
||||||
|
shuffle: state.shuffle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function read<T>(key: string): Partial<T> | null {
|
||||||
|
try {
|
||||||
|
const raw = instanceStorage.get(key);
|
||||||
|
return raw ? (JSON.parse(raw) as Partial<T>) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the queue slice's initial state, restoring any persisted queue. */
|
||||||
|
export function loadQueueState(): QueueState {
|
||||||
|
const persisted = read<PersistedQueue>(QUEUE_KEY);
|
||||||
|
if (!persisted) return queueInitialState;
|
||||||
|
const merged: QueueState = { ...queueInitialState, ...persisted };
|
||||||
|
// Guard the index against a corrupted/short entries array.
|
||||||
|
if (
|
||||||
|
merged.currentIndex >= merged.entries.length ||
|
||||||
|
merged.currentIndex < -1
|
||||||
|
) {
|
||||||
|
merged.currentIndex = merged.entries.length ? 0 : -1;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the player slice's initial state, restoring any persisted player. */
|
||||||
|
export function loadPlayerState(): PlayerState {
|
||||||
|
const persisted = read<PersistedPlayer>(PLAYER_KEY);
|
||||||
|
if (!persisted) return playerInitialState;
|
||||||
|
// Never auto-resume playback on load: browsers block autoplay and the
|
||||||
|
// <audio> element starts paused regardless. isPlaying stays false.
|
||||||
|
return { ...playerInitialState, ...persisted, isPlaying: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe a store so queue/player changes are flushed to localStorage. The
|
||||||
|
* write is throttled because `setPosition` fires several times a second during
|
||||||
|
* playback — without throttling we'd hammer localStorage on every tick.
|
||||||
|
*/
|
||||||
|
export function startPersistence(store: {
|
||||||
|
getState: () => RootState;
|
||||||
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
}): () => void {
|
||||||
|
const initial = store.getState();
|
||||||
|
let lastQueue = JSON.stringify(pickQueue(initial.queue));
|
||||||
|
let lastPlayer = JSON.stringify(pickPlayer(initial.player));
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
timer = null;
|
||||||
|
const state = store.getState();
|
||||||
|
const queueSnapshot = JSON.stringify(pickQueue(state.queue));
|
||||||
|
if (queueSnapshot !== lastQueue) {
|
||||||
|
instanceStorage.set(QUEUE_KEY, queueSnapshot);
|
||||||
|
lastQueue = queueSnapshot;
|
||||||
|
}
|
||||||
|
const playerSnapshot = JSON.stringify(pickPlayer(state.player));
|
||||||
|
if (playerSnapshot !== lastPlayer) {
|
||||||
|
instanceStorage.set(PLAYER_KEY, playerSnapshot);
|
||||||
|
lastPlayer = playerSnapshot;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return store.subscribe(() => {
|
||||||
|
if (timer) return;
|
||||||
|
timer = setTimeout(flush, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* Tier 2 offline support: persist the RTK Query cache (last-seen server data —
|
||||||
|
* library/albums/artists/…) to the active backend's localStorage namespace and
|
||||||
|
* replay it into the cache on startup. With the backend down, components keep
|
||||||
|
* rendering the last-known data read-only instead of an error state.
|
||||||
|
*
|
||||||
|
* Per the architecture invariant, server data is NOT copied into a slice: this
|
||||||
|
* snapshots the RTKQ cache itself and feeds it back through RTKQ's own
|
||||||
|
* `extractRehydrationInfo` mechanism (see `api/index.ts`).
|
||||||
|
*/
|
||||||
|
import { instanceStorage } from '../config/instances';
|
||||||
|
import { rehydrateApi, type RehydrateApiPayload } from '../api/rehydrate';
|
||||||
|
import type { RootState } from './index';
|
||||||
|
|
||||||
|
const CACHE_KEY = 'rtkq';
|
||||||
|
|
||||||
|
type ApiState = RootState['api'];
|
||||||
|
type QueryEntry = ApiState['queries'][string];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only successfully-fulfilled query results — pending/rejected entries
|
||||||
|
* carry no usable data and subscriptions are rebuilt by components on mount.
|
||||||
|
* Mutation results are never restored.
|
||||||
|
*/
|
||||||
|
function snapshot(apiState: ApiState): RehydrateApiPayload {
|
||||||
|
const queries: Record<string, unknown> = {};
|
||||||
|
for (const [key, entry] of Object.entries(apiState.queries)) {
|
||||||
|
const q = entry as QueryEntry | undefined;
|
||||||
|
if (q && q.status === 'fulfilled') queries[key] = q;
|
||||||
|
}
|
||||||
|
return { queries, mutations: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(): RehydrateApiPayload | null {
|
||||||
|
try {
|
||||||
|
const raw = instanceStorage.get(CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
|
||||||
|
if (!parsed.queries) return null;
|
||||||
|
return { queries: parsed.queries, mutations: {} };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replay the persisted cache into RTKQ. Call once after the store is created. */
|
||||||
|
export function rehydrateApiCache(dispatch: (action: unknown) => void): void {
|
||||||
|
const cached = load();
|
||||||
|
if (cached) dispatch(rehydrateApi(cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe a store so the RTKQ cache is flushed to localStorage. Throttled,
|
||||||
|
* since cache state churns on every in-flight query transition.
|
||||||
|
*/
|
||||||
|
export function startApiPersistence(store: {
|
||||||
|
getState: () => RootState;
|
||||||
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
}): () => void {
|
||||||
|
let last = '';
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
timer = null;
|
||||||
|
const snap = JSON.stringify(snapshot(store.getState().api));
|
||||||
|
if (snap !== last) {
|
||||||
|
instanceStorage.set(CACHE_KEY, snap);
|
||||||
|
last = snap;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return store.subscribe(() => {
|
||||||
|
if (timer) return;
|
||||||
|
timer = setTimeout(flush, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
type RepeatMode = 'none' | 'one' | 'all';
|
export type RepeatMode = 'none' | 'one' | 'all';
|
||||||
|
|
||||||
interface PlayerState {
|
export interface PlayerState {
|
||||||
currentTrackId: string | null;
|
currentTrackId: string | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
@@ -15,7 +15,7 @@ interface PlayerState {
|
|||||||
isQueueOpen: boolean;
|
isQueueOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: PlayerState = {
|
export const playerInitialState: PlayerState = {
|
||||||
currentTrackId: null,
|
currentTrackId: null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
position: 0,
|
position: 0,
|
||||||
@@ -25,14 +25,12 @@ const initialState: PlayerState = {
|
|||||||
repeat: 'none',
|
repeat: 'none',
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
isNowPlayingOpen: false,
|
isNowPlayingOpen: false,
|
||||||
// STUB: open by default so the queue drawer look is visible before a backend
|
isQueueOpen: false,
|
||||||
// exists (pairs with DEMO_QUEUE). Default to false once real playback lands.
|
|
||||||
isQueueOpen: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playerSlice = createSlice({
|
export const playerSlice = createSlice({
|
||||||
name: 'player',
|
name: 'player',
|
||||||
initialState,
|
initialState: playerInitialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
play(state, action: PayloadAction<string>) {
|
play(state, action: PayloadAction<string>) {
|
||||||
state.currentTrackId = action.payload;
|
state.currentTrackId = action.payload;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
type QueueSource =
|
export type QueueSource =
|
||||||
| 'manual'
|
| 'manual'
|
||||||
| 'album'
|
| 'album'
|
||||||
| 'playlist'
|
| 'playlist'
|
||||||
@@ -8,7 +8,7 @@ type QueueSource =
|
|||||||
| 'search'
|
| 'search'
|
||||||
| 'radio';
|
| 'radio';
|
||||||
|
|
||||||
interface QueueEntry {
|
export interface QueueEntry {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
title: string;
|
title: string;
|
||||||
artistName: string;
|
artistName: string;
|
||||||
@@ -17,7 +17,7 @@ interface QueueEntry {
|
|||||||
albumArtUrl?: string;
|
albumArtUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueueState {
|
export interface QueueState {
|
||||||
entries: QueueEntry[];
|
entries: QueueEntry[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
source: QueueSource;
|
source: QueueSource;
|
||||||
@@ -25,52 +25,17 @@ interface QueueState {
|
|||||||
sourceName: string | null;
|
sourceName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STUB demo queue — purely client-side display data so the player bar and
|
export const queueInitialState: QueueState = {
|
||||||
// queue drawer render with content before the backend exists. Delete this
|
entries: [],
|
||||||
// block (reset entries/currentIndex/source to the empty values) once real
|
currentIndex: -1,
|
||||||
// playback wires tracks into the queue.
|
source: 'manual',
|
||||||
const DEMO_QUEUE: QueueEntry[] = [
|
|
||||||
{
|
|
||||||
trackId: 'd1',
|
|
||||||
title: 'Quiet Storage',
|
|
||||||
artistName: 'Cyan Atlas',
|
|
||||||
albumTitle: 'Night Index',
|
|
||||||
durationMs: 312_000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
trackId: 'd2',
|
|
||||||
title: 'Magnetic North',
|
|
||||||
artistName: 'Tidal Bloom',
|
|
||||||
albumTitle: 'Ferric Coast',
|
|
||||||
durationMs: 243_000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
trackId: 'd3',
|
|
||||||
title: 'Ambergris',
|
|
||||||
artistName: 'Møller',
|
|
||||||
albumTitle: 'Warm Static',
|
|
||||||
durationMs: 201_000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
trackId: 'd4',
|
|
||||||
title: 'Slow Carrier',
|
|
||||||
artistName: 'Tidal Bloom',
|
|
||||||
albumTitle: 'Ferric Coast',
|
|
||||||
durationMs: 301_000,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialState: QueueState = {
|
|
||||||
entries: DEMO_QUEUE,
|
|
||||||
currentIndex: 0,
|
|
||||||
source: 'radio',
|
|
||||||
sourceId: null,
|
sourceId: null,
|
||||||
sourceName: 'My radio',
|
sourceName: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queueSlice = createSlice({
|
export const queueSlice = createSlice({
|
||||||
name: 'queue',
|
name: 'queue',
|
||||||
initialState,
|
initialState: queueInitialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setQueue(
|
setQueue(
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// @rstest-environment jsdom
|
||||||
|
import { expect, test, beforeEach, rstest } from '@rstest/core';
|
||||||
|
import {
|
||||||
|
loadQueueState,
|
||||||
|
loadPlayerState,
|
||||||
|
startPersistence,
|
||||||
|
} from '../src/store/persist';
|
||||||
|
import { queueInitialState, type QueueState } from '../src/store/slices/queue';
|
||||||
|
import {
|
||||||
|
playerInitialState,
|
||||||
|
type PlayerState,
|
||||||
|
} from '../src/store/slices/player';
|
||||||
|
import {
|
||||||
|
upsertInstance,
|
||||||
|
setActiveInstanceId,
|
||||||
|
instanceStorage,
|
||||||
|
} from '../src/config/instances';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
const inst = upsertInstance('http://test.local');
|
||||||
|
setActiveInstanceId(inst.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sampleQueue: QueueState = {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
trackId: 't1',
|
||||||
|
title: 'A',
|
||||||
|
artistName: 'X',
|
||||||
|
albumTitle: 'Alb',
|
||||||
|
durationMs: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trackId: 't2',
|
||||||
|
title: 'B',
|
||||||
|
artistName: 'Y',
|
||||||
|
albumTitle: 'Alb',
|
||||||
|
durationMs: 2000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
currentIndex: 1,
|
||||||
|
source: 'album',
|
||||||
|
sourceId: 'alb-1',
|
||||||
|
sourceName: 'My Album',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('loaders fall back to initial state with nothing persisted', () => {
|
||||||
|
expect(loadQueueState()).toEqual(queueInitialState);
|
||||||
|
expect(loadPlayerState()).toEqual(playerInitialState);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadQueueState restores a persisted queue', () => {
|
||||||
|
instanceStorage.set('queue', JSON.stringify(sampleQueue));
|
||||||
|
expect(loadQueueState()).toEqual(sampleQueue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadQueueState guards a currentIndex past the entries array', () => {
|
||||||
|
instanceStorage.set(
|
||||||
|
'queue',
|
||||||
|
JSON.stringify({ ...sampleQueue, currentIndex: 99 }),
|
||||||
|
);
|
||||||
|
expect(loadQueueState().currentIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadPlayerState restores fields but never auto-resumes playback', () => {
|
||||||
|
instanceStorage.set(
|
||||||
|
'player',
|
||||||
|
JSON.stringify({
|
||||||
|
currentTrackId: 't2',
|
||||||
|
position: 42,
|
||||||
|
volume: 0.5,
|
||||||
|
muted: true,
|
||||||
|
repeat: 'all',
|
||||||
|
shuffle: true,
|
||||||
|
// a stale isPlaying:true must not survive a reload
|
||||||
|
isPlaying: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const loaded = loadPlayerState();
|
||||||
|
expect(loaded.currentTrackId).toBe('t2');
|
||||||
|
expect(loaded.position).toBe(42);
|
||||||
|
expect(loaded.volume).toBe(0.5);
|
||||||
|
expect(loaded.repeat).toBe('all');
|
||||||
|
expect(loaded.isPlaying).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupt JSON falls back to initial state', () => {
|
||||||
|
instanceStorage.set('queue', '{not json');
|
||||||
|
expect(loadQueueState()).toEqual(queueInitialState);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startPersistence flushes changed state to storage after throttle', () => {
|
||||||
|
rstest.useFakeTimers();
|
||||||
|
let state = {
|
||||||
|
queue: queueInitialState,
|
||||||
|
player: playerInitialState,
|
||||||
|
} as { queue: QueueState; player: PlayerState };
|
||||||
|
let listener: (() => void) | null = null;
|
||||||
|
const store = {
|
||||||
|
getState: () => state as never,
|
||||||
|
subscribe: (l: () => void) => {
|
||||||
|
listener = l;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
startPersistence(store);
|
||||||
|
|
||||||
|
// mutate + notify
|
||||||
|
state = { ...state, queue: sampleQueue };
|
||||||
|
listener!();
|
||||||
|
// nothing written before the throttle window elapses
|
||||||
|
expect(instanceStorage.get('queue')).toBeNull();
|
||||||
|
|
||||||
|
rstest.advanceTimersByTime(1000);
|
||||||
|
expect(JSON.parse(instanceStorage.get('queue')!).currentIndex).toBe(1);
|
||||||
|
|
||||||
|
rstest.useRealTimers();
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// @rstest-environment jsdom
|
||||||
|
import { expect, test, beforeEach, rstest } from '@rstest/core';
|
||||||
|
import {
|
||||||
|
rehydrateApiCache,
|
||||||
|
startApiPersistence,
|
||||||
|
} from '../src/store/rtkqPersist';
|
||||||
|
import { REHYDRATE_API } from '../src/api/rehydrate';
|
||||||
|
import {
|
||||||
|
upsertInstance,
|
||||||
|
setActiveInstanceId,
|
||||||
|
instanceStorage,
|
||||||
|
} from '../src/config/instances';
|
||||||
|
import type { RootState } from '../src/store/index';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
const inst = upsertInstance('http://test.local');
|
||||||
|
setActiveInstanceId(inst.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
function apiStateWith(queries: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
api: { queries, mutations: {}, provided: {}, subscriptions: {}, config: {} },
|
||||||
|
} as unknown as RootState;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rehydrateApiCache dispatches nothing when no cache is stored', () => {
|
||||||
|
const dispatched: unknown[] = [];
|
||||||
|
rehydrateApiCache((a) => dispatched.push(a));
|
||||||
|
expect(dispatched).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rehydrateApiCache replays a stored cache as a rehydrate action', () => {
|
||||||
|
instanceStorage.set(
|
||||||
|
'rtkq',
|
||||||
|
JSON.stringify({
|
||||||
|
queries: { 'getLibrary(undefined)': { status: 'fulfilled', data: [1] } },
|
||||||
|
mutations: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const dispatched: Array<{ type: string; payload: unknown }> = [];
|
||||||
|
rehydrateApiCache((a) =>
|
||||||
|
dispatched.push(a as { type: string; payload: unknown }),
|
||||||
|
);
|
||||||
|
expect(dispatched).toHaveLength(1);
|
||||||
|
expect(dispatched[0].type).toBe(REHYDRATE_API);
|
||||||
|
expect(dispatched[0].payload).toMatchObject({
|
||||||
|
queries: { 'getLibrary(undefined)': { status: 'fulfilled' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startApiPersistence saves only fulfilled queries after throttle', () => {
|
||||||
|
rstest.useFakeTimers();
|
||||||
|
let state = apiStateWith({});
|
||||||
|
let listener: (() => void) | null = null;
|
||||||
|
const store = {
|
||||||
|
getState: () => state,
|
||||||
|
subscribe: (l: () => void) => {
|
||||||
|
listener = l;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
startApiPersistence(store);
|
||||||
|
|
||||||
|
state = apiStateWith({
|
||||||
|
'getAlbums(undefined)': { status: 'fulfilled', data: ['a'] },
|
||||||
|
'getArtists(undefined)': { status: 'pending' },
|
||||||
|
'getTracks(undefined)': { status: 'rejected', error: 'boom' },
|
||||||
|
});
|
||||||
|
listener!();
|
||||||
|
|
||||||
|
// throttled — nothing yet
|
||||||
|
expect(instanceStorage.get('rtkq')).toBeNull();
|
||||||
|
|
||||||
|
rstest.advanceTimersByTime(2000);
|
||||||
|
const saved = JSON.parse(instanceStorage.get('rtkq')!);
|
||||||
|
expect(Object.keys(saved.queries)).toEqual(['getAlbums(undefined)']);
|
||||||
|
expect(saved.mutations).toEqual({});
|
||||||
|
|
||||||
|
rstest.useRealTimers();
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, test } from '@rstest/core';
|
||||||
|
import {
|
||||||
|
trackIdFromUrl,
|
||||||
|
cacheKeyFor,
|
||||||
|
parseRangeHeader,
|
||||||
|
selectEvictions,
|
||||||
|
} from '../public/sw-core.js';
|
||||||
|
|
||||||
|
test('trackIdFromUrl extracts the content id from a stream URL', () => {
|
||||||
|
expect(
|
||||||
|
trackIdFromUrl('https://host/api/v1/streaming/tracks/abc123?token=xyz'),
|
||||||
|
).toBe('abc123');
|
||||||
|
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cacheKeyFor strips the token so the key is token-stable', () => {
|
||||||
|
const a = cacheKeyFor('https://host/api/v1/streaming/tracks/t1?token=AAA');
|
||||||
|
const b = cacheKeyFor('https://host/api/v1/streaming/tracks/t1?token=BBB');
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(a).toBe('https://host/api/v1/streaming/tracks/t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cacheKeyFor keeps different origins distinct', () => {
|
||||||
|
expect(cacheKeyFor('https://a/streaming/tracks/t1?token=x')).not.toBe(
|
||||||
|
cacheKeyFor('https://b/streaming/tracks/t1?token=x'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRangeHeader: closed range', () => {
|
||||||
|
expect(parseRangeHeader('bytes=0-99', 1000)).toEqual({ start: 0, end: 99 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRangeHeader: open-ended range clamps to size', () => {
|
||||||
|
expect(parseRangeHeader('bytes=500-', 1000)).toEqual({
|
||||||
|
start: 500,
|
||||||
|
end: 999,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRangeHeader: suffix range (last N bytes)', () => {
|
||||||
|
expect(parseRangeHeader('bytes=-200', 1000)).toEqual({
|
||||||
|
start: 800,
|
||||||
|
end: 999,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRangeHeader: end past size is clamped', () => {
|
||||||
|
expect(parseRangeHeader('bytes=900-5000', 1000)).toEqual({
|
||||||
|
start: 900,
|
||||||
|
end: 999,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseRangeHeader: invalid / no range returns null', () => {
|
||||||
|
expect(parseRangeHeader('', 1000)).toBeNull();
|
||||||
|
expect(parseRangeHeader('items=0-1', 1000)).toBeNull();
|
||||||
|
expect(parseRangeHeader('bytes=500-100', 1000)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectEvictions: nothing evicted when under cap', () => {
|
||||||
|
const index = {
|
||||||
|
a: { size: 100, lastAccess: 1 },
|
||||||
|
b: { size: 100, lastAccess: 2 },
|
||||||
|
};
|
||||||
|
expect(selectEvictions(index, 100, 1000)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectEvictions: evicts least-recently-used first until it fits', () => {
|
||||||
|
const index = {
|
||||||
|
a: { size: 400, lastAccess: 10 }, // oldest
|
||||||
|
b: { size: 400, lastAccess: 30 },
|
||||||
|
c: { size: 400, lastAccess: 20 },
|
||||||
|
};
|
||||||
|
// total 1200 + incoming 400 = 1600, cap 1000 → must free >=600.
|
||||||
|
// LRU order: a (10), c (20). Evict a (1200→800... wait incl incoming)
|
||||||
|
const evicted = selectEvictions(index, 400, 1000);
|
||||||
|
// total with incoming = 1600; evict a → 1200; evict c → 800 <= 1000.
|
||||||
|
expect(evicted).toEqual(['a', 'c']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user