zwischenstand

This commit is contained in:
2026-03-18 15:05:45 +01:00
parent 7c7f19a4b7
commit ff63dda086
29 changed files with 626 additions and 269 deletions

View File

@@ -1,12 +1,15 @@
// @ts-check
import { defineConfig } from 'astro/config';
import svelte from '@astrojs/svelte';
import tailwindcss from '@tailwindcss/vite';
const isProd = process.env.NODE_ENV === 'production';
// https://astro.build/config
export default defineConfig({
base: isProd ? '/buzzer/' : '/',
site: 'https://home.iten.pro',
integrations: [svelte()],
vite: {

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,9 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><style>
#light-icon {
display: inline;
}
#dark-icon {
display: none;
}
@media (prefers-color-scheme: dark) {
#light-icon {
display: none;
}
#dark-icon {
display: inline;
}
}
</style><g id="light-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><g><g transform="matrix(31.25,0,0,31.25,0,0)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="32" height="32" fill="#000000" viewBox="0 0 256 256" id="svg1" sodipodi:docname="siren.svg" inkscape:version="1.4 (86a8ad7, 2024-10-11)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"><defs id="defs1"></defs><sodipodi:namedview id="namedview1" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="36.46875" inkscape:cx="16" inkscape:cy="16" inkscape:window-width="3440" inkscape:window-height="1369" inkscape:window-x="1672" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="svg1"></sodipodi:namedview><path d="m 232,176 v 24 a 16,16 0 0 1 -16,16 H 40 A 16,16 0 0 1 24,200 V 176 A 16,16 0 0 1 40,160 V 128 A 88,88 0 0 1 128.67,40 C 176.82,40.36 216,80.29 216,129 v 31 a 16,16 0 0 1 16,16 z" id="path5"></path><path d="M 216,200 V 176 H 40 v 24 z" id="path7" style="fill:#b3b3b3"></path><path d="M 56,160 H 200 V 129 C 200,89 167.95,56.29 128.55,56 H 128 a 72,72 0 0 0 -72,72 z" id="path6" style="fill:#ff0000"></path><path d="M 137.34,72.11 A 8,8 0 1 0 134.7,87.89 C 153.67,91.08 168,108.32 168,128 a 8,8 0 0 0 16,0 c 0,-27.4 -20.07,-51.43 -46.68,-55.89 z" id="path4"></path><path d="M 50.34,45.66 A 8.0044488,8.0044488 0 0 0 61.66,34.34 l -8,-8 A 8.0044488,8.0044488 0 0 0 42.34,37.66 Z" id="path3"></path><path d="m 200,48 a 8,8 0 0 0 5.66,-2.34 l 8,-8 A 8.0044488,8.0044488 0 0 0 202.34,26.34 l -8,8 A 8,8 0 0 0 200,48 Z" id="path2"></path><path d="M 120,16 V 8 a 8,8 0 0 1 16,0 v 8 a 8,8 0 0 1 -16,0 z" id="path1"></path></svg></g></g></svg></g><g id="dark-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><g><g transform="matrix(31.25,0,0,31.25,0,0)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="32" height="32" fill="#000000" viewBox="0 0 256 256" id="svg1" sodipodi:docname="siren_dark.svg" inkscape:version="1.4 (86a8ad7, 2024-10-11)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"><defs id="defs1"></defs><sodipodi:namedview id="namedview1" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="36.46875" inkscape:cx="16" inkscape:cy="16" inkscape:window-width="3440" inkscape:window-height="1369" inkscape:window-x="1672" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="svg1"></sodipodi:namedview><path d="m 232,176 v 24 a 16,16 0 0 1 -16,16 H 40 A 16,16 0 0 1 24,200 V 176 A 16,16 0 0 1 40,160 V 128 A 88,88 0 0 1 128.67,40 C 176.82,40.36 216,80.29 216,129 v 31 a 16,16 0 0 1 16,16 z" id="path5" style="fill:#ffffff"></path><path d="M 216,200 V 176 H 40 v 24 z" id="path7" style="fill:#b3b3b3"></path><path d="M 56,160 H 200 V 129 C 200,89 167.95,56.29 128.55,56 H 128 a 72,72 0 0 0 -72,72 z" id="path6" style="fill:#ff0000"></path><path d="M 137.34,72.11 A 8,8 0 1 0 134.7,87.89 C 153.67,91.08 168,108.32 168,128 a 8,8 0 0 0 16,0 c 0,-27.4 -20.07,-51.43 -46.68,-55.89 z" id="path4" style="fill:#ffffff"></path><path d="M 50.34,45.66 A 8.0044488,8.0044488 0 0 0 61.66,34.34 l -8,-8 A 8.0044488,8.0044488 0 0 0 42.34,37.66 Z" id="path3" style="fill:#ffffff"></path><path d="m 200,48 a 8,8 0 0 0 5.66,-2.34 l 8,-8 A 8.0044488,8.0044488 0 0 0 202.34,26.34 l -8,8 A 8,8 0 0 0 200,48 Z" id="path2" style="fill:#ffffff"></path><path d="M 120,16 V 8 a 8,8 0 0 1 16,0 v 8 a 8,8 0 0 1 -16,0 z" id="path1" style="fill:#ffffff"></path></svg></g></g></svg></g></svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "Edis Buzzer",
"short_name": "Buzzer",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -6,7 +6,7 @@
onMount(async () => {
performHardwareCheck();
if ($isBluetoothSupported) {
const { restoreSession } = await import("../lib/bluetooth");
await restoreSession();
@@ -22,9 +22,11 @@
</div>
{:else if !$isBluetoothSupported}
<div
class="fixed lg:h-screen inset-0 flex flex-col items-center justify-center p-0 lg:p-4 z-[100] bg-white lg:bg-transparent" style="hyphens:auto;">
class="fixed lg:h-screen inset-0 flex flex-col items-center justify-center p-0 lg:p-4 z-[100] bg-white lg:bg-transparent"
style="hyphens:auto;"
>
<div
class="w-full h-full lg:h-auto lg:max-w-md bg-red-50 lg:border border-red-600 lg:shadow-xl lg:rounded-lg p-6 lg:p-8 text-red-600 flex flex-col justify-center"
class="w-full h-full lg:h-auto lg:max-w-md bg-red-50 shadow-red-500/30 lg:border border-red-600 lg:shadow-xl lg:rounded-lg p-6 lg:p-8 text-red-600 flex flex-col justify-center"
>
<h1 class="text-2xl font-bold mb-2 text-center">Dein Browser ist... suboptimal</h1>
<div class="text-center text-7xl md:text-9xl font-bold mb-4">🥺</div>
@@ -34,10 +36,12 @@
Leider unterstützt dein Browser die benötigten Bluetooth-Funktionen nicht. Bitte versuche
es mit einem aktuellen <span class="font-semibold">Chrome</span>
oder einem andern Chromium-basierten Browser.
<span class="font-semibold">Winzigweich Kante</span> soll gerüchteweise auch Chromium-basiert sein...
<span class="font-semibold">Winzigweich Kante</span>
soll gerüchteweise auch Chromium-basiert sein...
</p>
<p>
Rundreise auf iOS unterstützt Bluetooth leider nicht, aber du kannst es mit einem vernünftigen Gerät oder Browser versuchen.
Rundreise auf iOS unterstützt Bluetooth leider nicht, aber du kannst es mit einem
vernünftigen Gerät oder Browser versuchen.
</p>
</div>
</div>

View File

@@ -15,17 +15,22 @@
WarningCircleIcon,
} from "phosphor-svelte";
import {
isFetchingRemote,
isTransferingRemote,
transferStats,
transferDetails,
buzzerAudioFiles,
localAudioFiles,
syncStateMap,
fsInfo,
} from "../lib/store";
import { SETTINGS } from "../lib/settings";
import { tagEditorState } from "../lib/store";
import { tooltip } from "../lib/actions/tooltip";
import { deleteRemoteFile } from "../lib/transport";
import { deleteLocalFile } from "../lib/db";
import { refreshRemote, refreshLocal } from "../lib/sync";
import { addToast } from "../lib/toast";
export let file: BuzzerFile;
export let type: "local" | "buzzer" = "buzzer";
@@ -37,7 +42,7 @@
$: myIndex = selectedFiles.findIndex((f) => f.name === file.name);
$: state = (() => {
if (!file.selected || !$isFetchingRemote) return "default";
if (!file.selected || !$isTransferingRemote) return "default";
if (file.name === $transferStats.currentFileName) return "active";
if (myIndex < currentIndex) return "done";
if (myIndex > currentIndex) return "pending";
@@ -93,7 +98,7 @@
})();
function toggleSelection() {
if ($isFetchingRemote) return;
if ($isTransferingRemote) return;
if (type === "buzzer") {
buzzerAudioFiles.update((files) =>
@@ -110,6 +115,35 @@
),
);
}
async function handleDeleteClick() {
if (!confirm(`Möchten Sie die Datei "${file.name}" wirklich löschen?`)) {
menuOpen = false;
return;
}
if (type === "buzzer") {
try {
const basePath = $fsInfo?.audioPath || "/lfs/a";
const fullPath = `${basePath}/${file.name}`;
await deleteRemoteFile(fullPath);
addToast(`Datei ${file.name} erfolgreich vom Buzzer gelöscht.`, "success");
await refreshRemote();
} catch (error) {
console.error("Fehler beim Löschen:", error);
addToast("Fehler beim Löschen der Datei auf dem Buzzer.", "error");
}
} else {
try {
await deleteLocalFile(file.name);
addToast(`Lokale Datei ${file.name} gelöscht.`, "success");
await refreshLocal();
} catch (error) {
console.error("Fehler beim Löschen:", error);
}
}
menuOpen = false;
}
</script>
<svelte:window on:click={() => (menuOpen = false)} />
@@ -128,14 +162,14 @@
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
{!$isTransferingRemote && file.selected ? 'hover:bg-blue-100 cursor-pointer' : ''}
{!$isTransferingRemote && !file.selected
? 'hover:bg-slate-100 hover:border-l-blue-200 cursor-pointer'
: ''}
{$isFetchingRemote ? 'cursor-default' : ''}
{$isTransferingRemote ? 'cursor-default' : ''}
{state === 'pending' ? 'grayscale opacity-80' : ''}"
on:click={toggleSelection}
disabled={$isFetchingRemote}
disabled={$isTransferingRemote}
>
<MusicNotesIcon weight="fill" class="mr-3 w-5 h-5 shrink-0" />
@@ -198,10 +232,7 @@
<button
class="menu-btn danger"
title="Löschen"
on:click|stopPropagation={() => {
console.log("Delete", file.name);
menuOpen = false;
}}
on:click|stopPropagation={handleDeleteClick}
>
<TrashIcon class="list-menu-icon" />
</button>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fade, slide } from "svelte/transition";
import { localAudioFiles, buzzerAudioFiles } from "../lib/store";
import { deleteSelectedLocalFiles } from "../lib/sync";
import { deleteSelectedLocalFiles, deleteSelectedRemoteFiles } from "../lib/sync";
import { addToast } from "../lib/toast";
import { tooltip } from "../lib/actions/tooltip";
import { updateLocalAudioCrc } from "../lib/tagHandler";
@@ -143,12 +143,11 @@
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();
if (type === "buzzer") {
deleteSelectedRemoteFiles();
} else {
deleteSelectedLocalFiles();
}
closeMenu();
}}
>

