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:
@@ -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');
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
60
apps/web/src/lib/offline/db.ts
Normal file
60
apps/web/src/lib/offline/db.ts
Normal 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);
|
||||||
|
}
|
||||||
133
apps/web/src/lib/stores/offline.ts
Normal file
133
apps/web/src/lib/stores/offline.ts
Normal 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);
|
||||||
|
}
|
||||||
170
apps/web/src/routes/(app)/offline/+page.svelte
Normal file
170
apps/web/src/routes/(app)/offline/+page.svelte
Normal 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>
|
||||||
@@ -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, ~3–5 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>
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -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=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user