Lokales Tag-Handling implementiert

This commit is contained in:
2026-03-16 15:13:46 +01:00
parent b5eb3b56c0
commit 6ec66cd9da
18 changed files with 1592 additions and 110 deletions

122
Tags.md Normal file
View File

@@ -0,0 +1,122 @@
# Edi's Buzzer - Metadata Tags Format
## Architektur-Übersicht
Die Metadaten werden transparent an das Ende der rohen Audio-Daten angehängt. Das Format basiert auf einer strikten **Little-Endian** Byte-Reihenfolge und nutzt eine erweiterbare **TLV-Struktur** (Type-Length-Value) für die eigentlichen Datenblöcke.
Das physische Layout einer Datei im Flash-Speicher sieht wie folgt aus:
`[Audio-Rohdaten] [TLV-Block 1] ... [TLV-Block N] [Footer (8 Bytes)]`
---
## 1. Footer-Struktur
Der Footer liegt exakt auf den letzten 8 Bytes der Datei (EOF - 8). Er dient als Ankerpunkt für den Parser, um die Metadaten rückwärts aus der Datei zu extrahieren. Das 8-Byte-Alignment stellt speichersichere Casts auf 32-Bit-ARM-Architekturen sicher.
| Offset | Feld | Typ | Beschreibung |
| :--- | :--- | :--- | :--- |
| 0 | `total_size` | `uint16_t` | Gesamtgröße in Bytes (Summe aller TLV-Blöcke + 8 Bytes Footer). |
| 2 | `version` | `uint16_t` | Format-Version. Aktuell `0x0001`. |
| 4 | `magic` | `char[4]` | Fixe Signatur: `"TAG!"` (Hex: `54 41 47 21`). |
---
## 2. TLV-Header (Type-Length-Value)
Jeder Metadaten-Block beginnt mit einem exakt 4 Bytes großen Header. Unbekannte Typen können vom Controller durch einen relativen Sprung (`fs_seek` um `length` Bytes) übersprungen werden.
| Offset | Feld | Typ | Beschreibung |
| :--- | :--- | :--- | :--- |
| 0 | `type` | `uint8_t` | Definiert den Inhalt des Blocks (siehe Typen-Definitionen). |
| 1 | `index` | `uint8_t` | Erlaubt die Fragmentierung großer Datensätze (z.B. bei JSON > 64 KB). Standard: `0x00`. |
| 2 | `length` | `uint16_t` | Größe der folgenden Payload in Bytes (ohne diesen Header). |
---
## 3. Typen-Definitionen
### Type `0x00`: Binary System Metadata
Dieser Typ gruppiert maschinenlesbare, binäre Systeminformationen. Die Unterscheidung erfolgt über das `Index`-Feld.
#### Index `0x00`: Audio Format
Dieser Block konfiguriert den I2S-Treiber vor der Wiedergabe.
* **Typ:** `0x00`
* **Index:** `0x00`
* **Länge:** `0x0008` (8 Bytes)
* **Payload:** `[codec: 1 Byte] [bit_depth: 1 Byte] [reserved: 2 Bytes] [samplerate: 4 Bytes]`
#### Index `0x01`: Audio CRC32
Speichert die CRC32-Prüfsumme (IEEE) der reinen Audiodaten (vom Dateianfang bis zum Beginn des ersten TLV-Blocks). Dient Synchronisations-Tools für einen schnellen Integritäts- und Abgleich-Check, ohne die gesamte Datei neu hashen zu müssen.
* **Typ:** `0x00`
* **Index:** `0x01`
* **Länge:** `0x0004` (4 Bytes)
* **Payload:** `uint32_t` (Little-Endian)
### Type `0x10`: JSON Metadata
Dieser Block enthält Metadaten, die primär für das Host-System (z. B. das Python-Tool) zur Verwaltung, Kategorisierung und Anzeige bestimmt sind. Der Mikrocontroller ignoriert und überspringt diesen Block während der Audiowiedergabe.
* **Typ:** `0x10`
* **Länge:** Variabel
* **Payload:** UTF-8-kodierter JSON-String (ohne Null-Terminator).
#### Standardisierte JSON-Schlüssel
Die nachfolgenden Schlüssel (Keys) sind im Basis-Standard definiert. Die Integration weiterer, proprietärer Schlüssel ist technisch möglich. Es wird jedoch empfohlen, dies mit Vorsicht zu handhaben, da zukünftige Standardisierungen diese Schlüsselnamen belegen könnten (Namenskollision).
| Schlüssel | Datentyp | Beschreibung |
| :--- | :--- | :--- |
| `t` | String | Titel der Audiodatei |
| `a` | String | Autor oder Ersteller |
| `r` | String | Bemerkungen (Remarks) oder Beschreibung |
| `c` | Array of Strings | Kategorien zur Gruppierung |
| `dc` | String | Erstellungsdatum (Date Created), idealerweise nach ISO 8601 |
| `ds` | String | Speicher- oder Änderungsdatum (Date Saved), idealerweise nach ISO 8601 |
**Beispiel-Payload:**
Ein vollständiger JSON-Datensatz gemäß dieser Spezifikation hat folgendes Format:
```json
{
"t": "Testaufnahme System A",
"a": "Entwickler-Team",
"r": "Überprüfung der Mikrofon-Aussteuerung.",
"c": ["Test", "Audio", "V1"],
"dc": "2026-03-05T13:00:00Z",
"ds": "2026-03-05T13:10:00Z"
}
```
*(Hinweis zur Skalierbarkeit: Für zukünftige Erweiterungen können dedizierte TLV-Typen definiert werden, wie beispielsweise 0x11 für GZIP-komprimierte JSON-Daten oder 0x20 für binäre Bilddaten wie PNG-Cover).*
---
## 4. Lese-Algorithmus (Parser-Logik)
Der Controller extrahiert die Hardware-Parameter nach folgendem Ablauf:
1. **Footer lokalisieren:** * Gehe zu `EOF - 8`. Lese 8 Bytes in das `tag_footer_t` Struct.
* Validiere `magic == "TAG!"` und `version == 0x0001` (unter Berücksichtigung von Little-Endian Konvertierung via `sys_le16_to_cpu`).
2. **Grenzen berechnen:**
* Lese `total_size`.
* Die reinen Audiodaten enden bei `audio_limit = EOF - total_size`.
* Gehe zur Position `audio_limit`.
3. **TLV-Blöcke iterieren:**
* Solange die aktuelle Leseposition kleiner als `EOF - 8` ist:
* Lese 4 Bytes in den `tlv_header_t`.
* Wenn `type == 0x00`: Lese die nächsten 8 Bytes in das `tlv_audio_format_t` Struct.
* Wenn `type != 0x00`: Führe `fs_seek(header.length, FS_SEEK_CUR)` aus.
---
## 5. Hex-Beispiel
Eine fiktive Datei enthält Audio-Daten. Es soll ein PCM-Mono Format (16 Bit, 16 kHz) sowie ein kurzes JSON `{"t":"A"}` (9 Bytes) angehängt werden.
**1. TLV 0x00 (Audio Format):**
* Header: `00 00 08 00` (Type 0, Index 0, Length 8)
* Payload: `00 10 00 00 80 3E 00 00` (Mono, 16-Bit, Reserved, 16000 Hz)
**2. TLV 0x10 (JSON):**
* Header: `10 00 09 00` (Type 16, Index 0, Length 9)
* Payload: `7B 22 74 22 3A 22 41 22 7D` (`{"t":"A"}`)
**3. Footer:**
* Total Size: `2D 00` (45 Bytes = 12 Bytes Audio-TLV + 13 Bytes JSON-TLV + 12 Bytes Padding/Zusatz + 8 Bytes Footer) -> *Hinweis: Size ist in diesem Konstrukt abhängig vom genauen Payload.*
* Version: `01 00`
* Magic: `54 41 47 21` (`TAG!`)

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import FileListItem from "./FileListItem.svelte";
import { localAudioFiles, buzzerAudioFiles } from "../lib/store";
import { slide } from "svelte/transition";
import { flip } from "svelte/animate";
export let type: "local" | "buzzer" = "buzzer";
@@ -10,11 +12,13 @@
<div class="flex flex-col w-full">
{#each $activeStore as file (file.name)}
<div transition:slide|local={{ duration: 500 }} animate:flip={{ duration: 500 }}>
<FileListItem {file} {type} />
</div>
{/each}
{#if $activeStore.length === 0}
<div class="p-4 text-center text-text-muted text-sm italic tracking-widest uppercase">
<div class="p-4 text-center text-slate-400 text-sm italic tracking-widest uppercase">
Keine Dateien vorhanden.
</div>
{/if}

View File

@@ -1,71 +1,225 @@
<script lang="ts">
import type { BuzzerFile } from "../lib/types";
import { FileAudioIcon } from "phosphor-svelte";
import { isFetchingRemote, transferStats, transferDetails, buzzerAudioFiles } from "../lib/store";
import {
MusicNotesIcon,
DotsThreeVerticalIcon,
PlayIcon,
TrashIcon,
InfoIcon,
CircleIcon,
QuestionIcon,
UserCircleCheckIcon,
WarningCircleIcon,
PersonIcon,
TagIcon,
UserIcon,
} from "phosphor-svelte";
import {
isFetchingRemote,
transferStats,
transferDetails,
buzzerAudioFiles,
localAudioFiles,
} from "../lib/store";
import { SETTINGS } from "../lib/settings";
import { tagEditorState } from "../lib/store";
import { tooltip } from "../lib/actions/tooltip";
export let file: BuzzerFile;
export let type: "local" | "buzzer" = "buzzer";
let menuOpen = false;
// Status-Berechnung für die Queue
$: selectedFiles = $buzzerAudioFiles.filter(f => f.selected);
$: currentIndex = selectedFiles.findIndex(f => f.name === $transferStats.currentFileName);
$: myIndex = selectedFiles.findIndex(f => f.name === file.name);
$: activeStore = type === "local" ? localAudioFiles : buzzerAudioFiles;
$: selectedFiles = $activeStore.filter((f) => f.selected);
$: currentIndex = selectedFiles.findIndex((f) => f.name === $transferStats.currentFileName);
$: myIndex = selectedFiles.findIndex((f) => f.name === file.name);
$: state = (() => {
if (!file.selected || !$isFetchingRemote) return 'default';
if (file.name === $transferStats.currentFileName) return 'active';
if (myIndex < currentIndex) return 'done';
if (myIndex > currentIndex) return 'pending';
return 'default';
if (!file.selected || !$isFetchingRemote) return "default";
if (file.name === $transferStats.currentFileName) return "active";
if (myIndex < currentIndex) return "done";
if (myIndex > currentIndex) return "pending";
return "default";
})();
function toggleSelection() {
if ($isFetchingRemote) return; // Blockiert Änderungen während des Transfers
file.selected = !file.selected;
buzzerAudioFiles.update(files => files); // Triggert die Reaktivität im Svelte-Store
if ($isFetchingRemote) return;
if (type === "buzzer") {
buzzerAudioFiles.update((files) =>
files.map((entry) =>
entry.name === file.name ? { ...entry, selected: !entry.selected } : entry,
),
);
return;
}
localAudioFiles.update((files) =>
files.map((entry) =>
entry.name === file.name ? { ...entry, selected: !entry.selected } : entry,
),
);
}
</script>
<div class="relative overflow-hidden">
<svelte:window on:click={() => (menuOpen = false)} />
{#if state === 'active'}
<div class="relative overflow-hidden group/item">
{#if state === "active"}
<div
class="absolute top-0 left-0 h-full bg-indigo-100 z-0"
style="width: {$transferDetails.filePercent}%; transition: {$transferDetails.filePercent === 0 ? 'none' : `width ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
style="width: {$transferDetails.filePercent}%; transition: {$transferDetails.filePercent === 0
? 'none'
: `width ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
></div>
{/if}
<button
class="relative z-10 w-full text-left flex-1 px-3 py-1 flex items-center border-l-4 transition-colors border-b border-b-border-card
class="relative z-10 w-full text-left flex-1 px-3 py-1 pr-16 flex items-center border-l-4 transition-colors border-b border-b-border-card
{file.selected ? 'border-l-blue-600' : 'border-l-transparent'}
{file.selected && state !== 'active' ? 'bg-blue-50' : ''}
{!$isFetchingRemote && file.selected ? 'hover:bg-blue-100 cursor-pointer' : ''}
{!$isFetchingRemote && !file.selected ? 'hover:bg-slate-100 hover:border-l-blue-200 cursor-pointer' : ''}
{!$isFetchingRemote && !file.selected
? 'hover:bg-slate-100 hover:border-l-blue-200 cursor-pointer'
: ''}
{$isFetchingRemote ? 'cursor-default' : ''}
{state === 'pending' ? 'grayscale opacity-80' : ''}"
on:click={toggleSelection}
disabled={$isFetchingRemote}
>
<FileAudioIcon class="text-blue-600 mr-3 w-5 h-5" />
<div class="flex flex-col">
<span class="font-light">
<MusicNotesIcon weight="fill" class="mr-3 w-5 h-5 shrink-0" />
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
<div class="flex items-center min-w-0">
<span class="font-light truncate min-w-0 text-sm">
{file.name || "Unbekannte Datei"}
{#if file.metaTags?.t}
&thinsp;-&thinsp;
<span class="font-medium">{file.metaTags.t}</span>
<span class="font-normal">{file.metaTags.t}</span>
{/if}
</span>
<div class="text-xs">
<span class="font-light text-text-muted text-xs">
{parseFloat((file.size/1024).toFixed(1))}&thinsp;kB
</div>
<div class="flex items-center gap-0 text-xs text-text-muted mt-0.5 min-w-0">
{#if file.sysTags?.crc32}
<span
use:tooltip={{
text:
"crc32: <span class='font-mono'>0x" +
file.sysTags.crc32.toString(16).toUpperCase() +
"</span>",
pos: "right",
}}
>
<CircleIcon weight="fill" class="mr-1 shrink-0 text-emerald-600 w-3.5 h-3.5" />
</span>
<span>
{#if file.metaTags?.a}<span class="text-text-muted"> | Author:</span>&thinsp;{file.metaTags.a}{/if}
{:else}
<span
use:tooltip={{
text: "Keine Prüfsumme in den Tags verfügbar. Bitte aktualisiere über das Menü die CRC32-Tags.",
pos: "right",
variant: "warning",
}}
>
<QuestionIcon weight="fill" class="mr-1 shrink-0 text-amber-500 w-3.5 h-3.5" />
</span>
{/if}
<span class="font-light shrink-0">
{parseFloat((file.size / 1024).toFixed(1))}&thinsp;kB
</span>
{#if file.metaTags?.a}
<UserIcon weight="fill" class="ml-1 pl-1 mr-0.5 shrink-0 text-slate-500 w-4.5 h-3.5 border-l border-l-text-muted" />
<span class="truncate min-w-0">{file.metaTags.a}</span>
{/if}
{#if file.metaTags?.c}
<TagIcon weight="fill" class="ml-1 pl-1 mr-0.5 shrink-0 text-slate-500 w-4.5 h-3.5 border-l border-l-text-muted" />
<span class="truncate min-w-0">{file.metaTags.c}</span>
{/if}
</div>
</div>
</button>
<div class="menu-btn-grp group/menu" class:is-open={menuOpen}>
<div
class="flex items-center overflow-hidden transition-all duration-300 ease-in-out
{menuOpen
? 'max-w-[120px] opacity-100'
: 'max-w-0 opacity-0 group-hover/menu:max-w-[120px] group-hover/menu:opacity-100'}"
>
<button
class="menu-btn danger"
title="Löschen"
on:click|stopPropagation={() => {
console.log("Delete", file.name);
menuOpen = false;
}}
>
<TrashIcon class="w-5 h-5" />
</button>
<button
class="menu-btn"
title="Abspielen"
on:click|stopPropagation={() => {
console.log("Play", file.name);
menuOpen = false;
}}
>
<PlayIcon class="w-5 h-5" />
</button>
<button
class="menu-btn"
title="Datei-Info"
on:click|stopPropagation={() => {
console.log("Info", file.name);
tagEditorState.set({ show: true, type, fileName: file.name });
menuOpen = false;
}}
>
<InfoIcon class="w-5 h-5" />
</button>
</div>
<button
class="menu-btn !border-r-transparent"
on:click|stopPropagation={() => (menuOpen = !menuOpen)}
>
<DotsThreeVerticalIcon class="w-5 h-5" />
</button>
</div>
</div>
<style>
@reference "../styles/app.css";
.menu-btn-grp {
@apply absolute right-2 top-1/2 -translate-y-1/2 z-20 overflow-hidden
p-0 flex items-center backdrop-blur-sm
border border-transparent rounded-full transition-all;
}
/* Kombiniert Hover von Mausnutzern und aktiven Touch-Zustand */
.menu-btn-grp:hover,
.menu-btn-grp.is-open {
@apply border-border-card bg-white shadow-sm;
}
.menu-btn {
@apply p-1.5 border-r border-r-border-card
flex items-center justify-center shrink-0 transition-colors;
&:not(:disabled):not(.danger) {
@apply hover:bg-surface-hover;
}
&.danger:not(:disabled) {
@apply text-red-700 hover:bg-red-100;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed grayscale;
}
}
</style>

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import { fade, slide } from "svelte/transition";
import { localAudioFiles, buzzerAudioFiles } from "../lib/store";
import { deleteSelectedLocalFiles } from "../lib/sync";
import { addToast } from "../lib/toast";
import { tooltip } from "../lib/actions/tooltip";
import { updateLocalAudioCrc } from "../lib/tagHandler";
import { refreshLocal } from "../lib/sync";
import {
GearIcon,
CloudArrowUpIcon,
ArrowClockwiseIcon,
DotsThreeVerticalIcon,
CheckSquareOffsetIcon,
SquareIcon,
DownloadIcon,
TrashIcon,
FingerprintIcon,
} from "phosphor-svelte";
export let show = false;
export let type: "local" | "buzzer" = "buzzer";
export let onClose: () => void;
// Interne reaktive Logik statt Props [cite: 15, 16]
$: activeStore = type === "local" ? localAudioFiles : buzzerAudioFiles;
$: fileCount = $activeStore.length;
$: totalSize = $activeStore.reduce((sum, f) => sum + f.size, 0);
$: selectedFileCount = $activeStore.filter((f) => f.selected).length;
$: selectedFileSize = $activeStore.filter((f) => f.selected).reduce((sum, f) => sum + f.size, 0);
function closeMenu() {
if (onClose) onClose();
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}\u202B`;
else if (bytes < 10 * 1024) return `${(bytes / 1024).toFixed(2)}\u202F\KiB`;
else if (bytes < 100 * 1024) return `${(bytes / 1024).toFixed(1)}\u202F\KiB`;
else if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}\u202F\KiB`;
else return `${(bytes / (1024 * 1024)).toFixed(2)}\u202F\MiB`;
}
</script>
<svelte:window
on:click={() => {
if (show) closeMenu();
}}
on:keydown={(e) => {
if (show && e.key === "Escape") closeMenu();
}}
/>
{#if show}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full h-full z-20 bg-white flex flex-col items-center justify-start rounded-b-lg shadow-sm"
transition:fade={{ duration: 150 }}
on:click|stopPropagation={closeMenu}
>
<div class="w-full flex flex-col" on:click|stopPropagation>
<div
class="list_entry text-center bg-gradient-to-b from-white to-slate-100"
class:text-text-muted={fileCount === 0}
>
{#if fileCount === 0}
Keine Dateien vorhanden.
{:else if selectedFileCount === 0}
keine Datei ausgewählt
{:else if selectedFileCount === fileCount}
Alle {fileCount} Dateien ausgewählt ({formatSize(selectedFileSize)})
{:else}
{selectedFileCount} von {fileCount} Dateien ausgewählt ({formatSize(
selectedFileSize,
)}/{formatSize(totalSize)})
{/if}
</div>
<button
class="menu-btn"
on:click={() => {
$activeStore.forEach((f) => (f.selected = true));
$activeStore = $activeStore;
}}
disabled={fileCount === 0 || selectedFileCount === fileCount}
>
<CheckSquareOffsetIcon class="btn-icon" />
Alle Dateien auswählen
</button>
<button
class="menu-btn"
on:click={() => {
$activeStore.forEach((f) => (f.selected = false));
$activeStore = $activeStore;
}}
disabled={fileCount === 0 || selectedFileCount === 0}
>
<SquareIcon class="btn-icon" />
Alle Dateien abwählen
</button>
{#if selectedFileCount > 0}
<div
class="list_entry border-t mt-2 text-medium bg-gradient-to-b from-white to-slate-100 flex items-justify items-center"
class:text-text-muted={selectedFileCount === 0}
transition:slide={{ duration: 150 }}
>
<div>Augesählte Dateien:</div>
<div
class="ml-auto text-2xs text-white bg-gradient-to-b from-blue-400 to-blue-600 rounded-full px-2 py-0.5"
class:hidden={selectedFileCount === 0}
>
{#if selectedFileCount > 0}
{selectedFileCount}
{/if}
</div>
</div>
<button
class="menu-btn"
disabled={selectedFileCount === 0}
on:click={async () => {
if (type === "buzzer") {
addToast("CRC32 Update auf Buzzer wird derzeit nicht unterstützt.", "error");
} else {
const selectedFiles = $activeStore.filter((f) => f.selected);
for (const file of selectedFiles) {
await updateLocalAudioCrc(file.name);
}
await refreshLocal();
addToast(`CRC32-Prüfsumme${selectedFiles.length > 1 ? "n" : ""} von ${selectedFiles.length > 1 ? selectedFiles.length + " lokalen Dateien": "einer lokalen Datei"} aktualisiert.`, "success");
}
closeMenu();
}}
use:tooltip={{
text: "Berechnet die CRC32 des Audioanteils einer Datei und speichert sie in den Tags.",
position: "right",
}}
>
<FingerprintIcon class="btn-icon" />
CRC32 neu berechnen und speichern
</button>
<button
class="menu-btn danger"
disabled={selectedFileCount === 0}
on:click={() => {
if (type === "buzzer")
addToast(
"Löschen von Dateien auf dem Buzzer wird derzeit nicht unterstützt.",
"error",
);
else deleteSelectedLocalFiles();
closeMenu();
}}
>
<TrashIcon class="btn-icon" />
Löschen
</button>
{/if}
</div>
</div>
{/if}
<style>
@reference "../styles/app.css";
.list_entry {
@apply py-2 px-4 border-border-card border-b-1 text-sm;
}
.menu-btn {
@apply w-full px-4 border-b border-b-border-card text-left text-sm flex items-center min-h-10;
&:not(:disabled):not(.danger) {
@apply hover:bg-surface-hover;
}
/* Spezifisches Styling für rote Aktions-Buttons (z.B. Löschen) */
&.danger:not(:disabled) {
@apply text-red-700 hover:bg-red-100;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed grayscale;
}
}
:global(.btn-icon) {
@apply w-5 h-5 mr-2;
}
</style>

View File

@@ -0,0 +1,442 @@
<script lang="ts">
import { updateFile } from "../lib/tagHandler";
import { refreshLocal, refreshRemote } from "../lib/sync";
import { fade, slide } from "svelte/transition";
import { localAudioFiles, buzzerAudioFiles, fsInfo } from "../lib/store";
import type { MetadataTags } from "../lib/types";
import {
XIcon,
CaretLeftIcon,
CaretRightIcon,
FloppyDiskIcon,
ArrowUUpLeftIcon,
CheckSquareIcon,
SquareIcon,
PencilIcon,
} from "phosphor-svelte";
import { addToast } from "../lib/toast";
export let show = false;
export let type: "local" | "buzzer" = "buzzer";
export let initialFileName: string | null = null;
export let onClose: () => void;
let drafts: Record<string, { newName: string; tags: MetadataTags }> = {};
let currentFileName = "";
let applyToBoth = false;
let isDropdownOpen = false;
let lastOpenedName: string | null = null;
$: autoApplyIcon = applyToBoth ? CheckSquareIcon : SquareIcon;
$: activeStore = type === "local" ? localAudioFiles : buzzerAudioFiles;
$: fileList = $activeStore;
$: if (show && initialFileName !== lastOpenedName) {
if (initialFileName) {
currentFileName = initialFileName;
} else if (fileList.length > 0) {
currentFileName = fileList[0].name;
}
lastOpenedName = initialFileName;
}
$: if (!show) {
lastOpenedName = null;
}
$: currentIndex = fileList.findIndex((f) => f.name === currentFileName);
$: currentFile = fileList[currentIndex];
$: hasDraft = currentFile ? drafts[currentFile.name] !== undefined : false;
$: hasAnyDrafts = Object.keys(drafts).length > 0;
$: activeDraft = currentFile ? drafts[currentFile.name] : null;
$: activeTags = activeDraft ? activeDraft.tags : currentFile?.metaTags || {};
$: activeName = activeDraft ? activeDraft.newName : currentFile?.name || "";
$: maxFilenameLength = $fsInfo ? $fsInfo.maxPathLength - $fsInfo.audioPath.length - 2 : 30;
function closeEditor() {
if (onClose) onClose();
}
function nextFile() {
if (currentIndex < fileList.length - 1) {
currentFileName = fileList[currentIndex + 1].name;
isDropdownOpen = false;
}
}
function prevFile() {
if (currentIndex > 0) {
currentFileName = fileList[currentIndex - 1].name;
isDropdownOpen = false;
}
}
function initDraftIfNeeded() {
if (!currentFile) return;
if (!drafts[currentFile.name]) {
drafts[currentFile.name] = {
newName: currentFile.name,
tags: { ...(currentFile.metaTags || {}) },
};
}
}
function updateTag(field: keyof MetadataTags, event: Event) {
initDraftIfNeeded();
if (currentFile) {
drafts[currentFile.name].tags[field] = (event.target as HTMLInputElement).value;
drafts = drafts;
}
}
function updateName(event: Event) {
initDraftIfNeeded();
if (currentFile) {
const inputEl = event.target as HTMLInputElement;
let val = inputEl.value.replace(/[^a-zA-Z0-9_\-\.]/g, "");
if (val.length > maxFilenameLength) val = val.substring(0, maxFilenameLength);
inputEl.value = val;
drafts[currentFile.name].newName = val;
drafts = drafts;
}
}
function resetCurrent() {
if (!currentFile) return;
delete drafts[currentFile.name];
drafts = drafts;
}
function resetAll() {
drafts = {};
}
async function saveCurrent() {
if (!currentFile || !drafts[currentFile.name]) return;
const draft = drafts[currentFile.name];
const oldName = currentFile.name;
const newName = draft.newName;
try {
await updateFile(oldName, newName, currentFile.sysTags, draft.tags, type);
addToast(`Datei ${newName} gespeichert.`, "success");
delete drafts[oldName];
drafts = drafts;
if (oldName !== newName) currentFileName = newName;
if (type === "local") await refreshLocal();
if (type === "buzzer") await refreshRemote();
} catch (error) {
addToast("Fehler beim Speichern.", "error");
}
}
async function saveAll() {
try {
let savedCount = 0;
for (const [oldName, draft] of Object.entries(drafts)) {
const file = fileList.find((f) => f.name === oldName);
if (file) {
await updateFile(oldName, draft.newName, file.sysTags, draft.tags, type);
savedCount++;
}
}
drafts = {};
addToast(`${savedCount} Dateien gespeichert.`, "success");
if (type === "local") await refreshLocal();
if (type === "buzzer") await refreshRemote();
} catch (error) {
addToast("Fehler beim Speichern.", "error");
}
}
function formatSize(bytes: number) {
return (bytes / 1024).toFixed(1) + "\u202FkB";
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (!node.contains(event.target as Node) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent("outclick"));
}
};
document.addEventListener("click", handleClick, true);
return {
destroy() {
document.removeEventListener("click", handleClick, true);
},
};
}
function getDropdownItemClass(fileName: string, current: string, currentDrafts: any): string {
const isChanged = !!currentDrafts[fileName];
const isSelected = fileName === current;
if (isChanged)
return isSelected
? "bg-amber-50 hover:bg-amber-100 font-semibold"
: "bg-amber-50 hover:bg-amber-100";
return isSelected ? "font-semibold hover:bg-slate-100" : "bg-white hover:bg-slate-100";
}
function isTagChanged(
field: keyof MetadataTags,
currentDraftTags: MetadataTags,
originalFile: any,
draftExists: boolean,
): boolean {
if (!draftExists || !originalFile) return false;
const orig = originalFile.metaTags?.[field];
const draft = currentDraftTags[field];
const origStr = Array.isArray(orig) ? orig.join(", ") : orig || "";
const draftStr = Array.isArray(draft) ? draft.join(", ") : draft || "";
return origStr !== draftStr;
}
function getInputClass(isChanged: boolean): string {
return isChanged
? "border-amber-400 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 bg-amber-50/50 text-amber-900"
: "border-border-card focus:border-on-surface focus:ring-1 focus:ring-on-surface";
}
function handleKeydown(event: KeyboardEvent) {
if (show && event.key === "Escape" && !isDropdownOpen) {
closeEditor();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if show && currentFile}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full h-full bg-white flex flex-col rounded-b-lg shadow-sm overflow-hidden"
transition:fade={{ duration: 150 }}
on:click|stopPropagation
>
<div
class="flex items-center justify-between p-2 border-b border-border-card bg-gradient-to-b from-white {hasDraft
? 'to-amber-100'
: 'to-slate-100'}"
>
<button class="nav-btn" on:click={prevFile} disabled={currentIndex === 0}>
<CaretLeftIcon class="w-5 h-5" />
</button>
<div
class="relative flex-1 px-2"
use:clickOutside
on:outclick={() => (isDropdownOpen = false)}
>
<button
class="w-full flex items-center justify-center gap-1.5 text-sm font-semibold cursor-pointer outline-none"
on:click={() => (isDropdownOpen = !isDropdownOpen)}
>
{#if hasDraft}<PencilIcon class="w-4 h-4 shrink-0 text-amber-700" />{/if}
<span class="truncate {hasDraft ? 'text-amber-800' : 'text-slate-800'}">
{activeName}
</span>
</button>
{#if isDropdownOpen}
<div
transition:slide={{ duration: 200 }}
class="absolute left-0 right-0 top-full mt-2 bg-white shadow-lg rounded-md border border-border-card max-h-64 overflow-y-auto z-50"
>
{#each fileList as file}
<button
class="w-full text-left px-3 py-2 text-sm transition-colors flex items-center gap-2 border-b border-border-card last:border-0 {getDropdownItemClass(
file.name,
currentFileName,
drafts,
)}"
on:click={() => {
currentFileName = file.name;
isDropdownOpen = false;
}}
>
{#if drafts[file.name]}<PencilIcon class="w-4 h-4 shrink-0 text-amber-600" />
<span class="truncate text-amber-800">{drafts[file.name].newName}</span>
{:else}<div class="w-4 h-4 shrink-0"></div>
<span class="truncate text-slate-700">{file.name}</span>{/if}
</button>
{/each}
</div>
{/if}
</div>
<button class="nav-btn" on:click={nextFile} disabled={currentIndex === fileList.length - 1}>
<CaretRightIcon class="w-5 h-5" />
</button>
<button
class="ml-2 p-1.5 hover:bg-slate-200 rounded-full transition-colors"
on:click={closeEditor}
>
<XIcon class="w-5 h-5" />
</button>
</div>
<div class="flex-1 overflow-y-auto flex flex-col">
<div
class="px-4 py-3 bg-slate-50 border-b border-border-card flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500 font-mono"
>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-slate-400">Grösse</span>
<span>{formatSize(currentFile.size)}</span>
</div>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-slate-400">CRC32</span>
<span>
{currentFile.sysTags?.crc32
? `0x${currentFile.sysTags.crc32.toString(16).toUpperCase()}`
: "N/A"}
</span>
</div>
<div class="flex flex-col">
<span class="text-[10px] uppercase text-slate-400">Audio Codec</span>
<span>
{currentFile.sysTags?.format?.codec || "PCM"} / {currentFile.sysTags?.format
?.sampleRate || "16000"}Hz
</span>
</div>
</div>
<div class="p-4 flex flex-col gap-3 {hasDraft ? 'bg-amber-50/50' : ''}">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-slate-600">Dateiname</span>
<input
type="text"
value={activeName}
on:input={updateName}
maxlength={maxFilenameLength}
class="editor-input font-medium {getInputClass(
hasDraft && activeName !== currentFile.name,
)}"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-slate-600">Titel</span>
<input
type="text"
value={activeTags.t || ""}
on:input={(e) => updateTag("t", e)}
class="editor-input {getInputClass(
isTagChanged('t', activeTags, currentFile, hasDraft),
)}"
placeholder="Titel..."
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-slate-600">Autor</span>
<input
type="text"
value={activeTags.a || ""}
on:input={(e) => updateTag("a", e)}
class="editor-input {getInputClass(
isTagChanged('a', activeTags, currentFile, hasDraft),
)}"
placeholder="Autor..."
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-slate-600">Tags/Kategorien</span>
<input
type="text"
value={Array.isArray(activeTags.c) ? activeTags.c.join(", ") : activeTags.c || ""}
on:input={(e) => updateTag("c", e)}
class="editor-input {getInputClass(
isTagChanged('c', activeTags, currentFile, hasDraft),
)}"
placeholder="Tags..."
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-slate-600">Bemerkungen</span>
<textarea
value={activeTags.r || ""}
on:input={(e) => updateTag("r", e)}
class="editor-input resize-none h-20 {getInputClass(
isTagChanged('r', activeTags, currentFile, hasDraft),
)}"
placeholder="Infos..."
></textarea>
</label>
</div>
</div>
<div class="flex flex-col border-t border-border-card bg-white mt-auto">
{#if hasAnyDrafts}
<div class="flex bg-amber-50">
<button
class="menu-btn !border-b-0 flex-1 text-amber-700 hover:bg-amber-100"
on:click={resetAll}
>
<ArrowUUpLeftIcon class="btn-icon" /> Alle zurück
</button>
<button
class="menu-btn !border-b-0 flex-1 border-l border-amber-200 text-amber-700 hover:bg-amber-100 font-semibold"
on:click={saveAll}
>
<FloppyDiskIcon class="btn-icon" /> Alle speichern
</button>
</div>
<div class="border-t border-amber-200"></div>
{/if}
<div class="flex">
<button class="menu-btn flex-1" disabled={!hasDraft} on:click={resetCurrent}>
<ArrowUUpLeftIcon class="btn-icon" /> Zurück
</button>
<button
class="menu-btn flex-1 border-l border-border-card font-semibold {hasDraft
? 'text-blue-700'
: ''}"
disabled={!hasDraft}
on:click={saveCurrent}
>
<FloppyDiskIcon class="btn-icon" /> Speichern
</button>
</div>
<button
class="menu-btn bg-slate-50 hover:bg-slate-100 !justify-start text-slate-700"
on:click={() => (applyToBoth = !applyToBoth)}
>
<svelte:component
this={autoApplyIcon}
class="btn-icon {applyToBoth ? 'text-blue-600' : 'text-slate-400'}"
/> Auch {type === "buzzer" ? "lokal" : "auf dem Buzzer"} anwenden
</button>
</div>
</div>
{/if}
<style>
@reference "../styles/app.css";
.nav-btn {
@apply p-1.5 rounded-full transition-colors;
&:not(:disabled) {
@apply hover:bg-slate-200 cursor-pointer;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed grayscale;
}
}
.editor-input {
@apply w-full px-3 py-2 text-sm bg-white border rounded shadow-sm outline-none transition-colors;
}
.menu-btn {
@apply w-full px-4 border-b border-border-card text-left text-xs sm:text-sm flex justify-center items-center min-h-10 transition-colors;
&:not(:disabled) {
@apply hover:bg-surface-hover cursor-pointer;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed grayscale;
}
}
:global(.btn-icon) {
@apply w-4 h-4 mr-2 shrink-0;
}
</style>

View File

@@ -12,15 +12,27 @@
} from "phosphor-svelte";
import { slide } from "svelte/transition";
import {
isConnected, isConnecting, availableDevices, pairedDevices,
activeDeviceId, autoConnect, loadConnectionState, fsInfo
isConnected,
isConnecting,
availableDevices,
pairedDevices,
activeDeviceId,
autoConnect,
loadConnectionState,
fsInfo,
} from "../lib/store";
import { connectBuzzer, disconnectBuzzer, pairBuzzer, forgetDevice, getPairedDevices } from "../lib/bluetooth";
import {
connectBuzzer,
disconnectBuzzer,
pairBuzzer,
forgetDevice,
getPairedDevices,
} from "../lib/bluetooth";
let showDropdown = false;
$: lastDeviceId = loadConnectionState()?.deviceId;
$: targetDevice = $pairedDevices.find(d => d.id === lastDeviceId);
$: targetDevice = $pairedDevices.find((d) => d.id === lastDeviceId);
$: canQuickConnect = targetDevice ? $availableDevices.has(targetDevice.id) : false;
$: autoConnectIcon = $autoConnect ? CheckSquareIcon : SquareIcon;
@@ -53,17 +65,23 @@
</script>
<header
class="fixed top-0 left-0 w-full h-16 bg-surface-card border-b border-border-card z-50 flex items-center justify-between px-4 lg:px-8 bg-gradient-to-b from-white to-slate-100"
class="fixed top-0 left-0 w-full h-16 bg-surface-card border-b border-border-card z-50
flex items-center justify-between px-4 lg:px-8 bg-gradient-to-b from-white to-slate-100"
>
<div class="flex items-center">
<span class="text-lg uppercase text-brand">
<span class="text-2xl uppercase text-brand">
<span class="font-extrabold">Edis&nbsp;Buzzer</span>
&nbsp;
<span class="font-light">CONTROL</span>
</span>
</div>
<div class="relative" class:disabled={$isConnecting} use:clickOutside on:outclick={() => (showDropdown = false)}>
<div
class="relative"
class:disabled={$isConnecting}
use:clickOutside
on:outclick={() => (showDropdown = false)}
>
<div
class="btn-connect-group"
class:connected={$isConnected}
@@ -83,7 +101,11 @@
{/if}
</button>
<button class="btn-dropdown" on:click={() => (showDropdown = !showDropdown)} disabled={$isConnecting}>
<button
class="btn-dropdown"
on:click={() => (showDropdown = !showDropdown)}
disabled={$isConnecting}
>
<CaretDownIcon
weight="bold"
class="w-4 h-4 transition-transform duration-300 {showDropdown ? '-scale-y-100' : ''}"
@@ -100,17 +122,22 @@
{#each $pairedDevices as dev (dev.id)}
<HeaderDeviceListItem
device={dev}
name={dev.name || ''}
name={dev.name || ""}
isConnected={$activeDeviceId === dev.id}
isLastConnectedDevice={lastDeviceId === dev.id}
isAvailable={$availableDevices.has(dev.id)}
on:connect={(e) => { connectBuzzer(e.detail); showDropdown = false; }}
on:forget={(e) => forgetDevice(e.detail)}
onConnect={(device) => {
connectBuzzer(device);
showDropdown = false;
}}
onForget={(device) => forgetDevice(device)}
/>
{/each}
{#if $pairedDevices.length === 0}
<div class="px-4 py-3 text-xs text-text-muted text-center italic border-b border-border-card">
<div
class="px-4 py-3 text-xs text-text-muted text-center italic border-b border-border-card"
>
Keine Geräte gekoppelt
</div>
{/if}
@@ -118,7 +145,13 @@
<div
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-connect relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
>
<button class="flex items-center w-full text-left" on:click={() => { pairBuzzer(); showDropdown = false; }}>
<button
class="flex items-center w-full text-left"
on:click={() => {
pairBuzzer();
showDropdown = false;
}}
>
<LinkIcon weight="bold" class="mr-2 w-5 h-5" />
<div class="flex-1">
<div class="flex flex-col">
@@ -146,7 +179,10 @@
<div
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-auto relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
>
<button class="flex items-center w-full text-left" on:click={() => $autoConnect = !$autoConnect}>
<button
class="flex items-center w-full text-left"
on:click={() => ($autoConnect = !$autoConnect)}
>
<svelte:component this={autoConnectIcon} class="mr-2 w-5 h-5" />
<div class="flex-1">
<div class="flex flex-col">
@@ -167,24 +203,28 @@
@apply border-border-card;
}
.connected .btn-main, .connected .btn-dropdown {
@apply hover:bg-surface-hover text-slate-700;
.connected .btn-main,
.connected .btn-dropdown {
@apply bg-gradient-to-b from-white to-slate-200 hover:from-slate-100 hover:to-slate-300;
}
.last-available, .last-unavailable {
.last-available,
.last-unavailable {
@apply border-transparent;
}
.last-available .btn-main, .last-available .btn-dropdown {
@apply bg-emerald-600 hover:bg-emerald-700 text-white;
.last-available .btn-main,
.last-available .btn-dropdown {
@apply bg-gradient-to-b from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white;
}
.last-unavailable .btn-main, .last-unavailable .btn-dropdown {
@apply bg-indigo-600 hover:bg-indigo-700 text-white;
.last-unavailable .btn-main,
.last-unavailable .btn-dropdown {
@apply bg-gradient-to-b from-indigo-400 to-indigo-600 hover:from-indigo-400 hover:to-indigo-700 text-white;
}
.btn-connect-group {
@apply flex items-stretch rounded-lg overflow-hidden shadow-sm border;
@apply flex items-stretch rounded-full overflow-hidden shadow-sm;
}
.btn-main {

View File

@@ -1,7 +1,6 @@
<!-- HeaderDeviceListItem.svelte -->
<script lang="ts">
import { BluetoothIcon, BluetoothSlashIcon, LinkBreakIcon } from "phosphor-svelte";
import { createEventDispatcher } from "svelte";
export let device: any = null;
export let isAvailable: boolean = false;
@@ -10,8 +9,10 @@
export let name: string = "";
export let type: string = "ble";
export let onConnect: (device: any) => void;
export let onForget: (device: any) => void;
let isHovered = false;
const dispatch = createEventDispatcher();
</script>
<div
@@ -26,7 +27,7 @@
>
<button
class="flex-1 flex items-center text-left min-2-0"
on:click={() => dispatch("connect", device)}
on:click={() => onConnect(device)}
disabled={!isAvailable || isConnected}
>
{#if type === "ble"}
@@ -55,6 +56,7 @@
<button
on:mouseenter={() => (isHovered = true)}
on:mouseleave={() => (isHovered = false)}
on:click|stopPropagation={() => onForget(device)}
title="'{name}' entfernen"
class="flex-shrink-0"
>

View File

@@ -1,15 +1,13 @@
<script lang="ts">
import { isConnected, fsInfo } from "../lib/store";
import { isConnected, fsInfo, tagEditorState } from "../lib/store";
import {
GearIcon,
CloudArrowUpIcon,
ListIcon,
ArrowClockwiseIcon,
DotsThreeVerticalIcon,
CheckSquareOffsetIcon,
SquareIcon,
DownloadIcon,
XIcon,
} from "phosphor-svelte";
import FileList from "./FileList.svelte";
import DeviceInfo from "./DeviceInfo.svelte";
@@ -26,10 +24,16 @@
} from "../lib/store";
import { SETTINGS } from "../lib/settings";
import TransferProgress from "./TransferProgress.svelte";
import FileMenuOverlay from "./FileMenuOverlay.svelte";
import FileTagEditor from "./FileTagEditor.svelte";
let showOverlay = false;
let isTransferFinished = false;
let overlayTimeout: ReturnType<typeof setTimeout>;
let showBuzzerMenu = false;
let showLocalMenu = false;
let editModeType: "local" | "buzzer" | null = null;
let fileToEdit: string | null = null;
$: currentDevice = $pairedDevices.find((d) => d.id === $activeDeviceId);
@@ -55,6 +59,11 @@
// Optional: resetTransferStats() aufrufen, um die Werte zu nullen
}
export function openTagEditor(type: "local" | "buzzer", fileName: string) {
editModeType = type;
fileToEdit = fileName;
}
function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return "∞";
const m = Math.floor(seconds / 60);
@@ -105,19 +114,43 @@
</div>
</section>
<section class="buzzer-card">
<section class="buzzer-card relative self-start">
<div class="card-header">
<h3 class="card-title">Lokale Bibliothek</h3>
<button class="btn" aria-label="Menu">
<ListIcon class="icon" />
<button
class="btn"
aria-label="Menu"
on:click|stopPropagation={() => (showLocalMenu = !showLocalMenu)}
>
<DotsThreeVerticalIcon class="icon" />
</button>
</div>
<div class="grid">
<div class="col-start-1 row-start-1">
<div class="card-body">
<FileList type="local" />
</div>
</div>
<div
class="col-start-1 row-start-1 z-30"
class:hidden={!$tagEditorState.show || $tagEditorState.type !== "local"}
>
<FileTagEditor
type="local"
show={$tagEditorState.show && $tagEditorState.type === "local"}
initialFileName={$tagEditorState.fileName}
onClose={() => tagEditorState.set({ show: false, type: "local", fileName: "" })}
/>
</div>
<div class="col-start-1 row-start-1 z-20" class:pointer-events-none={!showLocalMenu}>
<FileMenuOverlay type="local" show={showLocalMenu} onClose={() => (showLocalMenu = false)} />
</div>
</div>
</section>
<section class="buzzer-card relative">
<section class="buzzer-card relative self-start">
<div class="card-header">
<div>
<h3 class="card-title">
@@ -170,27 +203,56 @@
>
<ArrowClockwiseIcon class="icon" />
</button>
<button class="btn-main" aria-label="Menu" disabled={!$isConnected} title="Menu">
<DotsThreeVerticalIcon weight="bold" class="icon" />
<button
class="btn-main"
aria-label="Menu"
disabled={!$isConnected}
title="Menu"
on:click|stopPropagation={() => (showBuzzerMenu = !showBuzzerMenu)}
>
<DotsThreeVerticalIcon class="icon" />
</button>
</div>
</div>
</div>
<div class="grid">
<div class="col-start-1 row-start-1">
<div class="card-body" class:disconnected={!$isConnected}>
<FileList type="buzzer" />
</div>
</div>
<div class="col-start-1 row-start-1 z-20" class:pointer-events-none={!showBuzzerMenu}>
<FileMenuOverlay
type="buzzer"
show={showBuzzerMenu}
onClose={() => (showBuzzerMenu = false)}
/>
</div>
</div>
</section>
</div>
<style>
@reference "../styles/app.css";
.btn {
@apply rounded-full transition-colors border-0;
&:not(:disabled) {
@apply hover:bg-slate-200 hover:shadow-sm;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed;
}
}
.btn-connect-group {
@apply flex items-stretch rounded overflow-hidden border-transparent transition-all hover:border-slate-200 hover:shadow-sm;
@apply flex items-stretch rounded-full overflow-hidden border-transparent transition-all hover:border-slate-200 hover:shadow-sm;
}
.btn-main {
@apply border-r-1 border-r-transparent last:border-r-0;
@apply border-r-1 border-r-transparent last:border-r-0 focus:outline-none;
&:not(:disabled) {
@apply hover:border-border-card hover:shadow-sm hover:bg-slate-200 cursor-pointer group-hover:border-r-slate-200;

View File

@@ -7,12 +7,12 @@
$: console.debug("Aktuelle Toasts im Store:", $toasts);
</script>
<div class="fixed bottom-20 right-6 z-[9999] flex flex-col gap-3 pointer-events-none">
<div class="fixed bottom-6 right-6 z-[9999] flex flex-col gap-3 pointer-events-none">
{#each $toasts as toast (toast.id)}
<div
in:fly={{ y: 20, duration: 300 }}
out:fly={{ y: -20, duration: 300 }}
class="pointer-events-auto flex items-center justify-between px-5 py-3 rounded-lg border-l-4 shadow-xl min-w-[280px]
class="pointer-events-auto flex items-center justify-between px-5 py-3 rounded-lg border-l-4 shadow-xl min-w-[280px] backdrop-blur-sm
{toast.type === 'success' ? 'bg-green-100/50 border-green-500 text-green-800' : ''}
{toast.type === 'info' ? 'bg-blue-100/50 border-blue-500 text-blue-800' : ''}
{toast.type === 'warning' ? 'bg-amber-100/50 border-amber-500 text-amber-800' : ''}

View File

@@ -0,0 +1,51 @@
<script lang="ts">
let {
text = "",
position = 'top',
variant = 'default'
} = $props();
let x = $state(-999);
let y = $state(-999);
let visible = $state(false);
export function setPosition(newX: number, newY: number) {
x = newX;
y = newY;
visible = true;
}
export function hideTooltip() {
visible = false;
}
// Svelte 5 Reaktivität für dynamische Klassen basierend auf dem Typ
let containerClass = $derived(
variant === 'warning' ? 'bg-amber-700' :
variant === 'danger' ? 'bg-red-700' :
'bg-slate-700' // default
);
let arrowClass = $derived(
variant === 'warning' ? 'bg-amber-700' :
variant === 'danger' ? 'bg-red-700' :
'bg-slate-700' // default
);
</script>
<div
class="fixed z-[9999] pointer-events-none transition-opacity duration-200 {visible ? 'opacity-100' : 'opacity-0'}"
style="left: {x}px; top: {y}px;"
>
<div class="relative text-xs text-white px-2 py-1 rounded shadow-xl max-w-xs {containerClass}">
{@html text}
<div class="absolute w-2 h-2 rotate-45 {arrowClass}
{position === 'top' ? 'bottom-[-4px] left-1/2 -translate-x-1/2' : ''}
{position === 'bottom' ? 'top-[-4px] left-1/2 -translate-x-1/2' : ''}
{position === 'left' ? 'right-[-4px] top-1/2 -translate-y-1/2' : ''}
{position === 'right' ? 'left-[-4px] top-1/2 -translate-y-1/2' : ''}"
></div>
</div>
</div>

View File

@@ -9,7 +9,7 @@ import "../styles/app.css";
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Edis Buzzer</title>
</head>
<body class="bg-surface text-on-surface antialiased transition-colors duration-300">
<body class="bg-surface text-on-surface subpixel-antialiased transition-colors duration-300">
<slot />
</body>
</html>

View File

@@ -0,0 +1,99 @@
import Tooltip from "../../components/Tooltip.svelte";
import { mount, unmount } from "svelte";
// Typisierung um 'variant' erweitert
export interface TooltipOptions {
text: string;
pos?: "top" | "bottom" | "left" | "right";
variant?: "default" | "warning" | "danger";
}
export function tooltip(node: HTMLElement, options: TooltipOptions) {
let tooltipInstance: any = null;
let container: HTMLElement | null = null;
let isHovered = false;
const show = () => {
isHovered = true;
if (!options.text) return;
container = document.createElement('div');
document.body.appendChild(container);
tooltipInstance = mount(Tooltip, {
target: container,
props: {
text: options.text,
position: options.pos || "top",
variant: options.variant || "default" // Übergabe an die Komponente
}
});
requestAnimationFrame(() => {
if (!isHovered || !container || !tooltipInstance) return;
const tooltipEl = container.firstElementChild as HTMLElement;
if (!tooltipEl) return;
const nodeRect = node.getBoundingClientRect();
const tooltipRect = tooltipEl.getBoundingClientRect();
const gap = 8;
let x = 0, y = 0;
if (options.pos === "right") {
x = nodeRect.right + gap;
y = nodeRect.top + nodeRect.height / 2 - tooltipRect.height / 2;
} else if (options.pos === "left") {
x = nodeRect.left - tooltipRect.width - gap;
y = nodeRect.top + nodeRect.height / 2 - tooltipRect.height / 2;
} else if (options.pos === "bottom") {
x = nodeRect.left + nodeRect.width / 2 - tooltipRect.width / 2;
y = nodeRect.bottom + gap;
} else {
x = nodeRect.left + nodeRect.width / 2 - tooltipRect.width / 2;
y = nodeRect.top - tooltipRect.height - gap;
}
tooltipInstance.setPosition(x, y);
});
};
const hide = () => {
isHovered = false;
if (tooltipInstance) {
tooltipInstance.hideTooltip();
const instanceToRemove = tooltipInstance;
const containerToRemove = container;
tooltipInstance = null;
container = null;
setTimeout(() => {
try {
unmount(instanceToRemove);
if (containerToRemove) containerToRemove.remove();
} catch (e) {
console.warn("[Tooltip Action] Cleanup Fehler", e);
}
}, 200);
}
};
node.addEventListener("mouseenter", show);
node.addEventListener("mouseleave", hide);
node.addEventListener("mousedown", hide);
return {
update(newOptions: TooltipOptions) {
options = newOptions;
},
destroy() {
hide();
node.removeEventListener("mouseenter", show);
node.removeEventListener("mouseleave", hide);
node.removeEventListener("mousedown", hide);
}
};
}

View File

@@ -45,3 +45,16 @@ export async function getLocalFiles(): Promise<any[]> {
request.onerror = () => reject(request.error);
});
}
export async function deleteLocalFile(name: string): Promise<void> {
const db = await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.delete(name);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}

View File

@@ -225,3 +225,9 @@ export const selectedBuzzerFilesCount = derived(
buzzerAudioFiles,
($files) => $files.filter(f => f.selected).length
);
export const tagEditorState = writable<{show: boolean, type: "local" | "buzzer", fileName: string}>({
show: false,
type: "buzzer",
fileName: ""
});

View File

@@ -4,7 +4,9 @@ import { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport'
import type { BuzzerFile } from './types';
import { getFile } from './transport';
import { addToast } from './toast';
import { getLocalFiles } from './db';
import { getLocalFiles, deleteLocalFile } from './db';
import { parseLocalFileTags } from './tagHandler';
import { SETTINGS } from './settings';
function mapToBuzzerFile(rawFile: any): BuzzerFile {
return {
@@ -54,15 +56,19 @@ export async function refreshLocal() {
try {
const dbFiles = await getLocalFiles();
// Mappen auf die BuzzerFile-Struktur
const files: BuzzerFile[] = dbFiles.map(record => ({
// Paralleles Parsen aller Blobs in der lokalen Datenbank
const files: BuzzerFile[] = await Promise.all(dbFiles.map(async (record) => {
const { sysTags, metaTags } = await parseLocalFileTags(record.blob, record.name);
return {
name: record.name,
size: record.size,
type: 0, // 0 = File
tagsLoaded: false,
sysTags: { format: null, crc32: null },
metaTags: {},
tagsLoaded: true, // Marker, dass Tags erfolgreich extrahiert wurden
sysTags: sysTags,
metaTags: metaTags,
selected: false,
};
}));
localAudioFiles.set(files);
@@ -72,6 +78,7 @@ export async function refreshLocal() {
isFetchingLocal.set(false);
}
}
export async function downloadSelectedFiles() {
const files = get(buzzerAudioFiles).filter(f => f.selected);
const pathPrefix = get(fsInfo)?.audioPath || "/lfs/a";
@@ -100,7 +107,7 @@ for (const file of files) {
transferStats.update(s => ({ ...s, pendingFileName: file.name }));
const fullPath = `${pathPrefix}/${file.name}`;
await new Promise(r => setTimeout(r, 10));
await new Promise(r => setTimeout(r, SETTINGS.ui.transferUpdateIntervalMs)); // Kurze Verzögerung für UI-Update
await getFile(fullPath);
}
@@ -121,3 +128,20 @@ for (const file of files) {
isFetchingRemote.set(false);
}
}
export async function deleteSelectedLocalFiles() {
const files = get(localAudioFiles);
const selectedFiles = files.filter(f => f.selected);
if (selectedFiles.length === 0) return;
try {
for (const file of selectedFiles) {
await deleteLocalFile(file.name);
}
await refreshLocal();
} catch (error) {
console.error("Fehler beim Löschen lokaler Dateien:", error);
}
}

View File

@@ -0,0 +1,284 @@
import { getLocalFiles, saveLocalFile, deleteLocalFile } from './db';
import type { SystemTags, MetadataTags } from './types';
import { addToast } from './toast';
import { crc32 } from './protocol/crc32';
function getEmptyTags() {
return {
sysTags: { format: null, crc32: null } as SystemTags,
metaTags: {} as MetadataTags
};
}
export async function parseLocalFileTags(blob: Blob, filename: string): Promise<{ sysTags: SystemTags, metaTags: MetadataTags }> {
const MAGIC = "TAG!";
const VERSION = 1;
// Ein Footer ist exakt 8 Bytes groß
if (blob.size < 8) return getEmptyTags();
// 1. Footer auslesen (EOF - 8)
const footerBuffer = await blob.slice(-8).arrayBuffer();
const footerView = new DataView(footerBuffer);
// Magic-Signatur prüfen ("TAG!")
const magicDecoder = new TextDecoder('ascii');
const magicStr = magicDecoder.decode(new Uint8Array(footerBuffer, 4, 4));
if (magicStr !== MAGIC) {
return getEmptyTags(); // Datei hat keine angehängten Metadaten
}
// Version prüfen (Little-Endian)
const version = footerView.getUint16(2, true);
if (version !== VERSION) {
console.warn(`TagHandler: Nicht unterstützte Format-Version (${version}) in der Datei ${filename}.`);
addToast(`Warnung: Unbekanntes Metadaten-Format in der Datei <b>${filename}</b>. Es können keine Tags ausgelesen werden.`, "warning");
return getEmptyTags();
}
// Gesamtgröße der Metadaten ermitteln
const totalSize = footerView.getUint16(0, true);
if (totalSize < 8 || totalSize > blob.size) {
console.error(`TagHandler: total_size ist ungültig (korrupt) in der Datei ${filename}.`);
addToast(`Fehler: Ungültige Metadaten in der Datei <b>${filename}</b>. Es können keine Tags ausgelesen werden.`, "error");
return getEmptyTags();
}
// 2. TLV-Block isolieren (alles zwischen Audio-Daten und Footer)
const tlvSize = totalSize - 8;
if (tlvSize <= 0) return getEmptyTags(); // Keine Tags vorhanden, nur Footer
const metadataStart = blob.size - totalSize;
const tlvBuffer = await blob.slice(metadataStart, metadataStart + tlvSize).arrayBuffer();
const tlvView = new DataView(tlvBuffer);
const tags = getEmptyTags();
let offset = 0;
// 3. TLV-Blöcke iterieren
while (offset + 4 <= tlvSize) { // 4 Bytes für den Header (Type, Index, Length)
const type = tlvView.getUint8(offset);
const index = tlvView.getUint8(offset + 1);
const length = tlvView.getUint16(offset + 2, true);
offset += 4; // Zeiger hinter den Header verschieben
// Sicherheitsprüfung gegen Pufferüberläufe durch korrupte Längenangaben
if (offset + length > tlvSize) {
console.warn(`TagHandler: TLV-Block überschreitet Puffergröße in der Datei ${filename}. Abbruch.`);
addToast(`Warnung: Ungültige Metadatenstruktur in der Datei <b>${filename}</b>. Es können nur teilweise Tags ausgelesen werden.`, "warning");
break;
}
if (type === 0x00) {
// System Metadata
if (index === 0x00 && length === 8) {
// Audio Format
tags.sysTags.format = {
codec: tlvView.getUint8(offset),
bitDepth: tlvView.getUint8(offset + 1),
// Bytes an Offset 2 und 3 sind reserviert/Padding
sampleRate: tlvView.getUint32(offset + 4, true)
};
} else if (index === 0x01 && length === 4) {
// Audio CRC32
tags.sysTags.crc32 = tlvView.getUint32(offset, true);
}
} else if (type === 0x10) {
// JSON Metadata
try {
const jsonBytes = new Uint8Array(tlvBuffer, offset, length);
const jsonStr = new TextDecoder('utf-8').decode(jsonBytes);
const parsed = JSON.parse(jsonStr);
// Geparste Daten in das MetaTag-Interface mergen
tags.metaTags = { ...tags.metaTags, ...parsed };
} catch (e) {
console.error(`TagHandler: Fehler beim Parsen der JSON-Metadaten in der Datei ${filename}.`, e);
addToast(`Fehler: Ungültige JSON-Metadaten in der Datei <b>${filename}</b>. Es können keine Tags ausgelesen werden.`, "error");
}
}
offset += length; // Zum nächsten TLV-Block springen
}
return tags;
}
// Baut den binären TLV-Block gemäß Spezifikation
function buildTagBlock(sysTags: SystemTags, metaTags: MetadataTags): ArrayBuffer {
const jsonStr = JSON.stringify(metaTags);
const jsonBytes = new TextEncoder().encode(jsonStr);
// Größen berechnen
const tlvAudioFormatSize = sysTags.format ? 4 + 8 : 0; // Header(4) + Payload(8)
const tlvCrcSize = sysTags.crc32 ? 4 + 4 : 0; // Header(4) + Payload(4)
const tlvJsonSize = 4 + jsonBytes.length; // Header(4) + Payload(variabel)
const totalSize = tlvAudioFormatSize + tlvCrcSize + tlvJsonSize + 8; // + 8 für Footer
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const uint8View = new Uint8Array(buffer);
let offset = 0;
// 1. Audio Format TLV (Type 0x00, Index 0x00)
if (sysTags.format) {
view.setUint8(offset, 0x00);
view.setUint8(offset + 1, 0x00);
view.setUint16(offset + 2, 8, true);
view.setUint8(offset + 4, sysTags.format.codec);
view.setUint8(offset + 5, sysTags.format.bitDepth);
// Bytes an Offset 6 und 7 bleiben 0 (Reserved)
view.setUint32(offset + 8, sysTags.format.sampleRate, true);
offset += 12;
}
// 2. CRC32 TLV (Type 0x00, Index 0x01)
if (sysTags.crc32) {
view.setUint8(offset, 0x00);
view.setUint8(offset + 1, 0x01);
view.setUint16(offset + 2, 4, true);
view.setUint32(offset + 4, sysTags.crc32, true);
offset += 8;
}
// 3. JSON Metadata TLV (Type 0x10, Index 0x00)
view.setUint8(offset, 0x10);
view.setUint8(offset + 1, 0x00);
view.setUint16(offset + 2, jsonBytes.length, true);
uint8View.set(jsonBytes, offset + 4);
offset += 4 + jsonBytes.length;
console.log(`TagHandler: JSON-Metadaten mit ${jsonBytes.length} Bytes hinzugefügt.`, { jsonStr });
// 4. Footer
view.setUint16(offset, totalSize, true); // Gesamtgröße
view.setUint16(offset + 2, 1, true); // Version 1
// Magic "TAG!"
view.setUint8(offset + 4, 0x54); // T
view.setUint8(offset + 5, 0x41); // A
view.setUint8(offset + 6, 0x47); // G
view.setUint8(offset + 7, 0x21); // !
return buffer;
}
// Hauptfunktion für das lokale Aktualisieren und Umbenennen
export async function updateLocalFile(oldName: string, newName: string, sysTags: SystemTags, newMetaTags: MetadataTags): Promise<void> {
const files = await getLocalFiles();
const record = files.find(f => f.name === oldName);
if (!record) throw new Error(`Datei ${oldName} nicht in der lokalen Datenbank gefunden.`);
const blob = record.blob;
let audioBlob = blob;
// Alte Metadaten abschneiden, falls vorhanden
if (blob.size >= 8) {
const footerBuf = await blob.slice(-8).arrayBuffer();
const footerView = new DataView(footerBuf);
const magicStr = new TextDecoder('ascii').decode(new Uint8Array(footerBuf, 4, 4));
if (magicStr === "TAG!") {
const totalSize = footerView.getUint16(0, true);
if (totalSize >= 8 && totalSize <= blob.size) {
audioBlob = blob.slice(0, blob.size - totalSize);
}
}
}
// Neue Tags generieren und an die Rohdaten anhängen
const newTagsBuffer = buildTagBlock(sysTags, newMetaTags);
const finalBlob = new Blob([audioBlob, newTagsBuffer]);
// In die Datenbank schreiben
if (oldName !== newName) {
await deleteLocalFile(oldName);
}
await saveLocalFile(newName, finalBlob, finalBlob.size);
}
export async function updateFile(
oldName: string,
newName: string,
sysTags: SystemTags,
newMetaTags: MetadataTags,
type: "local" | "buzzer"
): Promise<void> {
if (type === "local") {
await updateLocalFile(oldName, newName, sysTags, newMetaTags);
} else {
// TODO: Später hier die BLE-Kommandos (FILE_PUT oder spezifisches Tag-Update) einbauen
throw new Error("Das Speichern von Tags auf dem Buzzer ist über das Protokoll noch nicht implementiert.");
}
}
/**
* FASADE: Einheitlicher Einstiegspunkt zum Auslesen von Tags.
*/
export async function fetchFileTags(
filename: string,
type: "local" | "buzzer"
): Promise<{ sysTags: SystemTags, metaTags: MetadataTags }> {
if (type === "local") {
const files = await getLocalFiles();
const record = files.find(f => f.name === filename);
if (!record) throw new Error(`Datei ${filename} lokal nicht gefunden.`);
return await parseLocalFileTags(record.blob, filename);
} else {
// TODO: Später hier den BLE-Abruf der letzten Bytes (Footer + TLV) implementieren
// Solange das nicht geht, geben wir ein leeres Objekt zurück
return getEmptyTags();
}
}
export async function calculateLocalAudioCrc(filename: string): Promise<number> {
const files = await getLocalFiles();
const record = files.find(f => f.name === filename);
if (!record) throw new Error(`Datei ${filename} nicht gefunden.`);
const blob: Blob = record.blob;
let audioSize = blob.size;
// 1. Prüfen, ob Metadaten vorhanden sind, um nur den Audioanteil zu berechnen
if (blob.size >= 8) {
const footerBuffer = await blob.slice(-8).arrayBuffer();
const footerView = new DataView(footerBuffer);
const magic = new TextDecoder('ascii').decode(new Uint8Array(footerBuffer, 4, 4));
if (magic === "TAG!") {
const totalSize = footerView.getUint16(0, true);
if (totalSize >= 8 && totalSize <= blob.size) {
audioSize = blob.size - totalSize; // Nur bis zum Beginn der Tags lesen
}
}
}
// 2. Audio-Daten einlesen
const audioData = await blob.slice(0, audioSize).arrayBuffer();
// 3. CRC32 berechnen
return crc32(new Uint8Array(audioData));
}
export async function updateLocalAudioCrc(filename: string): Promise<number> {
const files = await getLocalFiles();
const record = files.find(f => f.name === filename);
if (!record) throw new Error(`Datei ${filename} nicht gefunden.`);
// Tags auslesen
const { sysTags, metaTags } = await parseLocalFileTags(record.blob, filename);
// Neue CRC berechnen
const newCrc = await calculateLocalAudioCrc(filename);
if (newCrc === sysTags.crc32) {
console.log(`TagHandler: CRC32 für ${filename} ist bereits aktuell (${newCrc}). Kein Update nötig.`);
return newCrc; // Keine Änderung, daher kein Update
}
console.log(`TagHandler: Aktualisiere CRC32 für ${filename} von ${sysTags.crc32} auf ${newCrc}.`);
// Mit aktualisierter CRC speichern
const updatedSysTags = { ...sysTags, crc32: newCrc };
await updateLocalFile(filename, filename, updatedSysTags, metaTags);
return newCrc;
}

View File

@@ -43,7 +43,7 @@
}
.buzzer-card {
@apply bg-surface-card border-border-card transition-all duration-300;
@apply bg-surface-card border-border-card transition-all duration-300 first:mt-0 mt-5 sm:mt-0 border-t first:border-t-0 sm:border-t-0 border-border-card;
@apply border-b lg:border lg:rounded-xl lg:shadow-sm overflow-hidden;
}

View File

@@ -1,8 +0,0 @@
@import "tailwindcss";
@theme {
--color-primary: var(--color-slate-700);
--color-surface: var(--color-slate-50);
--shadow-top: 0 -2px 3px -1px color-mix(in srgb, var(--color-slate-500), transparent 70%);
--shadow-bottom: 0 2px 3px -1px color-mix(in srgb, var(--color-slate-500), transparent 70%);
}