View File

@@ -2,8 +2,13 @@
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 {
localAudioFiles,
buzzerAudioFiles,
fsInfo,
syncStateMap,
} from "../lib/store";
import { type MetadataTags, SyncState } from "../lib/types";
import {
XIcon,
CaretLeftIcon,
@@ -15,6 +20,7 @@
PencilIcon,
} from "phosphor-svelte";
import { addToast } from "../lib/toast";
import { tooltip } from "../lib/actions/tooltip";
export let show = false;
export let type: "local" | "buzzer" = "buzzer";
@@ -29,7 +35,7 @@
$: autoApplyIcon = applyToBoth ? CheckSquareIcon : SquareIcon;
$: activeStore = type === "local" ? localAudioFiles : buzzerAudioFiles;
$: fileList = $activeStore;
$: fileList = $activeStore || [];
$: if (show && initialFileName !== lastOpenedName) {
if (initialFileName) {
@@ -44,7 +50,7 @@
lastOpenedName = null;
}
$: currentIndex = fileList.findIndex((f) => f.name === currentFileName);
$: currentIndex = (fileList || []).findIndex((f) => f.name === currentFileName);
$: currentFile = fileList[currentIndex];
$: hasDraft = currentFile ? drafts[currentFile.name] !== undefined : false;
$: hasAnyDrafts = Object.keys(drafts).length > 0;
@@ -53,6 +59,13 @@
$: activeTags = activeDraft ? activeDraft.tags : currentFile?.metaTags || {};
$: activeName = activeDraft ? activeDraft.newName : currentFile?.name || "";
$: syncStatus = (currentFileName && $syncStateMap[type]?.[currentFileName]) || { state: SyncState.UNKNOWN, linkedFiles: [] };
$: isDuplicate = syncStatus.state === SyncState.DUPLICATE;
$: if (isDuplicate) {
applyToBoth = false;
}
$: maxFilenameLength = $fsInfo ? $fsInfo.maxPathLength - $fsInfo.audioPath.length - 2 : 30;
function closeEditor() {
@@ -120,13 +133,21 @@
const newName = draft.newName;
try {
await updateFile(oldName, newName, currentFile.sysTags, draft.tags, type);
await updateFile(oldName, newName, currentFile.sysTags, draft.tags, type, applyToBoth);
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();
if (applyToBoth) {
// Wenn auf beide angewendet, beide Seiten neu laden
await refreshLocal();
await refreshRemote();
} else {
// Ansonsten nur die aktive Seite
if (type === "local") await refreshLocal();
if (type === "buzzer") await refreshRemote();
}
} catch (error) {
addToast("Fehler beim Speichern.", "error");
}
@@ -138,14 +159,20 @@
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);
await updateFile(oldName, draft.newName, file.sysTags, draft.tags, type, applyToBoth);
savedCount++;
}
}
drafts = {};
addToast(`${savedCount} Dateien gespeichert.`, "success");
if (type === "local") await refreshLocal();
if (type === "buzzer") await refreshRemote();
if (applyToBoth) {
await refreshLocal();
await refreshRemote();
} else {
if (type === "local") await refreshLocal();
if (type === "buzzer") await refreshRemote();
}
} catch (error) {
addToast("Fehler beim Speichern.", "error");
}
@@ -398,15 +425,27 @@
<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)}
<div
use:tooltip={{
text: "Diese Option ist deaktiviert, da ein Duplikat-Konflikt vorliegt. Lösen Sie den Konflikt, um Änderungen auf beiden Seiten anwenden zu können.",
pos: "top",
variant: "danger",
disabled: !isDuplicate,
}}
class:grayscale={isDuplicate}
class:cursor-not-allowed={isDuplicate}
>
<svelte:component
this={autoApplyIcon}
class="btn-icon {applyToBoth ? 'text-blue-600' : 'text-slate-400'}"
/> Auch {type === "buzzer" ? "lokal" : "auf dem Buzzer"} anwenden
</button>
<button
class="menu-btn bg-slate-50 hover:bg-slate-100 !justify-start text-slate-700 w-full"
on:click={() => (applyToBoth = !applyToBoth)}
disabled={isDuplicate}
>
<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>
</div>
{/if}

