From 2150452ae1f8bded46427898ca868d70cc6f0fb6 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 26 Mar 2026 09:16:27 +0100 Subject: [PATCH] feat(storage): add audio player with frequency visualizer Add full audio playback system to the Storage web app, inspired by the Mukke music app: - MiniPlayer bar at bottom with frequency visualizer, progress bar, and controls - FullPlayer fullscreen overlay with mirrored frequency bars background - Inline audio preview in FilePreviewModal with play button - Audio queue from all audio files in the current folder - Presigned S3 URLs for playback, Media Session API for OS controls Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/audio/analyzer.ts | 49 +++ .../lib/components/audio/FrequencyBars.svelte | 154 +++++++ .../lib/components/audio/FullPlayer.svelte | 414 ++++++++++++++++++ .../lib/components/audio/MiniPlayer.svelte | 269 ++++++++++++ .../components/files/FilePreviewModal.svelte | 92 +++- .../web/src/lib/stores/audio-player.svelte.ts | 238 ++++++++++ .../apps/web/src/routes/+layout.svelte | 7 + .../web/src/routes/favorites/+page.svelte | 1 + .../apps/web/src/routes/files/+page.svelte | 1 + .../src/routes/files/[folderId]/+page.svelte | 1 + .../apps/web/src/routes/search/+page.svelte | 1 + 11 files changed, 1226 insertions(+), 1 deletion(-) create mode 100644 apps/storage/apps/web/src/lib/audio/analyzer.ts create mode 100644 apps/storage/apps/web/src/lib/components/audio/FrequencyBars.svelte create mode 100644 apps/storage/apps/web/src/lib/components/audio/FullPlayer.svelte create mode 100644 apps/storage/apps/web/src/lib/components/audio/MiniPlayer.svelte create mode 100644 apps/storage/apps/web/src/lib/stores/audio-player.svelte.ts diff --git a/apps/storage/apps/web/src/lib/audio/analyzer.ts b/apps/storage/apps/web/src/lib/audio/analyzer.ts new file mode 100644 index 000000000..20ff7cef1 --- /dev/null +++ b/apps/storage/apps/web/src/lib/audio/analyzer.ts @@ -0,0 +1,49 @@ +/** + * Audio Analyzer - connects Web Audio API AnalyserNode to the player's Audio element. + * Singleton: one AudioContext and AnalyserNode shared by all visualizer components. + */ + +let audioContext: AudioContext | null = null; +let analyserNode: AnalyserNode | null = null; +let sourceNode: MediaElementAudioSourceNode | null = null; +let connectedElement: HTMLAudioElement | null = null; + +export function connectAnalyzer(audio: HTMLAudioElement): AnalyserNode { + if (!audioContext) { + audioContext = new AudioContext(); + } + + if (!analyserNode) { + analyserNode = audioContext.createAnalyser(); + } + + analyserNode.fftSize = 256; + analyserNode.smoothingTimeConstant = 0.8; + analyserNode.minDecibels = -90; + analyserNode.maxDecibels = -10; + + if (connectedElement !== audio) { + if (sourceNode) { + sourceNode.disconnect(); + } + sourceNode = audioContext.createMediaElementSource(audio); + sourceNode.connect(analyserNode); + analyserNode.connect(audioContext.destination); + connectedElement = audio; + } + + return analyserNode; +} + +export async function resumeAudioContext(): Promise { + if (audioContext?.state === 'suspended') { + await audioContext.resume(); + } +} + +export function getFrequencyData(): Uint8Array | null { + if (!analyserNode) return null; + const data = new Uint8Array(analyserNode.frequencyBinCount); + analyserNode.getByteFrequencyData(data); + return data; +} diff --git a/apps/storage/apps/web/src/lib/components/audio/FrequencyBars.svelte b/apps/storage/apps/web/src/lib/components/audio/FrequencyBars.svelte new file mode 100644 index 000000000..6920201d9 --- /dev/null +++ b/apps/storage/apps/web/src/lib/components/audio/FrequencyBars.svelte @@ -0,0 +1,154 @@ + + + diff --git a/apps/storage/apps/web/src/lib/components/audio/FullPlayer.svelte b/apps/storage/apps/web/src/lib/components/audio/FullPlayer.svelte new file mode 100644 index 000000000..29c70869b --- /dev/null +++ b/apps/storage/apps/web/src/lib/components/audio/FullPlayer.svelte @@ -0,0 +1,414 @@ + + + + +{#if audioPlayerStore.showFullPlayer && audioPlayerStore.currentFile} + +{/if} + + diff --git a/apps/storage/apps/web/src/lib/components/audio/MiniPlayer.svelte b/apps/storage/apps/web/src/lib/components/audio/MiniPlayer.svelte new file mode 100644 index 000000000..fe3b91f0f --- /dev/null +++ b/apps/storage/apps/web/src/lib/components/audio/MiniPlayer.svelte @@ -0,0 +1,269 @@ + + +{#if audioPlayerStore.currentFile} +
+ + {#if audioPlayerStore.error} +
+ {audioPlayerStore.error} + +
+ {/if} + + +
+
+ +
+
+
+
+
+ +
+ + + + +
+ {#if audioPlayerStore.queue.length > 1} + + {/if} + + + + {#if audioPlayerStore.queue.length > 1} + + {/if} + + +
+
+
+{/if} + + diff --git a/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte b/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte index 1e80b277c..b2d4fb699 100644 --- a/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte +++ b/apps/storage/apps/web/src/lib/components/files/FilePreviewModal.svelte @@ -13,22 +13,28 @@ FileVideo, FileAudio, FileZip, + Play, + Pause, } from '@manacore/shared-icons'; import type { StorageFile } from '$lib/api/client'; import FileVersionsModal from './FileVersionsModal.svelte'; + import { audioPlayerStore, getAudioFiles } from '$lib/stores/audio-player.svelte'; interface Props { open: boolean; file: StorageFile | null; + /** All files in the current folder (for building audio queue) */ + allFiles?: StorageFile[]; onClose: () => void; onAction: (action: string, file: StorageFile) => void; } - let { open, file, onClose, onAction }: Props = $props(); + let { open, file, allFiles = [], onClose, onAction }: Props = $props(); let showVersions = $state(false); let isImage = $derived(file?.mimeType.startsWith('image/') ?? false); + let isAudio = $derived(file?.mimeType.startsWith('audio/') ?? false); let isTextOrCode = $derived( file?.mimeType.startsWith('text/') || file?.mimeType.includes('javascript') || @@ -41,6 +47,32 @@ isImage && file ? `http://localhost:3016/api/v1/files/${file.id}/download` : null ); + /** Check if this file is currently playing in the global player */ + let isCurrentlyPlaying = $derived( + audioPlayerStore.currentFile?.id === file?.id && audioPlayerStore.isPlaying + ); + + function handlePlayAudio() { + if (!file) return; + + // If this file is already playing, toggle play/pause + if (audioPlayerStore.currentFile?.id === file.id) { + audioPlayerStore.togglePlay(); + return; + } + + // Build queue from all audio files in the folder + const audioFiles = getAudioFiles(allFiles); + const currentIndex = audioFiles.findIndex((f) => f.id === file!.id); + const audioFile = { id: file.id, name: file.name, mimeType: file.mimeType, size: file.size }; + + if (audioFiles.length > 0 && currentIndex >= 0) { + audioPlayerStore.playFile(audioFile, audioFiles, currentIndex); + } else { + audioPlayerStore.playFile(audioFile); + } + } + function getFileIcon(mimeType: string) { if (mimeType.startsWith('image/')) return FileImage; if (mimeType.startsWith('video/')) return FileVideo; @@ -104,6 +136,23 @@
{#if isImage && imageUrl} {file.name} + {:else if isAudio} +
+ +

{isCurrentlyPlaying ? 'Wird abgespielt' : 'Abspielen'}

+
{:else if isTextOrCode}
@@ -274,6 +323,47 @@ object-fit: contain; } + .audio-preview { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem; + } + + .audio-play-btn { + background: transparent; + border: none; + cursor: pointer; + padding: 0; + } + + .audio-play-icon { + width: 4.5rem; + height: 4.5rem; + border-radius: 50%; + background: rgb(var(--color-primary)); + color: white; + display: flex; + align-items: center; + justify-content: center; + transition: all 200ms ease; + box-shadow: 0 4px 20px rgb(var(--color-primary) / 0.3); + } + + .audio-play-btn:hover .audio-play-icon { + transform: scale(1.08); + box-shadow: 0 6px 28px rgb(var(--color-primary) / 0.4); + } + + .audio-label { + margin: 0; + font-size: 0.8125rem; + font-weight: 500; + color: rgb(var(--color-text-secondary)); + } + .no-preview { display: flex; flex-direction: column; diff --git a/apps/storage/apps/web/src/lib/stores/audio-player.svelte.ts b/apps/storage/apps/web/src/lib/stores/audio-player.svelte.ts new file mode 100644 index 000000000..1de34c232 --- /dev/null +++ b/apps/storage/apps/web/src/lib/stores/audio-player.svelte.ts @@ -0,0 +1,238 @@ +import type { StorageFile } from '$lib/api/client'; +import { authStore } from '$lib/stores/auth.svelte'; +import { browser } from '$app/environment'; + +export interface AudioFile { + id: string; + name: string; + mimeType: string; + size: number; +} + +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + if (injected) return injected; + } + return 'http://localhost:3016'; +} + +function createAudioPlayerStore() { + let state = $state({ + currentFile: null as AudioFile | null, + isPlaying: false, + currentTime: 0, + duration: 0, + volume: 1, + queue: [] as AudioFile[], + currentIndex: 0, + showFullPlayer: false, + error: null as string | null, + }); + + let audio: HTMLAudioElement | null = null; + + if (browser) { + audio = new Audio(); + audio.crossOrigin = 'anonymous'; + audio.addEventListener('timeupdate', () => { + state.currentTime = audio!.currentTime; + }); + audio.addEventListener('loadedmetadata', () => { + state.duration = audio!.duration; + }); + audio.addEventListener('ended', () => { + handleNext(); + }); + audio.addEventListener('error', () => { + const mediaError = audio!.error; + const msg = + mediaError?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED + ? 'Audioformat wird nicht unterstützt' + : mediaError?.code === MediaError.MEDIA_ERR_NETWORK + ? 'Netzwerkfehler beim Laden' + : 'Audiodatei konnte nicht geladen werden'; + state.error = msg; + state.isPlaying = false; + }); + } + + async function getDownloadUrl(fileId: string): Promise { + const token = await authStore.getAccessToken(); + const res = await fetch(`${getBackendUrl()}/api/v1/files/${fileId}/download?url=true`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error('Failed to get download URL'); + const data = await res.json(); + return data.url; + } + + function updateMediaSession(file: AudioFile) { + if (typeof navigator !== 'undefined' && 'mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: file.name, + }); + navigator.mediaSession.setActionHandler('play', () => store.togglePlay()); + navigator.mediaSession.setActionHandler('pause', () => store.togglePlay()); + navigator.mediaSession.setActionHandler('nexttrack', () => store.nextTrack()); + navigator.mediaSession.setActionHandler('previoustrack', () => store.previousTrack()); + } + } + + async function loadAndPlay(file: AudioFile) { + if (!audio) return; + + state.currentFile = file; + state.currentTime = 0; + state.duration = 0; + state.error = null; + + try { + const url = await getDownloadUrl(file.id); + audio.src = url; + await audio.play(); + state.isPlaying = true; + updateMediaSession(file); + } catch (e) { + console.warn(`[Storage Player] Failed to play "${file.name}":`, e); + state.isPlaying = false; + if (!state.error) { + state.error = 'Datei konnte nicht abgespielt werden.'; + } + } + } + + function handleNext() { + if (state.queue.length === 0) { + state.isPlaying = false; + return; + } + + if (state.currentIndex < state.queue.length - 1) { + state.currentIndex++; + loadAndPlay(state.queue[state.currentIndex]); + } else { + state.isPlaying = false; + if (audio) audio.pause(); + } + } + + const store = { + get currentFile() { + return state.currentFile; + }, + get isPlaying() { + return state.isPlaying; + }, + get currentTime() { + return state.currentTime; + }, + get duration() { + return state.duration; + }, + get volume() { + return state.volume; + }, + get queue() { + return state.queue; + }, + get currentIndex() { + return state.currentIndex; + }, + get showFullPlayer() { + return state.showFullPlayer; + }, + get error() { + return state.error; + }, + + async playFile(file: AudioFile, queue?: AudioFile[], startIndex?: number) { + if (queue) { + state.queue = [...queue]; + state.currentIndex = startIndex ?? 0; + } else { + state.queue = [file]; + state.currentIndex = 0; + } + await loadAndPlay(file); + }, + + togglePlay() { + if (!audio || !state.currentFile) return; + if (state.isPlaying) { + audio.pause(); + state.isPlaying = false; + } else { + audio.play(); + state.isPlaying = true; + } + }, + + seekTo(time: number) { + if (!audio) return; + audio.currentTime = time; + state.currentTime = time; + }, + + setVolume(vol: number) { + if (!audio) return; + const clamped = Math.max(0, Math.min(1, vol)); + audio.volume = clamped; + state.volume = clamped; + }, + + nextTrack() { + handleNext(); + }, + + previousTrack() { + if (state.currentTime > 3) { + store.seekTo(0); + return; + } + if (state.currentIndex > 0) { + state.currentIndex--; + loadAndPlay(state.queue[state.currentIndex]); + } + }, + + toggleFullPlayer() { + state.showFullPlayer = !state.showFullPlayer; + }, + + clearError() { + state.error = null; + }, + + stop() { + if (audio) { + audio.pause(); + audio.src = ''; + } + state.currentFile = null; + state.isPlaying = false; + state.currentTime = 0; + state.duration = 0; + state.queue = []; + state.currentIndex = 0; + state.showFullPlayer = false; + state.error = null; + }, + + getAudioElement(): HTMLAudioElement | null { + return audio; + }, + }; + + return store; +} + +export const audioPlayerStore = createAudioPlayerStore(); + +/** Extract audio files from a list of StorageFiles */ +export function getAudioFiles(files: StorageFile[]): AudioFile[] { + return files + .filter((f) => f.mimeType.startsWith('audio/')) + .map((f) => ({ id: f.id, name: f.name, mimeType: f.mimeType, size: f.size })); +} diff --git a/apps/storage/apps/web/src/routes/+layout.svelte b/apps/storage/apps/web/src/routes/+layout.svelte index 686f093db..15bca5d6e 100644 --- a/apps/storage/apps/web/src/routes/+layout.svelte +++ b/apps/storage/apps/web/src/routes/+layout.svelte @@ -17,6 +17,8 @@ import { storageOnboarding } from '$lib/stores/app-onboarding.svelte'; import { MiniOnboardingModal } from '@manacore/shared-app-onboarding'; import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui'; + import MiniPlayer from '$lib/components/audio/MiniPlayer.svelte'; + import FullPlayer from '$lib/components/audio/FullPlayer.svelte'; import '../app.css'; // App switcher items @@ -190,6 +192,7 @@ settingsHref="/settings" manaHref="/mana" profileHref="/profile" + helpHref="/help" allAppsHref="/apps" /> @@ -206,6 +209,10 @@ {/if} + + + + {/if} diff --git a/apps/storage/apps/web/src/routes/favorites/+page.svelte b/apps/storage/apps/web/src/routes/favorites/+page.svelte index 07532cb43..80eeb446b 100644 --- a/apps/storage/apps/web/src/routes/favorites/+page.svelte +++ b/apps/storage/apps/web/src/routes/favorites/+page.svelte @@ -137,6 +137,7 @@ (previewFile = null)} onAction={(action, file) => { handleFileAction(action, file); diff --git a/apps/storage/apps/web/src/routes/files/+page.svelte b/apps/storage/apps/web/src/routes/files/+page.svelte index d35d77eca..80623f42f 100644 --- a/apps/storage/apps/web/src/routes/files/+page.svelte +++ b/apps/storage/apps/web/src/routes/files/+page.svelte @@ -290,6 +290,7 @@ (previewFile = null)} onAction={(action, file) => { handleFileAction(action, file); diff --git a/apps/storage/apps/web/src/routes/files/[folderId]/+page.svelte b/apps/storage/apps/web/src/routes/files/[folderId]/+page.svelte index 248ec64ed..7f15e229f 100644 --- a/apps/storage/apps/web/src/routes/files/[folderId]/+page.svelte +++ b/apps/storage/apps/web/src/routes/files/[folderId]/+page.svelte @@ -306,6 +306,7 @@ (previewFile = null)} onAction={(action, file) => { handleFileAction(action, file); diff --git a/apps/storage/apps/web/src/routes/search/+page.svelte b/apps/storage/apps/web/src/routes/search/+page.svelte index 48bc91134..769d9b3a3 100644 --- a/apps/storage/apps/web/src/routes/search/+page.svelte +++ b/apps/storage/apps/web/src/routes/search/+page.svelte @@ -145,6 +145,7 @@ (previewFile = null)} onAction={() => { previewFile = null;