Lokales Tag-Handling implementiert
This commit is contained in:
122
Tags.md
Normal file
122
Tags.md
Normal 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!`)
|
||||
@@ -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)}
|
||||
<FileListItem {file} {type} />
|
||||
<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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
{#if state === 'active'}
|
||||
<div
|
||||
<svelte:window on:click={() => (menuOpen = false)} />
|
||||
|
||||
<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
|
||||
{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 ? 'cursor-default' : ''}
|
||||
{state === 'pending' ? 'grayscale opacity-80' : ''}"
|
||||
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 ? '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">
|
||||
{file.name || "Unbekannte Datei"}
|
||||
{#if file.metaTags?.t}
|
||||
 - 
|
||||
<span class="font-medium">{file.metaTags.t}</span>
|
||||
<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}
|
||||
 - 
|
||||
<span class="font-normal">{file.metaTags.t}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</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>
|
||||
{: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>
|
||||
<div class="text-xs">
|
||||
<span class="font-light text-text-muted text-xs">
|
||||
{parseFloat((file.size/1024).toFixed(1))} kB
|
||||
</span>
|
||||
<span>
|
||||
{#if file.metaTags?.a}<span class="text-text-muted"> | Author:</span> {file.metaTags.a}{/if}
|
||||
|
||||
<span class="font-light shrink-0">
|
||||
{parseFloat((file.size / 1024).toFixed(1))} 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>
|
||||
</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>
|
||||
|
||||
187
webpage/src/components/FileMenuOverlay.svelte
Normal file
187
webpage/src/components/FileMenuOverlay.svelte
Normal 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>
|
||||
442
webpage/src/components/FileTagEditor.svelte
Normal file
442
webpage/src/components/FileTagEditor.svelte
Normal 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>
|
||||
@@ -11,16 +11,28 @@
|
||||
CheckSquareIcon,
|
||||
} from "phosphor-svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import {
|
||||
isConnected, isConnecting, availableDevices, pairedDevices,
|
||||
activeDeviceId, autoConnect, loadConnectionState, fsInfo
|
||||
import {
|
||||
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 Buzzer</span>
|
||||
|
||||
<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">
|
||||
@@ -163,28 +199,32 @@
|
||||
<style>
|
||||
@reference "../styles/app.css";
|
||||
|
||||
.connected {
|
||||
.connected {
|
||||
@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 {
|
||||
@@ -202,4 +242,4 @@
|
||||
.menu-auto {
|
||||
@apply hover:bg-surface-hover font-semibold border-b last:border-b-0 border-border-card cursor-pointer;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
@@ -9,9 +8,11 @@
|
||||
export let isConnected: boolean = false;
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -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="card-body">
|
||||
<FileList type="local" />
|
||||
<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="card-body" class:disconnected={!$isConnected}>
|
||||
<FileList type="buzzer" />
|
||||
<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;
|
||||
|
||||
@@ -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' : ''}
|
||||
|
||||
51
webpage/src/components/Tooltip.svelte
Normal file
51
webpage/src/components/Tooltip.svelte
Normal 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>
|
||||
@@ -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>
|
||||
99
webpage/src/lib/actions/tooltip.ts
Normal file
99
webpage/src/lib/actions/tooltip.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -44,4 +44,17 @@ export async function getLocalFiles(): Promise<any[]> {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -224,4 +224,10 @@ export const buzzerFilesCount = derived(
|
||||
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: ""
|
||||
});
|
||||
@@ -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 => ({
|
||||
name: record.name,
|
||||
size: record.size,
|
||||
type: 0, // 0 = File
|
||||
tagsLoaded: false,
|
||||
sysTags: { format: null, crc32: null },
|
||||
metaTags: {},
|
||||
selected: false,
|
||||
// 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: 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);
|
||||
}
|
||||
|
||||
@@ -120,4 +127,21 @@ for (const file of files) {
|
||||
// Das console.log für den Live-Speed wurde entfernt, da es hier falsche Werte liefert
|
||||
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);
|
||||
}
|
||||
}
|
||||
284
webpage/src/lib/tagHandler.ts
Normal file
284
webpage/src/lib/tagHandler.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
Reference in New Issue
Block a user