View File

@@ -4,7 +4,7 @@
import FileList from "./FileList.svelte";
import DeviceInfo from "./DeviceInfo.svelte";
import { refreshRemote } from "../lib/sync";
import { transferStats, isFetchingRemote, pairedDevices, activeDeviceId } from "../lib/store";
import { transferStats, isTransferingRemote, pairedDevices, activeDeviceId } from "../lib/store";
import { SETTINGS } from "../lib/settings";
import TransferProgress from "./TransferProgress.svelte";
import FileMenuOverlay from "./FileMenuOverlay.svelte";
@@ -25,12 +25,12 @@
refreshRemote();
}
$: if ($isFetchingRemote && $transferStats.overallTotal > 0) {
$: if ($isTransferingRemote && $transferStats.overallTotal > 0) {
// Transfer startet oder läuft
showOverlay = true;
isTransferFinished = false;
clearTimeout(overlayTimeout);
} else if (showOverlay && !$isFetchingRemote && $transferStats.overallDone > 0) {
} else if (showOverlay && !$isTransferingRemote && $transferStats.overallDone > 0) {
// Transfer wurde soeben abgeschlossen
isTransferFinished = true;
overlayTimeout = setTimeout(closeOverlay, SETTINGS.ui.transferOverlayPersistMs);

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { XIcon } from "phosphor-svelte";
import { isFetchingRemote, transferStats, transferDetails } from '../lib/store';
import { isTransferingRemote, transferStats, transferDetails } from '../lib/store';
import { SETTINGS } from '../lib/settings';
let showOverlay = false;
let isTransferFinished = false;
let overlayTimeout: ReturnType<typeof setTimeout>;
$: if ($isFetchingRemote && $transferStats.overallTotal > 0) {
$: if ($isTransferingRemote && $transferStats.overallTotal > 0) {
showOverlay = true;
isTransferFinished = false;
clearTimeout(overlayTimeout);
} else if (showOverlay && !$isFetchingRemote && $transferStats.overallDone > 0) {
} else if (showOverlay && !$isTransferingRemote && $transferStats.overallDone > 0) {
isTransferFinished = true;
overlayTimeout = setTimeout(closeOverlay, SETTINGS.ui.transferOverlayPersistMs);
}

View File

@@ -1,15 +1,30 @@
<!-- MainLayout.astro -->
---
import "../styles/app.css";
---
<!-- MainLayout.astro -->
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="icon"
type="image/png"
href={`${import.meta.env.BASE_URL}favicon-96x96.png`}
sizes="96x96"
/>
<link rel="icon" type="image/svg+xml" href={`${import.meta.env.BASE_URL}favicon.svg`} />
<link rel="shortcut icon" href={`${import.meta.env.BASE_URL}favicon.ico`} />
<link
rel="apple-touch-icon"
sizes="180x180"
href={`${import.meta.env.BASE_URL}apple-touch-icon.png`}
/>
<meta name="apple-mobile-web-app-title" content="Edis Buzzer" />
<link rel="manifest" href={`${import.meta.env.BASE_URL}site.webmanifest`} />
<title>Edis Buzzer</title>
</head>
<body class="bg-surface text-on-surface subpixel-antialiased transition-colors duration-300">
<body class="bg-surface text-on-surface antialiased transition-colors duration-300">
<slot />
</body>
</html>
</html>

View File

@@ -3,12 +3,13 @@ export const SETTINGS = {
connectionKey: 'buzzer_connection_state'
},
bluetooth: {
connectionTimeoutMs: 10000 // Timeout für den Verbindungsaufbau
connectionTimeoutMs: 3000, // Timeout für den Verbindungsaufbau
appleMaxInflight: 15, // iOS erlaubt nur wenige unbestätigte Nachrichten, daher begrenzen wir die Anzahl der gleichzeitig gesendeten Frames
},
ui: {
toastDurationMs: 5000,
transferUpdateIntervalMs: 300,
kbpsCalculationWindowMs: 5500,
transferUpdateIntervalMs: 1000,
kbpsCalculationWindowMs: 1000,
transferOverlayPersistMs: 4000,
}
},
};

View File

@@ -75,7 +75,7 @@ export const buzzerSysFiles = writable<BuzzerFile[]>([]);
export const localAudioFiles = writable<BuzzerFile[]>([]);
// Ladezustände getrennt nach Quelle
export const isFetchingRemote = writable<boolean>(false);
export const isTransferingRemote = writable<boolean>(false);
export const isFetchingLocal = writable<boolean>(false);
// Persistenz des letzten Verbindungsziels (nur im Browser nutzbar)
@@ -189,7 +189,7 @@ export function resetRemote(): void {
activeDeviceId.set(null);
buzzerAudioFiles.set([]);
buzzerSysFiles.set([]);
isFetchingRemote.set(false);
isTransferingRemote.set(false);
resetTransferStats();
}

View File

@@ -1,8 +1,8 @@
import { get } from 'svelte/store';
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isFetchingRemote, isFetchingLocal, storageUsage, localAudioFiles, transferStats } from './store';
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isTransferingRemote, isFetchingLocal, storageUsage, localAudioFiles, transferStats } from './store';
import { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport';
import type { BuzzerFile } from './types';
import { getFile, putFile } from './transport';
import { getFile, putFile, deleteRemoteFile } from './transport';
import { addToast } from './toast';
import { getLocalFiles, deleteLocalFile, getLocalFile } from './db';
import { parseAudioFileTags } from './tagHandler';
@@ -24,7 +24,7 @@ function mapToBuzzerFile(rawFile: any): BuzzerFile {
export async function refreshRemote() {
if (!get(isConnected)) return;
isFetchingRemote.set(true);
isTransferingRemote.set(true);
try {
await requestProtocolInfo();
await requestFSInfo();
@@ -64,7 +64,7 @@ export async function refreshRemote() {
console.error("Fehler beim Aktualisieren der Buzzer-Daten:", error);
addToast("Fehler beim Laden der Daten vom Buzzer: " + (error instanceof Error ? error.message : "Unbekannter Fehler"), "error");
} finally {
isFetchingRemote.set(false);
isTransferingRemote.set(false);
}
}
@@ -116,7 +116,7 @@ export async function downloadSelectedFiles() {
bulkStartTime: bulkStart
}));
isFetchingRemote.set(true);
isTransferingRemote.set(true);
try {
for (const file of files) {
@@ -142,7 +142,7 @@ export async function downloadSelectedFiles() {
...s,
overallDone: s.overallTotal,
}));
isFetchingRemote.set(false);
isTransferingRemote.set(false);
}
}
@@ -152,6 +152,10 @@ export async function deleteSelectedLocalFiles() {
if (selectedFiles.length === 0) return;
if (!confirm(`Möchten Sie wirklich ${selectedFiles.length} lokale Datei(en) löschen?`)) {
return;
}
try {
for (const file of selectedFiles) {
await deleteLocalFile(file.name);
@@ -163,6 +167,33 @@ export async function deleteSelectedLocalFiles() {
}
}
export async function deleteSelectedRemoteFiles() {
const files = get(buzzerAudioFiles).filter(f => f.selected);
const pathPrefix = get(fsInfo)?.audioPath || "/lfs/a";
if (files.length === 0) return;
if (!confirm(`Möchten Sie wirklich ${files.length} Datei(en) auf dem Buzzer löschen?`)) {
return;
}
isTransferingRemote.set(true);
try {
for (const file of files) {
const fullPath = `${pathPrefix}/${file.name}`;
console.debug(`Lösche Datei auf dem Buzzer: ${fullPath}`);
await deleteRemoteFile(fullPath);
}
addToast(`${files.length} Datei(en) auf dem Buzzer gelöscht.`, "success");
await refreshRemote();
} catch (error) {
console.error("Fehler beim Löschen auf dem Buzzer:", error);
addToast("Fehler beim Löschen: " + (error instanceof Error ? error.message : "Unbekannt"), "error");
} finally {
isTransferingRemote.set(false);
}
}
export async function uploadSelectedFiles() {
const files = get(localAudioFiles).filter(f => f.selected);
const pathPrefix = get(fsInfo)?.audioPath || "/lfs/a";
@@ -183,7 +214,7 @@ export async function uploadSelectedFiles() {
}));
// Wir nutzen isFetchingRemote als generischen "Transfer aktiv"-Trigger für das UI TODO: Namensänderung in isTransferring?
isFetchingRemote.set(true);
isTransferingRemote.set(true);
try {
for (const file of files) {
@@ -216,6 +247,6 @@ export async function uploadSelectedFiles() {
...s,
overallDone: s.overallTotal, // Schließt den Ladebalken visuell sauber ab
}));
isFetchingRemote.set(false);
isTransferingRemote.set(false);
}
}

