mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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:
parent
2d11ba6248
commit
2150452ae1
11 changed files with 1226 additions and 1 deletions
49
apps/storage/apps/web/src/lib/audio/analyzer.ts
Normal file
49
apps/storage/apps/web/src/lib/audio/analyzer.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
414
apps/storage/apps/web/src/lib/components/audio/FullPlayer.svelte
Normal file
414
apps/storage/apps/web/src/lib/components/audio/FullPlayer.svelte
Normal 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>
|
||||
269
apps/storage/apps/web/src/lib/components/audio/MiniPlayer.svelte
Normal file
269
apps/storage/apps/web/src/lib/components/audio/MiniPlayer.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
238
apps/storage/apps/web/src/lib/stores/audio-player.svelte.ts
Normal file
238
apps/storage/apps/web/src/lib/stores/audio-player.svelte.ts
Normal 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 }));
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@
|
|||
<FilePreviewModal
|
||||
open={previewFile !== null}
|
||||
file={previewFile}
|
||||
allFiles={files}
|
||||
onClose={() => (previewFile = null)}
|
||||
onAction={(action, file) => {
|
||||
handleFileAction(action, file);
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@
|
|||
<FilePreviewModal
|
||||
open={previewFile !== null}
|
||||
file={previewFile}
|
||||
allFiles={filesStore.files}
|
||||
onClose={() => (previewFile = null)}
|
||||
onAction={(action, file) => {
|
||||
handleFileAction(action, file);
|
||||
|
|
|
|||
|
|
@ -306,6 +306,7 @@
|
|||
<FilePreviewModal
|
||||
open={previewFile !== null}
|
||||
file={previewFile}
|
||||
allFiles={filesStore.files}
|
||||
onClose={() => (previewFile = null)}
|
||||
onAction={(action, file) => {
|
||||
handleFileAction(action, file);
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@
|
|||
<FilePreviewModal
|
||||
open={previewFile !== null}
|
||||
file={previewFile}
|
||||
allFiles={files}
|
||||
onClose={() => (previewFile = null)}
|
||||
onAction={() => {
|
||||
previewFile = null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue