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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 09:16:27 +01:00
parent 2d11ba6248
commit 2150452ae1
11 changed files with 1226 additions and 1 deletions

View file

@ -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<void> {
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;
}

View file

@ -0,0 +1,154 @@
<script lang="ts">
import { audioPlayerStore } from '$lib/stores/audio-player.svelte';
import { connectAnalyzer, getFrequencyData, resumeAudioContext } from '$lib/audio/analyzer';
interface Props {
barCount?: number;
barGap?: number;
barRadius?: number;
color?: string;
height?: number;
mirror?: boolean;
}
let {
barCount = 32,
barGap = 2,
barRadius = 2,
color = 'gradient',
height = 64,
mirror = false,
}: Props = $props();
let canvas: HTMLCanvasElement | undefined = $state();
let animationId: number | null = null;
let isConnected = $state(false);
$effect(() => {
const audio = audioPlayerStore.getAudioElement();
if (audio && audioPlayerStore.isPlaying && !isConnected) {
try {
connectAnalyzer(audio);
resumeAudioContext();
isConnected = true;
} catch (e) {
console.warn('[Visualizer] Failed to connect analyzer:', e);
}
}
});
$effect(() => {
if (audioPlayerStore.isPlaying && canvas && isConnected) {
startAnimation();
} else {
stopAnimation();
if (canvas) drawBars(null);
}
return () => stopAnimation();
});
function startAnimation() {
if (animationId !== null) return;
function loop() {
const data = getFrequencyData();
drawBars(data);
animationId = requestAnimationFrame(loop);
}
animationId = requestAnimationFrame(loop);
}
function stopAnimation() {
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
}
function drawBars(frequencyData: Uint8Array | null) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
}
ctx.clearRect(0, 0, w, h);
const effectiveBarCount = mirror ? Math.ceil(barCount / 2) : barCount;
const totalGap = barGap * (barCount - 1);
const barWidth = Math.max(1, (w - totalGap) / barCount);
const bars = new Float32Array(effectiveBarCount);
if (frequencyData) {
const binCount = frequencyData.length;
for (let i = 0; i < effectiveBarCount; i++) {
const logStart = Math.pow(i / effectiveBarCount, 1.5) * binCount;
const logEnd = Math.pow((i + 1) / effectiveBarCount, 1.5) * binCount;
const start = Math.floor(logStart);
const end = Math.max(start + 1, Math.floor(logEnd));
let sum = 0;
for (let j = start; j < end && j < binCount; j++) {
sum += frequencyData[j];
}
bars[i] = sum / (end - start) / 255;
}
}
for (let i = 0; i < barCount; i++) {
let barIndex: number;
if (mirror) {
const center = Math.floor(barCount / 2);
barIndex = Math.abs(i - center);
barIndex = Math.min(barIndex, effectiveBarCount - 1);
} else {
barIndex = Math.min(i, effectiveBarCount - 1);
}
const value = bars[barIndex];
const minHeight = 2;
const barHeight = Math.max(minHeight, value * h);
const x = i * (barWidth + barGap);
const y = h - barHeight;
if (color === 'gradient') {
const hue = mirror
? 200 + (Math.abs(i - barCount / 2) / (barCount / 2)) * 120
: 200 + (i / barCount) * 120;
const lightness = 50 + value * 20;
ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`;
} else {
ctx.fillStyle = color;
}
if (barRadius > 0) {
const r = Math.min(barRadius, barWidth / 2, barHeight / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + barWidth - r, y);
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + r);
ctx.lineTo(x + barWidth, y + barHeight);
ctx.lineTo(x, y + barHeight);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.fill();
} else {
ctx.fillRect(x, y, barWidth, barHeight);
}
}
}
</script>
<canvas bind:this={canvas} class="w-full block" style="height: {height}px;" aria-hidden="true"
></canvas>

View file

@ -0,0 +1,414 @@
<script lang="ts">
import { audioPlayerStore } from '$lib/stores/audio-player.svelte';
import FrequencyBars from './FrequencyBars.svelte';
import {
Play,
Pause,
SkipForward,
SkipBack,
X,
FileAudio,
SpeakerHigh,
SpeakerLow,
SpeakerNone,
} from '@manacore/shared-icons';
let innerHeight = $state(typeof window !== 'undefined' ? window.innerHeight : 800);
let progress = $derived(
audioPlayerStore.duration > 0
? (audioPlayerStore.currentTime / audioPlayerStore.duration) * 100
: 0
);
function formatTime(s: number): string {
if (!s || !isFinite(s)) return '0:00';
return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
}
function handleProgressClick(e: MouseEvent) {
const bar = e.currentTarget as HTMLElement;
const rect = bar.getBoundingClientRect();
const fraction = (e.clientX - rect.left) / rect.width;
const time = fraction * audioPlayerStore.duration;
audioPlayerStore.seekTo(time);
}
function handleVolumeInput(e: Event) {
const input = e.target as HTMLInputElement;
audioPlayerStore.setVolume(parseFloat(input.value));
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
audioPlayerStore.toggleFullPlayer();
} else if (e.key === ' ') {
e.preventDefault();
audioPlayerStore.togglePlay();
} else if (e.key === 'ArrowLeft') {
audioPlayerStore.seekTo(Math.max(0, audioPlayerStore.currentTime - 5));
} else if (e.key === 'ArrowRight') {
audioPlayerStore.seekTo(
Math.min(audioPlayerStore.duration, audioPlayerStore.currentTime + 5)
);
}
}
let VolumeIcon = $derived(
audioPlayerStore.volume === 0
? SpeakerNone
: audioPlayerStore.volume < 0.5
? SpeakerLow
: SpeakerHigh
);
</script>
<svelte:window bind:innerHeight />
{#if audioPlayerStore.showFullPlayer && audioPlayerStore.currentFile}
<div
class="full-player"
role="dialog"
aria-modal="true"
aria-label="Audio Player"
onkeydown={handleKeydown}
tabindex="-1"
>
<!-- Visualizer background -->
<div class="viz-background">
<FrequencyBars barCount={96} height={innerHeight} barGap={2} barRadius={3} mirror={true} />
</div>
<!-- Dark overlay -->
<div class="viz-overlay"></div>
<!-- Content -->
<div class="content-layer">
<!-- Top bar -->
<div class="top-bar">
<button
class="top-btn"
onclick={() => audioPlayerStore.toggleFullPlayer()}
aria-label="Schließen"
>
<X size={24} />
</button>
<div class="now-playing">Wird abgespielt</div>
<div style="width: 40px"></div>
</div>
<!-- Spacer -->
<div class="flex-1"></div>
<!-- File info + controls -->
<div class="bottom-section">
<!-- Icon + File info -->
<div class="file-hero">
<div class="file-hero-icon">
<FileAudio size={48} />
</div>
<div class="file-hero-name">{audioPlayerStore.currentFile.name}</div>
<div class="file-hero-type">{audioPlayerStore.currentFile.mimeType}</div>
</div>
<!-- Progress bar -->
<div class="progress-section">
<button class="progress-bar" onclick={handleProgressClick} aria-label="Seek">
<div class="progress-bar-fill" style="width: {progress}%"></div>
<div class="progress-bar-thumb" style="left: {progress}%"></div>
</button>
<div class="progress-times">
<span>{formatTime(audioPlayerStore.currentTime)}</span>
<span>{formatTime(audioPlayerStore.duration)}</span>
</div>
</div>
<!-- Transport controls -->
<div class="transport">
<button
class="transport-btn"
onclick={() => audioPlayerStore.previousTrack()}
aria-label="Vorheriger Track"
>
<SkipBack size={28} weight="fill" />
</button>
<button
class="transport-btn play"
onclick={() => audioPlayerStore.togglePlay()}
aria-label={audioPlayerStore.isPlaying ? 'Pause' : 'Abspielen'}
>
{#if audioPlayerStore.isPlaying}
<Pause size={32} weight="fill" />
{:else}
<Play size={32} weight="fill" />
{/if}
</button>
<button
class="transport-btn"
onclick={() => audioPlayerStore.nextTrack()}
aria-label="Nächster Track"
>
<SkipForward size={28} weight="fill" />
</button>
</div>
<!-- Volume -->
<div class="volume-row">
<VolumeIcon size={18} />
<input
type="range"
min="0"
max="1"
step="0.01"
value={audioPlayerStore.volume}
oninput={handleVolumeInput}
class="volume-slider"
aria-label="Lautstärke"
/>
</div>
<!-- Queue info -->
{#if audioPlayerStore.queue.length > 1}
<div class="queue-info">
Track {audioPlayerStore.currentIndex + 1} von {audioPlayerStore.queue.length}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.full-player {
position: fixed;
inset: 0;
z-index: 50;
background: #000;
display: flex;
flex-direction: column;
}
.viz-background {
position: absolute;
inset: 0;
z-index: 0;
}
.viz-background :global(canvas) {
height: 100% !important;
width: 100% !important;
}
.viz-overlay {
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.1) 40%,
rgba(0, 0, 0, 0.4) 70%,
rgba(0, 0, 0, 0.85) 100%
);
pointer-events: none;
}
.content-layer {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
height: 100%;
}
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
}
.top-btn {
padding: 0.5rem;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
cursor: pointer;
backdrop-filter: blur(8px);
transition: background 150ms ease;
}
.top-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.now-playing {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
}
.flex-1 {
flex: 1;
}
.bottom-section {
padding: 1.5rem;
padding-bottom: 2.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 28rem;
margin: 0 auto;
width: 100%;
}
.file-hero {
text-align: center;
}
.file-hero-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.1);
color: white;
margin-bottom: 0.75rem;
backdrop-filter: blur(8px);
}
.file-hero-name {
font-size: 1.25rem;
font-weight: 700;
color: white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-hero-type {
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.5);
margin-top: 0.25rem;
}
.progress-section {
width: 100%;
}
.progress-bar {
position: relative;
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
cursor: pointer;
border: none;
padding: 0;
display: block;
}
.progress-bar-fill {
height: 100%;
background: white;
border-radius: 3px;
transition: width 100ms ease;
}
.progress-bar-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.progress-times {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
font-variant-numeric: tabular-nums;
}
.transport {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
}
.transport-btn {
padding: 0.5rem;
border-radius: 50%;
background: transparent;
border: none;
color: white;
cursor: pointer;
transition: all 150ms ease;
}
.transport-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.transport-btn.play {
padding: 1rem;
background: white;
color: black;
}
.transport-btn.play:hover {
opacity: 0.9;
}
.volume-row {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.volume-slider {
width: 6rem;
height: 4px;
border-radius: 2px;
appearance: none;
background: rgba(255, 255, 255, 0.2);
cursor: pointer;
}
.volume-slider::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
cursor: pointer;
border: none;
}
.queue-info {
text-align: center;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
}
</style>

View file

@ -0,0 +1,269 @@
<script lang="ts">
import { audioPlayerStore } from '$lib/stores/audio-player.svelte';
import FrequencyBars from './FrequencyBars.svelte';
import { Play, Pause, SkipForward, SkipBack, X, FileAudio } from '@manacore/shared-icons';
let progress = $derived(
audioPlayerStore.duration > 0
? (audioPlayerStore.currentTime / audioPlayerStore.duration) * 100
: 0
);
function formatTime(s: number): string {
if (!s || !isFinite(s)) return '0:00';
return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
}
</script>
{#if audioPlayerStore.currentFile}
<div class="mini-player">
<!-- Error toast -->
{#if audioPlayerStore.error}
<div class="error-bar">
<span class="error-text">{audioPlayerStore.error}</span>
<button
class="error-dismiss"
onclick={() => audioPlayerStore.clearError()}
aria-label="Schließen"
>
<X size={14} />
</button>
</div>
{/if}
<!-- Frequency visualizer + progress bar -->
<div class="viz-wrapper">
<div class="viz-bars">
<FrequencyBars barCount={64} height={20} barGap={1} barRadius={1} />
</div>
<div class="progress-track">
<div class="progress-fill" style="width: {progress}%"></div>
</div>
</div>
<div class="player-content">
<!-- File info -->
<button class="file-info" onclick={() => audioPlayerStore.toggleFullPlayer()}>
<div class="file-icon">
<FileAudio size={20} />
</div>
<div class="file-meta">
<div class="file-name">{audioPlayerStore.currentFile.name}</div>
<div class="file-time">
{formatTime(audioPlayerStore.currentTime)} / {formatTime(audioPlayerStore.duration)}
</div>
</div>
</button>
<!-- Controls -->
<div class="controls">
{#if audioPlayerStore.queue.length > 1}
<button
class="control-btn"
onclick={() => audioPlayerStore.previousTrack()}
aria-label="Vorheriger Track"
>
<SkipBack size={18} />
</button>
{/if}
<button
class="control-btn primary"
onclick={() => audioPlayerStore.togglePlay()}
aria-label={audioPlayerStore.isPlaying ? 'Pause' : 'Abspielen'}
>
{#if audioPlayerStore.isPlaying}
<Pause size={20} weight="fill" />
{:else}
<Play size={20} weight="fill" />
{/if}
</button>
{#if audioPlayerStore.queue.length > 1}
<button
class="control-btn"
onclick={() => audioPlayerStore.nextTrack()}
aria-label="Nächster Track"
>
<SkipForward size={18} />
</button>
{/if}
<button
class="control-btn close"
onclick={() => audioPlayerStore.stop()}
aria-label="Player schließen"
>
<X size={16} />
</button>
</div>
</div>
</div>
{/if}
<style>
.mini-player {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
background: rgb(var(--color-surface-elevated));
border-top: 1px solid rgb(var(--color-border));
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
box-shadow: var(--shadow-lg);
}
.error-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 1rem;
background: rgb(var(--color-error) / 0.1);
border-bottom: 1px solid rgb(var(--color-error) / 0.2);
font-size: 0.8125rem;
color: rgb(var(--color-error));
}
.error-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-dismiss {
padding: 0.125rem;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
flex-shrink: 0;
}
.viz-wrapper {
position: relative;
}
.viz-bars {
opacity: 0.4;
}
.progress-track {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgb(var(--color-border));
}
.progress-fill {
height: 100%;
background: rgb(var(--color-primary));
transition: width 0.2s ease;
}
.player-content {
display: flex;
align-items: center;
height: 3.5rem;
padding: 0 0.75rem;
gap: 0.5rem;
}
.file-info {
display: flex;
align-items: center;
gap: 0.625rem;
flex: 1;
min-width: 0;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
padding: 0;
color: inherit;
}
.file-icon {
width: 2.25rem;
height: 2.25rem;
border-radius: var(--radius-md);
background: rgb(var(--color-primary) / 0.1);
color: rgb(var(--color-primary));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.file-meta {
min-width: 0;
}
.file-name {
font-size: 0.8125rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-time {
font-size: 0.6875rem;
color: rgb(var(--color-text-secondary));
font-variant-numeric: tabular-nums;
}
.controls {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: rgb(var(--color-text-secondary));
cursor: pointer;
transition: all 150ms ease;
}
.control-btn:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.control-btn.primary {
padding: 0.5rem;
background: rgb(var(--color-primary));
color: white;
border-radius: 50%;
}
.control-btn.primary:hover {
opacity: 0.9;
}
.control-btn.close {
margin-left: 0.25rem;
}
.control-btn.close:hover {
color: rgb(var(--color-error));
}
@media (max-width: 480px) {
.file-time {
display: none;
}
}
</style>

View file

@ -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 @@
<div class="preview-area">
{#if isImage && imageUrl}
<img src={imageUrl} alt={file.name} class="image-preview" />
{:else if isAudio}
<div class="audio-preview">
<button
class="audio-play-btn"
onclick={handlePlayAudio}
aria-label={isCurrentlyPlaying ? 'Pause' : 'Abspielen'}
>
<div class="audio-play-icon">
{#if isCurrentlyPlaying}
<Pause size={32} weight="fill" />
{:else}
<Play size={32} weight="fill" />
{/if}
</div>
</button>
<p class="audio-label">{isCurrentlyPlaying ? 'Wird abgespielt' : 'Abspielen'}</p>
</div>
{:else if isTextOrCode}
<div class="no-preview">
<Icon size={64} strokeWidth={1} />
@ -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;

View file

@ -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<string> {
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 }));
}

View file

@ -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}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
<!-- Audio Player -->
<MiniPlayer />
<FullPlayer />
{/if}
</AuthGate>

View file

@ -137,6 +137,7 @@
<FilePreviewModal
open={previewFile !== null}
file={previewFile}
allFiles={files}
onClose={() => (previewFile = null)}
onAction={(action, file) => {
handleFileAction(action, file);

View file

@ -290,6 +290,7 @@
<FilePreviewModal
open={previewFile !== null}
file={previewFile}
allFiles={filesStore.files}
onClose={() => (previewFile = null)}
onAction={(action, file) => {
handleFileAction(action, file);

View file

@ -306,6 +306,7 @@
<FilePreviewModal
open={previewFile !== null}
file={previewFile}
allFiles={filesStore.files}
onClose={() => (previewFile = null)}
onAction={(action, file) => {
handleFileAction(action, file);

View file

@ -145,6 +145,7 @@
<FilePreviewModal
open={previewFile !== null}
file={previewFile}
allFiles={files}
onClose={() => (previewFile = null)}
onAction={() => {
previewFile = null;