View File

@@ -2,9 +2,9 @@ import { getLocalFiles, saveLocalFile, deleteLocalFile } from './db';
import type { SystemTags, MetadataTags } from './types';
import { addToast } from './toast';
import { crc32 } from './protocol/crc32';
import { getTags, putTags } from './transport';
import { getTags, putTags, renameRemoteFile } from './transport';
import { get } from 'svelte/store';
import { fsInfo } from './store';
import { fsInfo, syncStateMap } from './store';
function getEmptyTags() {
return {
@@ -206,25 +206,24 @@ export async function updateRemoteFile(
sysTags: SystemTags,
newMetaTags: MetadataTags
): Promise<void> {
// Da das Binärprotokoll noch kein echtes "Rename"-Kommando hat,
// blockieren wir Dateinamen-Änderungen für den Buzzer vorerst.
const currentFsInfo = get(fsInfo);
const basePath = currentFsInfo?.audioPath || "/lfs/a";
const oldFullPath = `${basePath}/${oldName}`;
const newFullPath = `${basePath}/${newName}`;
if (oldName !== newName) {
throw new Error("Das Umbenennen von Dateien direkt auf dem Buzzer wird noch nicht unterstützt. Bitte speichere nur die Tags.");
console.log(`TagHandler: Benenne Datei auf dem Buzzer um (${oldFullPath} -> ${newFullPath})`);
await renameRemoteFile(oldFullPath, newFullPath);
}
// 1. Den binären TLV-Block inkl. Footer bauen
const newTagsBuffer = buildTagBlock(sysTags, newMetaTags);
const tagsBlob = new Blob([newTagsBuffer]);
// 2. Zielpfad ermitteln
const currentFsInfo = get(fsInfo);
const basePath = currentFsInfo?.audioPath || "/lfs/a";
const fullPath = `${basePath}/${oldName}`;
console.log(`Sende modifizierte Tags (${tagsBlob.size} Bytes) an ${fullPath}...`);
console.log(`Sende modifizierte Tags (${tagsBlob.size} Bytes) an ${newFullPath}...`);
// 3. Über das Protokoll an den Buzzer senden
await putTags(tagsBlob, fullPath);
await putTags(tagsBlob, newFullPath);
}
export async function updateFile(
@@ -232,12 +231,43 @@ export async function updateFile(
newName: string,
sysTags: SystemTags,
newMetaTags: MetadataTags,
type: "local" | "buzzer"
type: "local" | "buzzer",
applyToBoth: boolean = false
): Promise<void> {
if (type === "local") {
await updateLocalFile(oldName, newName, sysTags, newMetaTags);
if (applyToBoth) {
try {
const sMap = get(syncStateMap);
const syncStatus = sMap.local[oldName];
if (syncStatus && syncStatus.linkedFiles.length > 0) {
const remoteOldName = syncStatus.linkedFiles[0];
await updateRemoteFile(remoteOldName, newName, sysTags, newMetaTags);
} else {
console.warn(`Keine verknüpfte Remote-Datei für '${oldName}' gefunden. Überspringe Remote-Update.`);
}
} catch (e) {
console.error("Fehler beim synchronen Aktualisieren der Buzzer-Datei:", e);
addToast("Fehler beim Update der Buzzer-Datei: " + (e instanceof Error ? e.message : "Unbekannt"), "error");
}
}
} else {
await updateRemoteFile(oldName, newName, sysTags, newMetaTags);
if (applyToBoth) {
try {
const sMap = get(syncStateMap);
const syncStatus = sMap.buzzer[oldName];
if (syncStatus && syncStatus.linkedFiles.length > 0) {
const localOldName = syncStatus.linkedFiles[0];
await updateLocalFile(localOldName, newName, sysTags, newMetaTags);
} else {
throw new Error(`Keine verknüpfte lokale Datei für '${oldName}' gefunden.`);
}
} catch (e) {
console.error("Fehler beim synchronen Aktualisieren der lokalen Datei:", e);
addToast("Fehler beim Update der lokalen Datei: " + (e instanceof Error ? e.message : "Unbekannt"), "error");
}
}
}
}

View File

@@ -1,9 +1,15 @@
import { buildLSRequest, buildProtocolInfoRequest, buildFSInfoRequest, setLsResolver, buildFileGetRequest, setFileGetResolver, buildTagsGetRequest, uploadState } from './protocol/parser';
import { crc32 } from './protocol/crc32';
import { get } from 'svelte/store';
import { protocolInfo, transferStats } from './store';
import { protocolInfo, transferStats, } from './store';
import { DATA, FRAME } from './protocol/constants';
import { isConnected, resetRemote } from './store';
import { SETTINGS } from './settings';
const isMac = navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('Mac OS X');
const MAX_INFLIGHT = isMac ? SETTINGS.bluetooth.appleMaxInflight : Infinity; // iOS erlaubt nur wenige unbestätigte Nachrichten
console.log("Transport: Max Inflight Frames =", MAX_INFLIGHT);
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
let currentSender: FrameSender | null = null;
@@ -183,6 +189,10 @@ export async function putFile(fileBlob: Blob, remotePath: string, fileNameForUI:
});
}
if (uploadState.credits > MAX_INFLIGHT) {
uploadState.credits = MAX_INFLIGHT;
}
const chunkLen = Math.min(maxChunkSize, fileData.length - offset);
const chunkData = fileData.subarray(offset, offset + chunkLen);
@@ -252,6 +262,51 @@ export async function putFile(fileBlob: Blob, remotePath: string, fileNameForUI:
}
}
export async function deleteRemoteFile(fullPath: string): Promise<void> {
const pathBytes = new TextEncoder().encode(fullPath);
const payloadLength = 1 + 1 + pathBytes.length; // data_type(1) + path_length(1) + path
const buffer = new ArrayBuffer(3 + payloadLength);
const view = new DataView(buffer);
const uint8View = new Uint8Array(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, payloadLength, true);
view.setUint8(3, 0x24); // BUZZ_DATA_RM_FILE
view.setUint8(4, pathBytes.length);
uint8View.set(pathBytes, 5);
await sendFrame(buffer);
// Kurze Wartezeit, bis der Parser SUCCESS verarbeitet und der Flash fertig ist
await new Promise(resolve => setTimeout(resolve, 200));
}
export async function renameRemoteFile(oldFullPath: string, newFullPath: string): Promise<void> {
const oldBytes = new TextEncoder().encode(oldFullPath);
const newBytes = new TextEncoder().encode(newFullPath);
const payloadLength = 1 + 1 + 1 + oldBytes.length + newBytes.length; // data_type + 2x len + 2x string
const buffer = new ArrayBuffer(3 + payloadLength);
const view = new DataView(buffer);
const uint8View = new Uint8Array(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, payloadLength, true);
view.setUint8(3, 0x25); // BUZZ_DATA_RENAME_FILE
view.setUint8(4, oldBytes.length);
view.setUint8(5, newBytes.length);
uint8View.set(oldBytes, 6);
uint8View.set(newBytes, 6 + oldBytes.length);
await sendFrame(buffer);
// Kurze Wartezeit, bis der Parser SUCCESS verarbeitet und der Flash fertig ist
await new Promise(resolve => setTimeout(resolve, 200));
}
export async function putTags(tagsBlob: Blob, remotePath: string): Promise<void> {
if (isFileTransferring) {
throw new Error("Ein Dateitransfer läuft bereits.");

View File

@@ -1,13 +1,14 @@
<!-- index.astro -->
---
import AppGuard from "../components/AppGuard.svelte";
import MainLayout from "../layouts/MainLayout.astro";
import Header from "../components/Header.svelte";
import MainGrid from "../components/MainGrid.svelte";
---
<!-- index.astro -->
<MainLayout>
<AppGuard client:load>
<Header client:load/>
<MainGrid client:load/>
<AppGuard client:only="svelte">
<Header client:only="svelte" />
<MainGrid client:only="svelte" />
</AppGuard>
</MainLayout>
</MainLayout>

View File

@@ -52,7 +52,7 @@
}
.card-title {
@apply font-light font-features-['smcp'] tracking-tight;
@apply font-light font-features-['smcp'] tracking-tighter;
}
/* .card-body {