diff --git a/apps/api/src/routes/versions.ts b/apps/api/src/routes/versions.ts index 2c7d907..f2a2dd6 100644 --- a/apps/api/src/routes/versions.ts +++ b/apps/api/src/routes/versions.ts @@ -4,7 +4,7 @@ import { eq, and, desc, asc, sql } from 'drizzle-orm'; import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } from '@music-hub/shared'; import { tracks, versions, projectMembers } from '@music-hub/db'; 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 type { AppEnv } from '../types.js'; @@ -389,6 +389,67 @@ export const versionRoutes = new Hono() 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 .post('/:id/reject', async (c) => { const db = c.get('db'); diff --git a/apps/web/package.json b/apps/web/package.json index 063aebd..1da5d89 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@fontsource-variable/inter": "^5.2.8", "@music-hub/shared": "workspace:*", "@sveltejs/adapter-node": "^5.5.4", + "idb": "^8.0.3", "wavesurfer.js": "^7.12.5" }, "devDependencies": { diff --git a/apps/web/src/lib/components/ui/Icon.svelte b/apps/web/src/lib/components/ui/Icon.svelte index 790418c..c2324ba 100644 --- a/apps/web/src/lib/components/ui/Icon.svelte +++ b/apps/web/src/lib/components/ui/Icon.svelte @@ -7,7 +7,8 @@ | 'download' | 'upload' | 'share' | 'plus' | 'check' | 'x' | 'close' | 'chevron-down' | 'chevron-right' | 'more' | 'home' | 'panel' | 'panel-off' | '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 { name, @@ -138,6 +139,13 @@ + {:else if name === 'cloud-download'} + + + + {:else if name === 'cloud-check'} + + {/if} diff --git a/apps/web/src/lib/offline/db.ts b/apps/web/src/lib/offline/db.ts new file mode 100644 index 0000000..553da4e --- /dev/null +++ b/apps/web/src/lib/offline/db.ts @@ -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 | null = null; + +export async function getDb(): Promise> { + if (_db) return _db; + _db = await openDB('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 { + const db = await getDb(); + return db.getAll('offlineVersions'); +} + +export async function getOfflineVersion(versionId: string): Promise { + const db = await getDb(); + return db.get('offlineVersions', versionId); +} + +export async function saveOfflineVersion(record: OfflineVersionRecord): Promise { + const db = await getDb(); + await db.put('offlineVersions', record); +} + +export async function deleteOfflineVersion(versionId: string): Promise { + const db = await getDb(); + await db.delete('offlineVersions', versionId); +} diff --git a/apps/web/src/lib/stores/offline.ts b/apps/web/src/lib/stores/offline.ts new file mode 100644 index 0000000..b07a17d --- /dev/null +++ b/apps/web/src/lib/stores/offline.ts @@ -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([]); + +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 { + 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 { + 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 { + 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); +} diff --git a/apps/web/src/routes/(app)/offline/+page.svelte b/apps/web/src/routes/(app)/offline/+page.svelte new file mode 100644 index 0000000..b6ad783 --- /dev/null +++ b/apps/web/src/routes/(app)/offline/+page.svelte @@ -0,0 +1,170 @@ + + + + {#snippet actions()} + {#if offlineVersions.value.length > 0} + + {/if} + {/snippet} + + +
+ {#if offlineVersions.value.length === 0} + + {:else} +
+ Gerätespeicher belegt: + {formatBytes(storageUsed)} / {formatBytes(storageQuota)} +
+ +
+ {#each offlineVersions.value as v (v.versionId)} +
+
+ {v.title} + + V{v.versionNumber} + · {v.quality === 'stream' ? 'Stream (MP3)' : 'Original'} + · {formatBytes(v.sizeBytes)} + · {formatDate(v.downloadedAt)} + +
+ +
+ {/each} +
+ {/if} +
+ + diff --git a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte index 0a2b429..0bca515 100644 --- a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte +++ b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte @@ -18,6 +18,15 @@ import TrackStatusPill from '$lib/components/ui/TrackStatusPill.svelte'; import { onKey } from '$lib/utils/shortcuts.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 VersionInfo from './components/VersionInfo.svelte'; import VersionGraph from './components/VersionGraph.svelte'; @@ -93,12 +102,16 @@ let editVersionLabel = $state(''); let editVersionNotes = $state(''); 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 canApprove = $derived(['owner', 'artist', 'label', 'management'].includes(role)); const canComment = $derived(role !== 'viewer'); onMount(async () => { + await initOfflineStore(); try { const [projectRes, trackVersions, tracksRes, treeRes, stemsRes] = await Promise.all([ api.get<{ project: { name: string }; role: string }>(`/projects/${projectId}`), @@ -135,6 +148,17 @@ nextAutoPlay = cont?.autoPlay ?? false; 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([ api.get<{ url: string }>(`/versions/${version.id}/stream-url`), 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() { if (!selectedVersion) return; if (!confirm(`Version V${selectedVersion.versionNumber} wirklich löschen? Das kann nicht rückgängig gemacht werden.`)) return; @@ -418,6 +469,31 @@ + {#if selectedVersion} +
+ {#if isOffline(selectedVersion.id)} + + {:else if offlineDownloading} + {offlineProgress}% + {:else} + + {/if} + {#if offlineDropdownOpen} + + {/if} +
+ {/if} {#if canUpload}