feat: PWA Phase 1 — offline audio download and playback

- API: GET /versions/:id/audio?quality=stream|original (server proxy for SW caching)
- API: GET /versions/:id/waveform-data (server proxy for offline waveform)
- SW: cache-first from musichub-offline-v1 for proxied audio/waveform endpoints
- Client: IDB-backed offline store (idb lib) with progress-tracked download
- UI: per-version offline download button with stream/original quality picker
- UI: /offline page with storage estimate and remove-all action
- Manifest: shortcuts for Dashboard + Offline-Tracks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-16 22:17:01 +02:00
parent e642e63fdc
commit e58a7c250e
10 changed files with 603 additions and 5 deletions

View File

@@ -4,7 +4,7 @@ import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } from '@music-hub/shared'; import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } from '@music-hub/shared';
import { tracks, versions, projectMembers } from '@music-hub/db'; import { tracks, versions, projectMembers } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js'; import { createUploadUrl, createDownloadUrl, getObjectBuffer } from '../storage/s3.js';
import { processVersion } from '../services/audio-processor.js'; import { processVersion } from '../services/audio-processor.js';
import type { AppEnv } from '../types.js'; import type { AppEnv } from '../types.js';
@@ -389,6 +389,67 @@ export const versionRoutes = new Hono<AppEnv>()
return c.json({ version: updated }); return c.json({ version: updated });
}) })
// Proxy audio for offline download
.get('/:id/audio', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const quality = c.req.query('quality') === 'original' ? 'original' : 'stream';
const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const [track] = await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership) return c.json({ error: 'Not found' }, 404);
const useOriginal = quality === 'original' || !version.streamFileKey;
const key = useOriginal ? version.originalFileKey : version.streamFileKey!;
const contentType = useOriginal ? (version.mimeType || 'audio/wav') : 'audio/mpeg';
const buffer = await getObjectBuffer(key);
return new Response(buffer, {
headers: {
'Content-Type': contentType,
'Content-Length': String(buffer.byteLength),
'Cache-Control': 'private, max-age=3600',
'ETag': `"${versionId}-${quality}"`,
},
});
})
// Proxy waveform peaks for offline
.get('/:id/waveform-data', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1);
if (!version || !version.waveformDataKey) return c.json({ error: 'Not found' }, 404);
const [track] = await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership) return c.json({ error: 'Not found' }, 404);
const buffer = await getObjectBuffer(version.waveformDataKey);
return new Response(buffer, {
headers: {
'Content-Type': 'application/json',
'Content-Length': String(buffer.byteLength),
'Cache-Control': 'private, max-age=86400',
'ETag': `"${versionId}-waveform"`,
},
});
})
// Reject version // Reject version
.post('/:id/reject', async (c) => { .post('/:id/reject', async (c) => {
const db = c.get('db'); const db = c.get('db');

View File

@@ -14,6 +14,7 @@
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@music-hub/shared": "workspace:*", "@music-hub/shared": "workspace:*",
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"idb": "^8.0.3",
"wavesurfer.js": "^7.12.5" "wavesurfer.js": "^7.12.5"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -7,7 +7,8 @@
| 'download' | 'upload' | 'share' | 'plus' | 'check' | 'x' | 'close' | 'download' | 'upload' | 'share' | 'plus' | 'check' | 'x' | 'close'
| 'chevron-down' | 'chevron-right' | 'more' | 'home' | 'panel' | 'panel-off' | 'chevron-down' | 'chevron-right' | 'more' | 'home' | 'panel' | 'panel-off'
| 'git-branch' | 'arrow-up' | 'compare' | 'comment' | 'lock' | 'link' | 'git-branch' | 'arrow-up' | 'compare' | 'comment' | 'lock' | 'link'
| 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search' | 'music'; | 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search' | 'music'
| 'cloud-download' | 'cloud-check';
let { let {
name, name,
@@ -138,6 +139,13 @@
<path d="M9 18V5l12-2v13" /> <path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" /> <circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" /> <circle cx="18" cy="16" r="3" />
{:else if name === 'cloud-download'}
<polyline points="8 17 12 21 16 17" />
<line x1="12" y1="12" x2="12" y2="21" />
<path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29" />
{:else if name === 'cloud-check'}
<polyline points="20 6 9 17 4 12" />
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z" />
{/if} {/if}
</svg> </svg>

View File

@@ -0,0 +1,60 @@
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
export type OfflineQuality = 'stream' | 'original';
export interface OfflineVersionRecord {
versionId: string;
trackId: string;
projectId: string;
title: string;
versionNumber: number;
downloadedAt: number;
sizeBytes: number;
mimeType: string;
quality: OfflineQuality;
}
interface MusicHubDB extends DBSchema {
offlineVersions: {
key: string;
value: OfflineVersionRecord;
indexes: {
byTrack: string;
byProject: string;
};
};
}
let _db: IDBPDatabase<MusicHubDB> | null = null;
export async function getDb(): Promise<IDBPDatabase<MusicHubDB>> {
if (_db) return _db;
_db = await openDB<MusicHubDB>('musichub', 1, {
upgrade(db) {
const store = db.createObjectStore('offlineVersions', { keyPath: 'versionId' });
store.createIndex('byTrack', 'trackId');
store.createIndex('byProject', 'projectId');
},
});
return _db;
}
export async function getAllOfflineVersions(): Promise<OfflineVersionRecord[]> {
const db = await getDb();
return db.getAll('offlineVersions');
}
export async function getOfflineVersion(versionId: string): Promise<OfflineVersionRecord | undefined> {
const db = await getDb();
return db.get('offlineVersions', versionId);
}
export async function saveOfflineVersion(record: OfflineVersionRecord): Promise<void> {
const db = await getDb();
await db.put('offlineVersions', record);
}
export async function deleteOfflineVersion(versionId: string): Promise<void> {
const db = await getDb();
await db.delete('offlineVersions', versionId);
}

View File

@@ -0,0 +1,133 @@
import {
getAllOfflineVersions,
getOfflineVersion,
saveOfflineVersion,
deleteOfflineVersion,
type OfflineVersionRecord,
type OfflineQuality,
} from '$lib/offline/db.js';
export type { OfflineVersionRecord, OfflineQuality };
const OFFLINE_CACHE = 'musichub-offline-v1';
// Reactive state
let _versions = $state<OfflineVersionRecord[]>([]);
export const offlineVersions = {
get value() {
return _versions;
},
};
export async function initOfflineStore() {
_versions = await getAllOfflineVersions();
}
export function isOffline(versionId: string): boolean {
return _versions.some((v) => v.versionId === versionId);
}
export async function downloadForOffline(
versionId: string,
quality: OfflineQuality,
meta: { trackId: string; projectId: string; title: string; versionNumber: number },
onProgress?: (pct: number) => void,
): Promise<void> {
const audioUrl = `/api/v1/versions/${versionId}/audio?quality=${quality}`;
const waveformUrl = `/api/v1/versions/${versionId}/waveform-data`;
const cache = await caches.open(OFFLINE_CACHE);
// Download audio with progress tracking
const audioRes = await fetch(audioUrl);
if (!audioRes.ok) throw new Error(`Audio-Download fehlgeschlagen: ${audioRes.status}`);
const contentLength = Number(audioRes.headers.get('Content-Length') ?? '0');
let loaded = 0;
const reader = audioRes.body!.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
if (contentLength > 0 && onProgress) {
onProgress(Math.min(99, Math.round((loaded / contentLength) * 100)));
}
}
const audioBuffer = new Uint8Array(chunks.reduce((acc, c) => acc + c.byteLength, 0));
let offset = 0;
for (const chunk of chunks) {
audioBuffer.set(chunk, offset);
offset += chunk.byteLength;
}
const audioBlob = new Blob([audioBuffer], {
type: audioRes.headers.get('Content-Type') ?? 'audio/mpeg',
});
await cache.put(
new Request(audioUrl),
new Response(audioBlob, {
headers: {
'Content-Type': audioBlob.type,
'Content-Length': String(audioBlob.size),
'Cache-Control': 'private, max-age=3600',
},
}),
);
// Download waveform (smaller, no progress needed)
try {
const waveformRes = await fetch(waveformUrl);
if (waveformRes.ok) {
await cache.put(new Request(waveformUrl), waveformRes.clone());
}
} catch {
// Waveform is optional — player falls back to native rendering
}
const record: OfflineVersionRecord = {
versionId,
trackId: meta.trackId,
projectId: meta.projectId,
title: meta.title,
versionNumber: meta.versionNumber,
downloadedAt: Date.now(),
sizeBytes: audioBlob.size,
mimeType: audioBlob.type,
quality,
};
await saveOfflineVersion(record);
_versions = await getAllOfflineVersions();
if (onProgress) onProgress(100);
}
export async function removeOffline(versionId: string): Promise<void> {
const cache = await caches.open(OFFLINE_CACHE);
for (const quality of ['stream', 'original'] as const) {
await cache.delete(new Request(`/api/v1/versions/${versionId}/audio?quality=${quality}`));
}
await cache.delete(new Request(`/api/v1/versions/${versionId}/waveform-data`));
await deleteOfflineVersion(versionId);
_versions = await getAllOfflineVersions();
}
export async function getOfflineAudioUrl(versionId: string): Promise<string | null> {
const record = await getOfflineVersion(versionId);
if (!record) return null;
const cache = await caches.open(OFFLINE_CACHE);
const response = await cache.match(
new Request(`/api/v1/versions/${versionId}/audio?quality=${record.quality}`),
);
if (!response) return null;
const blob = await response.blob();
return URL.createObjectURL(blob);
}

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { onMount } from 'svelte';
import TopBar from '$lib/components/workspace/TopBar.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import { offlineVersions, removeOffline, initOfflineStore } from '$lib/stores/offline.js';
let storageUsed = $state(0);
let storageQuota = $state(0);
let removing = $state<string | null>(null);
onMount(async () => {
await initOfflineStore();
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
storageUsed = estimate.usage ?? 0;
storageQuota = estimate.quota ?? 0;
}
});
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
async function handleRemove(versionId: string) {
removing = versionId;
try {
await removeOffline(versionId);
} finally {
removing = null;
}
}
async function handleRemoveAll() {
if (!confirm('Alle offline-verfügbaren Versionen entfernen?')) return;
for (const v of offlineVersions.value) {
await removeOffline(v.versionId);
}
}
</script>
<TopBar crumbs={[{ label: 'Projekte', href: '/dashboard' }, { label: 'Offline-Tracks' }]}>
{#snippet actions()}
{#if offlineVersions.value.length > 0}
<Button size="sm" variant="ghost" onclick={handleRemoveAll}>Alle entfernen</Button>
{/if}
{/snippet}
</TopBar>
<div class="offline-page">
{#if offlineVersions.value.length === 0}
<EmptyState
title="Keine Offline-Tracks"
description="Öffne einen Track, klicke auf das Cloud-Icon neben einer Version und wähle eine Qualität zum Download."
/>
{:else}
<div class="storage-bar">
<span class="storage-label">Gerätespeicher belegt:</span>
<span class="storage-value">{formatBytes(storageUsed)} / {formatBytes(storageQuota)}</span>
</div>
<div class="version-list">
{#each offlineVersions.value as v (v.versionId)}
<div class="version-row">
<div class="version-info">
<span class="version-title">{v.title}</span>
<span class="version-meta">
V{v.versionNumber}
· {v.quality === 'stream' ? 'Stream (MP3)' : 'Original'}
· {formatBytes(v.sizeBytes)}
· {formatDate(v.downloadedAt)}
</span>
</div>
<button
class="remove-btn"
onclick={() => handleRemove(v.versionId)}
disabled={removing === v.versionId}
aria-label="Offline entfernen"
>
<Icon name="x" size={16} />
</button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.offline-page {
padding: var(--space-6);
max-width: 640px;
}
.storage-bar {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
color: var(--color-text-tertiary);
margin-bottom: var(--space-5);
}
.storage-label {
color: var(--color-text-secondary);
}
.version-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.version-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-2);
border-radius: 8px;
}
.version-info {
flex: 1;
min-width: 0;
}
.version-title {
display: block;
font-size: 0.9rem;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-meta {
display: block;
font-size: 0.78rem;
color: var(--color-text-tertiary);
margin-top: 0.15rem;
}
.remove-btn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-tertiary);
padding: 0.25rem;
border-radius: 4px;
display: flex;
align-items: center;
flex-shrink: 0;
}
.remove-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.remove-btn:disabled {
opacity: 0.4;
cursor: default;
}
</style>

View File

@@ -18,6 +18,15 @@
import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte'; import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte';
import { onKey } from '$lib/utils/shortcuts.js'; import { onKey } from '$lib/utils/shortcuts.js';
import { snapshotForTrack, continuationFor } from '$lib/stores/player.js'; import { snapshotForTrack, continuationFor } from '$lib/stores/player.js';
import {
offlineVersions,
downloadForOffline,
removeOffline,
getOfflineAudioUrl,
initOfflineStore,
isOffline,
type OfflineQuality,
} from '$lib/stores/offline.js';
import { TRACK_STATUSES, TRACK_STATUS_LABELS, type TrackStatus } from '@music-hub/shared'; import { TRACK_STATUSES, TRACK_STATUS_LABELS, type TrackStatus } from '@music-hub/shared';
import VersionInfo from './components/VersionInfo.svelte'; import VersionInfo from './components/VersionInfo.svelte';
import VersionGraph from './components/VersionGraph.svelte'; import VersionGraph from './components/VersionGraph.svelte';
@@ -93,12 +102,16 @@
let editVersionLabel = $state(''); let editVersionLabel = $state('');
let editVersionNotes = $state(''); let editVersionNotes = $state('');
let savingVersion = $state(false); let savingVersion = $state(false);
let offlineDropdownOpen = $state(false);
let offlineDownloading = $state(false);
let offlineProgress = $state(0);
const canUpload = $derived(role === 'owner' || role.includes('engineer')); const canUpload = $derived(role === 'owner' || role.includes('engineer'));
const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role)); const canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role));
const canComment = $derived(role !== 'viewer'); const canComment = $derived(role !== 'viewer');
onMount(async () => { onMount(async () => {
await initOfflineStore();
try { try {
const [projectRes, trackVersions, tracksRes, treeRes, stemsRes] = await Promise.all([ const [projectRes, trackVersions, tracksRes, treeRes, stemsRes] = await Promise.all([
api.get<{ project: { name: string }; role: string }>(`/projects/${projectId}`), api.get<{ project: { name: string }; role: string }>(`/projects/${projectId}`),
@@ -135,6 +148,17 @@
nextAutoPlay = cont?.autoPlay ?? false; nextAutoPlay = cont?.autoPlay ?? false;
selectedVersion = version; selectedVersion = version;
// Use cached audio if offline and version is downloaded
if (!navigator.onLine && isOffline(version.id)) {
const blobUrl = await getOfflineAudioUrl(version.id);
if (blobUrl) {
streamUrl = blobUrl;
comments = [];
return;
}
}
const [streamRes, commentRes] = await Promise.all([ const [streamRes, commentRes] = await Promise.all([
api.get<{ url: string }>(`/versions/${version.id}/stream-url`), api.get<{ url: string }>(`/versions/${version.id}/stream-url`),
api.get<{ comments: Comment[] }>(`/comments/version/${version.id}`), api.get<{ comments: Comment[] }>(`/comments/version/${version.id}`),
@@ -312,6 +336,33 @@
}, },
}); });
async function handleOfflineDownload(quality: OfflineQuality) {
if (!selectedVersion) return;
offlineDropdownOpen = false;
offlineDownloading = true;
offlineProgress = 0;
try {
await downloadForOffline(
selectedVersion.id,
quality,
{ trackId, projectId, title: trackName, versionNumber: selectedVersion.versionNumber },
(pct) => { offlineProgress = pct; },
);
toastSuccess('Offline verfügbar');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler';
toastSuccess(`Download fehlgeschlagen: ${msg}`);
} finally {
offlineDownloading = false;
}
}
async function handleOfflineRemove() {
if (!selectedVersion) return;
await removeOffline(selectedVersion.id);
toastSuccess('Offline-Version entfernt');
}
async function deleteVersion() { async function deleteVersion() {
if (!selectedVersion) return; if (!selectedVersion) return;
if (!confirm(`Version V${selectedVersion.versionNumber} wirklich löschen? Das kann nicht rückgängig gemacht werden.`)) return; if (!confirm(`Version V${selectedVersion.versionNumber} wirklich löschen? Das kann nicht rückgängig gemacht werden.`)) return;
@@ -418,6 +469,31 @@
<Button variant="ghost" size="sm" onclick={handleDownload}> <Button variant="ghost" size="sm" onclick={handleDownload}>
<Icon name="download" size={14} /> Download Original <Icon name="download" size={14} /> Download Original
</Button> </Button>
{#if selectedVersion}
<div class="offline-btn-wrap">
{#if isOffline(selectedVersion.id)}
<Button variant="ghost" size="sm" onclick={handleOfflineRemove}>
<Icon name="cloud-check" size={14} /> Offline
</Button>
{:else if offlineDownloading}
<span class="offline-progress">{offlineProgress}%</span>
{:else}
<Button variant="ghost" size="sm" onclick={() => (offlineDropdownOpen = !offlineDropdownOpen)}>
<Icon name="cloud-download" size={14} /> Offline
</Button>
{/if}
{#if offlineDropdownOpen}
<div class="offline-dropdown" role="menu">
<button onclick={() => handleOfflineDownload('stream')}>
<Icon name="music" size={13} /> Stream (MP3, ~35 MB)
</button>
<button onclick={() => handleOfflineDownload('original')}>
<Icon name="download" size={13} /> Original (WAV/FLAC)
</button>
</div>
{/if}
</div>
{/if}
{#if canUpload} {#if canUpload}
<Button variant="ghost" size="sm" onclick={openVersionEdit}> <Button variant="ghost" size="sm" onclick={openVersionEdit}>
<Icon name="settings" size={14} /> Bearbeiten <Icon name="settings" size={14} /> Bearbeiten
@@ -936,4 +1012,51 @@
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
} }
} }
.offline-btn-wrap {
position: relative;
}
.offline-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 20;
background: var(--color-bg-overlay);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
box-shadow: var(--shadow-md);
white-space: nowrap;
}
.offline-dropdown button {
background: none;
border: none;
padding: 8px 12px;
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
font-size: var(--text-sm);
font-family: inherit;
display: flex;
align-items: center;
gap: 6px;
}
.offline-dropdown button:hover {
background: var(--color-bg-raised);
color: var(--color-text-primary);
}
.offline-progress {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
padding: 0 var(--space-2);
font-variant-numeric: tabular-nums;
}
</style> </style>

View File

@@ -11,6 +11,7 @@ import { build, files, version } from '$service-worker';
const sw = self as unknown as ServiceWorkerGlobalScope; const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE = `musichub-${version}`; const CACHE = `musichub-${version}`;
const OFFLINE_CACHE = 'musichub-offline-v1';
const ASSETS = [...build, ...files]; const ASSETS = [...build, ...files];
sw.addEventListener('install', (event) => { sw.addEventListener('install', (event) => {
@@ -23,7 +24,12 @@ sw.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
caches caches
.keys() .keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) .then((keys) =>
Promise.all(
// Keep the offline cache across version updates
keys.filter((k) => k !== CACHE && k !== OFFLINE_CACHE).map((k) => caches.delete(k)),
),
)
.then(() => sw.clients.claim()), .then(() => sw.clients.claim()),
); );
}); });
@@ -34,8 +40,27 @@ sw.addEventListener('fetch', (event) => {
const url = new URL(req.url); const url = new URL(req.url);
// Don't intercept API or S3 traffic // Don't intercept S3 or other cross-origin traffic
if (url.pathname.startsWith('/api/') || url.hostname !== sw.location.hostname) return; if (url.hostname !== sw.location.hostname) return;
// Cache-first from offline cache for proxied audio/waveform endpoints
const isOfflineAsset =
/^\/api\/v1\/versions\/[^/]+\/audio/.test(url.pathname) ||
/^\/api\/v1\/versions\/[^/]+\/waveform-data$/.test(url.pathname);
if (isOfflineAsset) {
event.respondWith(
caches.open(OFFLINE_CACHE).then(async (cache) => {
const cached = await cache.match(req);
if (cached) return cached;
return fetch(req);
}),
);
return;
}
// Don't intercept other API traffic
if (url.pathname.startsWith('/api/')) return;
// Cache-first for built assets, network-first for everything else // Cache-first for built assets, network-first for everything else
if (ASSETS.includes(url.pathname)) { if (ASSETS.includes(url.pathname)) {

View File

@@ -10,6 +10,20 @@
"theme_color": "#f43f5e", "theme_color": "#f43f5e",
"lang": "de", "lang": "de",
"categories": ["music", "productivity"], "categories": ["music", "productivity"],
"shortcuts": [
{
"name": "Offline-Tracks",
"short_name": "Offline",
"url": "/offline",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
},
{
"name": "Dashboard",
"short_name": "Dashboard",
"url": "/dashboard",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
}
],
"icons": [ "icons": [
{ {
"src": "/icon-192.png", "src": "/icon-192.png",

View File

@@ -32,6 +32,7 @@
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@music-hub/shared": "workspace:*", "@music-hub/shared": "workspace:*",
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"idb": "^8.0.3",
"wavesurfer.js": "^7.12.5", "wavesurfer.js": "^7.12.5",
}, },
"devDependencies": { "devDependencies": {
@@ -487,6 +488,8 @@
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], "hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],