zwischenstand

This commit is contained in:
2026-03-21 13:49:05 +01:00
parent b863b04505
commit 01448223ad
30 changed files with 1446 additions and 295 deletions

View File

@@ -0,0 +1,456 @@
<script lang="ts">
import { GearIcon, CloudArrowUpIcon, InfoIcon, CaretDownIcon } from "phosphor-svelte";
import { slide, fade } from "svelte/transition";
import { addToast } from "../lib/toast";
import { processAudioFile } from "../lib/audioProcessor";
import { saveLocalFile } from "../lib/db";
import { refreshLocal } from "../lib/sync";
import { updateLocalAudioFormat, updateLocalAudioCrc } from "../lib/tagHandler";
import { audioOptions } from "../lib/store";
import { AUDIO_PRESETS, type AudioPresetName } from "../lib/settings";
import { tooltip } from "../lib/actions/tooltip";
import { clickOutside } from "../lib/actions/clickOutside"; // Angenommen, diese Action existiert bereits (analog zu Ihren anderen Menüs).
let isFileDragOver = false;
let showSettings = false;
let showPresetDropdown = false; // Neuer Zustand für das Custom Dropdown
// Definitionen der Preset-Infos für Tooltips im Dropdown
const presetInfos: Record<AudioPresetName, { name: string; desc: string }> = {
normal: { name: "Normal", desc: "Für natürliche Sprache. Moderate Verdichtung." },
broadcast: { name: "Broadcast", desc: "Aggressiv, sehr laut (Radio-Style). Maximale Präsenz." },
custom: { name: "Benutzerdefiniert", desc: "Manuelle Anpassung aller Kompressor-Parameter." },
};
// Reagiert auf Preset-Wechsel
function handlePresetChange(preset: AudioPresetName) {
$audioOptions.preset = preset;
showPresetDropdown = false; // Dropdown nach Auswahl schließen
if (preset !== "custom") {
const p = AUDIO_PRESETS[preset];
$audioOptions = {
...$audioOptions,
compressorThreshold: p.compressorThreshold!,
compressorRatio: p.compressorRatio!,
compressorKnee: p.compressorKnee!,
compressorAttack: p.compressorAttack!,
compressorRelease: p.compressorRelease!,
};
}
}
// Setzt das Preset automatisch auf Custom, sobald ein Slider bewegt wird
function setCustomPreset() {
$audioOptions.preset = "custom";
}
async function handleDrop(event: DragEvent) {
isFileDragOver = false;
showSettings = false;
showPresetDropdown = false; // Dropdown sicherheitshalber schließen
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const fileArray = Array.from(files);
const audioFiles = fileArray.filter((file) => file.type.startsWith("audio/"));
const rawFiles = fileArray.filter((file) => file.type === ""); // Ohne MIME-Type
if (audioFiles.length > 0 || rawFiles.length > 0) {
// 1. Reguläre Dateien umwandeln & taggen
for (const file of audioFiles) {
try {
const { buffer, name } = await processAudioFile(file, $audioOptions);
await saveLocalFile(name, new Blob([buffer]), buffer.byteLength);
await updateLocalAudioFormat(name, { codec: 0, bitDepth: 16, sampleRate: 16000 });
await updateLocalAudioCrc(name);
addToast(`Gespeichert & Getaggt: ${name}`, "success");
} catch (err) {
addToast(`Fehler bei ${file.name}: ${err}`, "error");
}
}
// 2. Rohdaten bypassen (direkt speichern & taggen)
for (const file of rawFiles) {
try {
const name = file.name;
await saveLocalFile(name, file, file.size);
// TagHandler erkennt alte Tags automatisch und fügt System-Tags sicher hinzu
await updateLocalAudioFormat(name, { codec: 0, bitDepth: 16, sampleRate: 16000 });
await updateLocalAudioCrc(name);
addToast(`Roh-Datei importiert: ${name}`, "success");
} catch (err) {
addToast(`Fehler bei Roh-Import ${file.name}: ${err}`, "error");
}
}
refreshLocal();
} else {
addToast("Keine gültigen Dateien gefunden.", "error");
}
}
</script>
<svelte:window
on:keydown={(e) => {
if (e.key === "Escape") showSettings = false;
}}
/>
<section class="buzzer-card flex flex-col h-full transition-all duration-300 min-w-[320px]">
<div class="card-header" class:blur={isFileDragOver}>
<h3 class="card-title">Dateiverarbeitung</h3>
<button class="btn" aria-label="Einstellungen" on:click={() => (showSettings = !showSettings)}>
<GearIcon class="icon" />
</button>
</div>
<div
class="card-body flex flex-col flex-1"
role="region"
aria-label="Audiofile dropzone"
on:dragenter={() => (isFileDragOver = true)}
on:dragleave={() => (isFileDragOver = false)}
on:dragover|preventDefault
on:drop|preventDefault={handleDrop}
>
{#if !showSettings}
<div
class="flex justify-center items-center pointer-events-none p-4 pb-0"
in:fade={{ duration: 200 }}
>
<div
class="text-center text-2xs tracking-tight font-mono font-semibold transition-all"
class:blur={isFileDragOver}
>
<span class={$audioOptions.lowCut ? "text-emerald-500" : "text-text-muted"}>LOW CUT</span>
|
<span class={$audioOptions.compress ? "text-emerald-500" : "text-text-muted"}>
COMPRESSOR
</span>
|
<span class={$audioOptions.normalize ? "text-emerald-500" : "text-text-muted"}>
NORMALIZER
</span>
</div>
</div>
<div class="flex-1 p-4 pt-2">
<div
in:fade={{ duration: 200 }}
class="flex h-full items-center justify-center border-2 border-dashed rounded-lg pointer-events-none transition-all duration-300 min-h-[8rem]"
class:border-slate-300={!isFileDragOver}
class:border-indigo-500={isFileDragOver}
class:bg-indigo-50={isFileDragOver}
>
<div class="relative flex size-24 transition-transform" class:scale-110={isFileDragOver}>
<span
class="absolute inline-flex h-full w-full opacity-50"
class:animate-ping={isFileDragOver}
class:text-indigo-500={isFileDragOver}
class:text-transparent={!isFileDragOver}
>
<CloudArrowUpIcon class="w-full h-full" />
</span>
<span
class="relative inline-flex size-24 transition-colors"
class:text-slate-500={!isFileDragOver}
class:text-indigo-600={isFileDragOver}
>
<CloudArrowUpIcon class="w-full h-full transition-colors" />
</span>
</div>
</div>
</div>
{:else}
<div class="flex-1 flex flex-col bg-white h-full" in:slide={{ duration: 300 }}>
<div
class="flex items-center justify-between px-4 pt-4 pb-2 border-b border-slate-100 mb-4"
>
<h4 class="font-semibold text-sm text-slate-800">Audio-Optionen</h4>
</div>
<div class="space-y-5 flex-1 overflow-y-auto px-4 pb-4">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
use:tooltip={{
text: "Entfernt tieffrequentes Rumpeln (Wind, Trittschall). Schont kleine Lautsprecher und verhindert Verzerrungen.",
pos: "right",
}}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<label class="flex items-center space-x-2 text-sm font-medium cursor-pointer flex-1">
<input
type="checkbox"
bind:checked={$audioOptions.lowCut}
class="accent-indigo-600"
/>
<span>Low-Cut Filter</span>
</label>
</div>
{#if $audioOptions.lowCut}
<div class="pl-6 flex items-center gap-3">
<span class="text-xs text-text-muted w-10 text-right">
{$audioOptions.lowCutFreq} Hz
</span>
<input
type="range"
min="50"
max="300"
step="10"
bind:value={$audioOptions.lowCutFreq}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
use:tooltip={{
text: "Gleicht Lautstärkeunterschiede an. Macht leise Passagen lauter und fängt laute Spitzen ab.",
pos: "right",
}}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<label class="flex items-center space-x-2 text-sm font-medium cursor-pointer flex-1">
<input
type="checkbox"
bind:checked={$audioOptions.compress}
class="accent-indigo-600"
/>
<span>Dynamik-Kompressor</span>
</label>
</div>
{#if $audioOptions.compress}
<div class="pl-6 flex flex-col gap-3">
<div class="relative" use:clickOutside={() => (showPresetDropdown = false)}>
<button
type="button"
class="flex items-center justify-between w-full text-sm border border-slate-300 rounded p-1.5 bg-slate-50 hover:bg-slate-100 transition"
on:click={() => (showPresetDropdown = !showPresetDropdown)}
>
<div class="flex items-center gap-2">
<div
use:tooltip={{ text: presetInfos[$audioOptions.preset].desc, pos: "right" }}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<span>Preset: {presetInfos[$audioOptions.preset].name}</span>
</div>
<span class:rotate-180={showPresetDropdown}>
<CaretDownIcon class="size-4 text-slate-500 transition-transform" />
</span>
</button>
{#if showPresetDropdown}
<div
class="absolute top-full left-0 right-0 mt-1 bg-white border border-slate-200 rounded-md shadow-lg z-20 overflow-hidden"
transition:fade={{ duration: 150 }}
>
{#each Object.entries(presetInfos) as [key, info]}
<button
type="button"
class="flex items-center gap-2 w-full text-left text-sm px-3 py-2 hover:bg-indigo-50 transition"
class:bg-indigo-100={$audioOptions.preset === key}
class:font-medium={$audioOptions.preset === key}
on:click={() => handlePresetChange(key as AudioPresetName)}
>
<div use:tooltip={{ text: info.desc, pos: "right" }}>
<InfoIcon class="text-text-muted size-4" />
</div>
<span>{info.name}</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="grid grid-cols-1 gap-2 text-xs">
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Pegel, ab dem der Kompressor zu arbeiten beginnt.",
pos: "right",
}}
>
Threshold
</span>
<span class="text-text-muted w-16 text-right">
{$audioOptions.compressorThreshold} dB
</span>
<input
type="range"
min="-60"
max="0"
step="1"
bind:value={$audioOptions.compressorThreshold}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Stärke der Pegelreduzierung oberhalb des Thresholds.",
pos: "right",
}}
>
Ratio
</span>
<span class="text-text-muted w-16 text-right">
{$audioOptions.compressorRatio}:1
</span>
<input
type="range"
min="1"
max="20"
step="1"
bind:value={$audioOptions.compressorRatio}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Reaktionszeit des Kompressors auf Pegelüberschreitungen.",
pos: "right",
}}
>
Attack
</span>
<span class="text-text-muted w-16 text-right">
{($audioOptions.compressorAttack * 1000).toFixed(0)} ms
</span>
<input
type="range"
min="0.001"
max="0.1"
step="0.001"
bind:value={$audioOptions.compressorAttack}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
<div
class="flex items-center gap-2"
class:opacity-50={$audioOptions.preset !== "custom"}
>
<span
class="w-20 cursor-help"
use:tooltip={{
text: "Zeit, bis der Kompressor nach Unterschreitung des Thresholds aufhört zu arbeiten.",
pos: "right",
}}
>
Release
</span>
<span class="text-text-muted w-16 text-right">
{($audioOptions.compressorRelease * 1000).toFixed(0)} ms
</span>
<input
type="range"
min="0.01"
max="1"
step="0.01"
bind:value={$audioOptions.compressorRelease}
disabled={$audioOptions.preset !== "custom"}
on:input={setCustomPreset}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
</div>
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
use:tooltip={{
text: "Hebt das verarbeitete Signal linear an, bis der lauteste Peak exakt den Zielwert erreicht.",
pos: "right",
}}
>
<InfoIcon class="text-text-muted size-4" />
</div>
<label class="flex items-center space-x-2 text-sm font-medium cursor-pointer flex-1">
<input
type="checkbox"
bind:checked={$audioOptions.normalize}
class="accent-indigo-600"
/>
<span>Lautstärke normalisieren</span>
</label>
</div>
{#if $audioOptions.normalize}
<div class="pl-6 flex items-center gap-3">
<span class="text-xs text-text-muted w-10 text-right">
{$audioOptions.normalizeTargetDb} dB
</span>
<input
type="range"
min="-10"
max="0"
step="0.1"
bind:value={$audioOptions.normalizeTargetDb}
class="flex-1 accent-indigo-500 h-1"
/>
</div>
{/if}
</div>
<div class="text-xs text-text-muted bg-slate-100 p-3 rounded mt-4">
Hinweis: Die Konvertierung zu 16kHz Mono PCM erfolgt immer automatisch beim Speichern.
</div>
</div>
</div>
{/if}
</div>
</section>
<style>
@reference "../styles/app.css";
.btn {
@apply rounded-full transition-colors border-0 p-2;
&:not(:disabled) {
@apply hover:bg-slate-200 hover:shadow-sm;
}
}
/* Styling für die Range-Slider */
input[type="range"] {
@apply cursor-pointer;
}
input[type="range"]:disabled {
@apply cursor-not-allowed;
}
</style>