File upload. Yeah

This commit is contained in:
2026-03-17 15:02:34 +01:00
parent 6ec66cd9da
commit 574ab9fa30
19 changed files with 1479 additions and 250 deletions

View File

@@ -1,16 +1,13 @@
<script lang="ts">
import type { BuzzerFile } from "../lib/types";
import { type BuzzerFile, SyncState } from "../lib/types";
import {
MusicNotesIcon,
DotsThreeVerticalIcon,
PlayIcon,
TrashIcon,
InfoIcon,
PencilIcon,
CircleIcon,
QuestionIcon,
UserCircleCheckIcon,
WarningCircleIcon,
PersonIcon,
TagIcon,
UserIcon,
} from "phosphor-svelte";
@@ -20,6 +17,7 @@
transferDetails,
buzzerAudioFiles,
localAudioFiles,
syncStateMap,
} from "../lib/store";
import { SETTINGS } from "../lib/settings";
@@ -43,6 +41,8 @@
return "default";
})();
$: syncStatus = $syncStateMap[type][file.name] || { state: SyncState.UNKNOWN, linkedFiles: [] };
function toggleSelection() {
if ($isFetchingRemote) return;
@@ -102,39 +102,34 @@
</div>
<div class="flex items-center gap-0 text-xs text-text-muted mt-0.5 min-w-0">
{#if file.sysTags?.crc32}
{#if syncStatus.state === SyncState.UNKNOWN}
<span
use:tooltip={{
text:
"crc32: <span class='font-mono'>0x" +
file.sysTags.crc32.toString(16).toUpperCase() +
"</span>",
pos: "right",
"Dieser Datei fehlt der CRC32-Tag. Bitte aktualisiere über das Menu die Metadaten.",
pos: "right",
variant: "warning",
}}
>
<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 class="font-light shrink-0">
{parseFloat((file.size / 1024).toFixed(1))}&thinsp;kB
</span>
{#if file.metaTags?.a}
<UserIcon weight="fill" class="ml-1 pl-1 mr-0.5 shrink-0 text-slate-500 w-4.5 h-3.5 border-l border-l-text-muted" />
<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" />
<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>
@@ -156,7 +151,7 @@
menuOpen = false;
}}
>
<TrashIcon class="w-5 h-5" />
<TrashIcon class="list-menu-icon" />
</button>
<button
class="menu-btn"
@@ -166,18 +161,17 @@
menuOpen = false;
}}
>
<PlayIcon class="w-5 h-5" />
<PlayIcon class="list-menu-icon" />
</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" />
<PencilIcon class="list-menu-icon" />
</button>
</div>
@@ -185,7 +179,7 @@
class="menu-btn !border-r-transparent"
on:click|stopPropagation={() => (menuOpen = !menuOpen)}
>
<DotsThreeVerticalIcon class="w-5 h-5" />
<DotsThreeVerticalIcon class="list-menu-icon" />
</button>
</div>
</div>
@@ -196,17 +190,17 @@
.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;
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;
@apply bg-white shadow-sm;
}
.menu-btn {
@apply p-1.5 border-r border-r-border-card
@apply border-r border-r-border-card
flex items-center justify-center shrink-0 transition-colors;
&:not(:disabled):not(.danger) {

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { localAudioFiles, buzzerAudioFiles } from "../lib/store";
import { isConnected, fsInfo, tagEditorState } 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,
refreshRemote,
downloadSelectedFiles,
uploadSelectedFiles,
} from "../lib/sync";
import {
GearIcon,
CloudArrowUpIcon,
ArrowClockwiseIcon,
DotsThreeVerticalIcon,
CheckSquareOffsetIcon,
SquareIcon,
DownloadIcon,
TrashIcon,
FingerprintIcon,
UploadIcon,
} from "phosphor-svelte";
export let type: "local" | "buzzer" = "buzzer";
export let onToggleMenu: () => void; // Callback-Prop definieren
let showMenu = false;
$: 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);
</script>
<div class="btn-connect-group {$isConnected || type === 'local' ? 'active group' : ''}">
{#if type === "buzzer"}
<button
class="btn-main"
aria-label="Alle ausgewählten Dateien herunterladen"
on:click={() => {
downloadSelectedFiles();
}}
disabled={!$isConnected || selectedFileCount === 0}
title="Alle ausgewählten Dateien herunterladen"
>
<DownloadIcon class="list-menu-icon" />
</button>
{:else}
<button
class="btn-main"
aria-label="Alle ausgewählten Dateien herunterladen"
on:click={() => {
uploadSelectedFiles();
}}
disabled={!$isConnected || selectedFileCount === 0}
title="Alle ausgewählten Dateien hochladen"
>
<UploadIcon class="list-menu-icon" />
</button>
{/if}
<button
class="btn-main"
aria-label="Alles Auswählen"
on:click={() => {
activeStore.update((files) => files.map((f) => ({ ...f, selected: true })));
}}
disabled={(!$isConnected && type === "buzzer") || selectedFileCount === fileCount}
title="Alle auswählen"
>
<CheckSquareOffsetIcon class="list-menu-icon" />
</button>
<button
class="btn-main"
on:click={() => {
activeStore.update((files) => files.map((f) => ({ ...f, selected: false })));
}}
aria-label="Auswahl löschen"
disabled={(!$isConnected && type === "buzzer") || selectedFileCount === 0}
title="Auswahl aufheben"
>
<SquareIcon class="list-menu-icon" />
</button>
<button
class="btn-main"
on:click={() => {
if (type === "buzzer") {
refreshRemote();
} else {
refreshLocal();
}
}}
aria-label="Reload"
disabled={!$isConnected && type === "buzzer"}
title="Dateiliste neu laden"
>
<ArrowClockwiseIcon class="list-menu-icon" />
</button>
<button
class="btn-main"
aria-label="Menu"
disabled={!$isConnected && type === "buzzer"}
title="Menu"
on:click|stopPropagation={onToggleMenu}
>
<DotsThreeVerticalIcon class="list-menu-icon" />
</button>
</div>
<style>
@reference "../styles/app.css";
.btn-connect-group {
@apply flex items-stretch rounded-full overflow-hidden transition-all;
}
.btn-connect-group.active {
@apply hover:border-border-card hover:shadow-sm hover:bg-white;
}
.btn-main {
@apply border-r-1 border-transparent last:border-r-0 focus:outline-none;
&:not(:disabled) {
@apply hover:shadow-sm hover:bg-slate-200 cursor-pointer group-hover:border-r-slate-200;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed group-hover:border-r-slate-200 border-r-transparent;
}
}
</style>

View File

@@ -35,7 +35,7 @@
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}\u202B`;
if (bytes < 1024) return `${bytes}\u202FB`;
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`;

View File

@@ -311,7 +311,7 @@
value={activeName}
on:input={updateName}
maxlength={maxFilenameLength}
class="editor-input font-medium {getInputClass(
class="editor-input {getInputClass(
hasDraft && activeName !== currentFile.name,
)}"
/>

View File

@@ -1,37 +1,21 @@
<script lang="ts">
import { isConnected, fsInfo, tagEditorState } from "../lib/store";
import {
GearIcon,
CloudArrowUpIcon,
ArrowClockwiseIcon,
DotsThreeVerticalIcon,
CheckSquareOffsetIcon,
SquareIcon,
DownloadIcon,
} from "phosphor-svelte";
import { GearIcon, CloudArrowUpIcon, DotsThreeVerticalIcon } from "phosphor-svelte";
import FileList from "./FileList.svelte";
import DeviceInfo from "./DeviceInfo.svelte";
import { refreshRemote, downloadSelectedFiles } from "../lib/sync";
import {
buzzerAudioFiles,
buzzerFilesCount,
selectedBuzzerFilesCount,
transferStats,
isFetchingRemote,
pairedDevices,
activeDeviceId,
transferDetails,
} from "../lib/store";
import { refreshRemote } from "../lib/sync";
import { transferStats, isFetchingRemote, pairedDevices, activeDeviceId } from "../lib/store";
import { SETTINGS } from "../lib/settings";
import TransferProgress from "./TransferProgress.svelte";
import FileMenuOverlay from "./FileMenuOverlay.svelte";
import FileTagEditor from "./FileTagEditor.svelte";
import FileListMenu from "./FileListMenu.svelte";
let showOverlay = false;
let isTransferFinished = false;
let overlayTimeout: ReturnType<typeof setTimeout>;
let showBuzzerMenu = false;
let showLocalMenu = false;
let showBuzzerMenu = false;
let editModeType: "local" | "buzzer" | null = null;
let fileToEdit: string | null = null;
@@ -117,13 +101,7 @@
<section class="buzzer-card relative self-start">
<div class="card-header">
<h3 class="card-title">Lokale Bibliothek</h3>
<button
class="btn"
aria-label="Menu"
on:click|stopPropagation={() => (showLocalMenu = !showLocalMenu)}
>
<DotsThreeVerticalIcon class="icon" />
</button>
<FileListMenu type="local" onToggleMenu={() => (showLocalMenu = !showLocalMenu)} />
</div>
<div class="grid">
<div class="col-start-1 row-start-1">
@@ -145,75 +123,23 @@
</div>
<div class="col-start-1 row-start-1 z-20" class:pointer-events-none={!showLocalMenu}>
<FileMenuOverlay type="local" show={showLocalMenu} onClose={() => (showLocalMenu = false)} />
<FileMenuOverlay
type="local"
show={showLocalMenu}
onClose={() => (showLocalMenu = false)}
/>
</div>
</div>
</section>
<section class="buzzer-card relative self-start">
<div class="card-header">
<div>
<h3 class="card-title">
Gerätebibliothek <span class="text-text-muted text-xs font-mono">
{$fsInfo?.audioPath}
</span>
</h3>
</div>
<div>
<div class="btn-connect-group group">
<button
class="btn-main"
aria-label="Alle ausgewählten Dateien herunterladen"
on:click={() => {
downloadSelectedFiles();
}}
disabled={!$isConnected || $selectedBuzzerFilesCount === 0}
title="Alle ausgewählten Dateien herunterladen"
>
<DownloadIcon class="icon" />
</button>
<button
class="btn-main"
aria-label="Alles Auswählen"
on:click={() => {
buzzerAudioFiles.update((files) => files.map((f) => ({ ...f, selected: true })));
}}
disabled={!$isConnected || $buzzerFilesCount === $selectedBuzzerFilesCount}
title="Alle auswählen"
>
<CheckSquareOffsetIcon class="icon" />
</button>
<button
class="btn-main"
on:click={() => {
buzzerAudioFiles.update((files) => files.map((f) => ({ ...f, selected: false })));
}}
aria-label="Auswahl löschen"
disabled={!$isConnected || $selectedBuzzerFilesCount === 0}
title="Auswahl aufheben"
>
<SquareIcon class="icon" />
</button>
<button
class="btn-main"
on:click={() => refreshRemote()}
aria-label="Reload"
disabled={!$isConnected}
title="Dateiliste neu laden"
>
<ArrowClockwiseIcon class="icon" />
</button>
<button
class="btn-main"
aria-label="Menu"
disabled={!$isConnected}
title="Menu"
on:click|stopPropagation={() => (showBuzzerMenu = !showBuzzerMenu)}
>
<DotsThreeVerticalIcon class="icon" />
</button>
</div>
</div>
<h3 class="card-title">
Gerätebibliothek <span class="text-text-muted text-xs font-mono">
{$fsInfo?.audioPath}
</span>
</h3>
<FileListMenu type="buzzer" onToggleMenu={() => (showBuzzerMenu = !showBuzzerMenu)} />
</div>
<div class="grid">
<div class="col-start-1 row-start-1">
@@ -222,6 +148,17 @@
</div>
</div>
<div
class="col-start-1 row-start-1 z-30"
class:hidden={!$tagEditorState.show || $tagEditorState.type !== "buzzer"}
>
<FileTagEditor
type="buzzer"
show={$tagEditorState.show && $tagEditorState.type === "buzzer"}
initialFileName={$tagEditorState.fileName}
onClose={() => tagEditorState.set({ show: false, type: "buzzer", fileName: "" })}
/>
</div>
<div class="col-start-1 row-start-1 z-20" class:pointer-events-none={!showBuzzerMenu}>
<FileMenuOverlay
type="buzzer"
@@ -247,20 +184,4 @@
@apply cursor-not-allowed;
}
}
.btn-connect-group {
@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 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;
}
&:disabled {
color: color-mix(in srgb, currentColor 50%, transparent);
@apply cursor-not-allowed group-hover:border-r-slate-200;
}
}
</style>

View File

@@ -57,4 +57,16 @@ export async function deleteLocalFile(name: string): Promise<void> {
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
export async function getLocalFile(name: string): Promise<{name: string, blob: Blob, size: number} | undefined> {
const db = await initDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(name);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

View File

@@ -10,11 +10,13 @@ export const FRAME = {
RESPONSE: 0x10,
ACK: 0x11,
ERROR: 0x12,
SUCCESS: 0x13,
FILE_START: 0x20,
FILE_CHUNK: 0x21,
FILE_END: 0x22,
LS_START: 0x40,
LS_ENTRY: 0x41,
LS_END: 0x42,
@@ -26,7 +28,8 @@ export const DATA = {
FILE_GET: 0x20,
FILE_PUT: 0x21,
TAGS_GET: 0x22,
TAGS_PUT: 0x23,
LS: 0x40
};

View File

@@ -6,6 +6,7 @@ import { crc32 } from './crc32';
import { get } from 'svelte/store';
import { saveLocalFile } from '../db';
import { refreshLocal } from '../sync';
import { file } from 'astro:schema';
let lastUiUpdate = 0;
let currentFileCrc32 = 0;
@@ -17,7 +18,7 @@ let fileChunks: Uint8Array[] = [];
let lsTimeout: ReturnType<typeof setTimeout> | null = null;
let lsResolve: ((data: any[]) => void) | null = null;
let lsReject: ((error: Error) => void) | null = null;
let fileGetResolve: ((success: boolean) => void) | null = null;
let fileGetResolve: ((result: { success: boolean, blob?: Blob }) => void) | null = null;
let fileGetReject: ((error: Error) => void) | null = null;
export function showErrorToast(errorCode: number) {
@@ -87,7 +88,6 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
case FRAME.LS_END:
if (lsTimeout) clearTimeout(lsTimeout);
const total = view.getUint32(3, true);
console.debug(`LS Stream beendet. Erwartete Einträge: ${total}, empfangen: ${lsBuffer.length}`, lsBuffer);
if (total !== lsBuffer.length) {
console.warn(`LS Stream: Erwartete Anzahl Einträge laut Header: ${total}, tatsächlich empfangen: ${lsBuffer.length}`);
addToast(`Warnung: LS Stream erwartete ${total} Einträge, aber nur ${lsBuffer.length} empfangen.`, 'warning');
@@ -98,20 +98,22 @@ export function parseIncomingFrame(view: DataView, sender: FrameSender) {
}
break;
case FRAME.FILE_START:
case FRAME.FILE_START:
currentFileCrc32 = 0;
const totalBytes = view.getUint32(3, true);
const nowStart = performance.now();
fileChunks = [];
transferStats.update(s => ({
...s,
bytesTotal: totalBytes,
bytesDone: 0,
currentFileName: s.pendingFileName || s.currentFileName,
fileStartTime: nowStart,
bulkStartTime: s.bulkStartTime === 0 ? nowStart : s.bulkStartTime
}));
if (fileTransfer.mode === 'file') {
transferStats.update(s => ({
...s,
bytesTotal: totalBytes,
bytesDone: 0,
currentFileName: s.pendingFileName || s.currentFileName,
fileStartTime: nowStart,
bulkStartTime: s.bulkStartTime === 0 ? nowStart : s.bulkStartTime
}));
}
// Parser-interne Metriken (Watchdog etc.)
fileTransfer.totalBytes = totalBytes;
@@ -120,8 +122,6 @@ case FRAME.FILE_START:
fileTransfer.startTime = nowStart;
lastUiUpdate = 0;
console.log(`[FILE_GET] Stream gestartet. Erwartete Größe: ${fileTransfer.totalBytes} Bytes.`);
fileTransfer.metricsTimer = setInterval(() => {
if (!fileTransfer.active) return;
@@ -166,17 +166,18 @@ case FRAME.FILE_START:
fileTransfer.receivedBytes += payloadLength;
fileTransfer.credits--;
const nowChunk = performance.now();
if (nowChunk - lastUiUpdate > SETTINGS.ui.transferUpdateIntervalMs) {
const delta = fileTransfer.receivedBytes - previousReceived; // Das Delta seit dem letzten Paket
if (fileTransfer.mode === 'file') {
const nowChunk = performance.now();
if (nowChunk - lastUiUpdate > SETTINGS.ui.transferUpdateIntervalMs) {
const delta = fileTransfer.receivedBytes - previousReceived;
transferStats.update(s => ({
...s,
bytesDone: fileTransfer.receivedBytes,
overallDone: s.overallDone + (fileTransfer.receivedBytes - s.bytesDone)
}));
console.log("[FILE_GET] Fortschritt: " + ((fileTransfer.receivedBytes / fileTransfer.totalBytes) * 100).toFixed(2) + "%");
lastUiUpdate = nowChunk;
transferStats.update(s => ({
...s,
bytesDone: fileTransfer.receivedBytes,
overallDone: s.overallDone + (fileTransfer.receivedBytes - s.bytesDone)
}));
lastUiUpdate = nowChunk;
}
}
if (fileTransfer.credits <= 64) {
@@ -186,53 +187,74 @@ case FRAME.FILE_START:
break;
case FRAME.FILE_END:
transferStats.update(s => {
return {
if (fileTransfer.mode === 'file') {
transferStats.update(s => ({
...s,
bytesDone: s.bytesTotal,
};
});
// Watchdog stoppen
}));
}
if (fileTransfer.metricsTimer) clearInterval(fileTransfer.metricsTimer);
fileTransfer.active = false;
const buzzerCrc32 = view.getUint32(3, true);
console.log(`[CRC] Lokal: 0x${currentFileCrc32.toString(16).toUpperCase()}`);
console.log(`[CRC] Buzzer: 0x${buzzerCrc32.toString(16).toUpperCase()}`);
const totalElapsed = (performance.now() - fileTransfer.startTime) / 1000;
const avgSpeed = (fileTransfer.receivedBytes / 1024) / totalElapsed;
console.log(`[FILE_GET] Stream beendet.`);
console.log(`[FILE_GET] Empfangen: ${fileTransfer.receivedBytes} Bytes in ${totalElapsed.toFixed(2)}s.`);
console.log(`[FILE_GET] Durchschnitt: ${avgSpeed.toFixed(2)} KB/s`);
if (currentFileCrc32 === buzzerCrc32) {
console.log("%c[CRC] Match! Datei ist integer.", "color: green; font-weight: bold;");
const fileBlob = new Blob(fileChunks, { type: 'audio/wav' });
const fileName = get(transferStats).currentFileName;
saveLocalFile(fileName, fileBlob, fileTransfer.totalBytes)
.then(() => {
console.log(`Datei ${fileName} erfolgreich lokal gespeichert.`);
refreshLocal();
})
.catch(err => {
console.error("Datenbankfehler:", err);
addToast(`Speichern von ${fileName} fehlgeschlagen.`, 'error');
});
const fileBlob = new Blob(fileChunks, { type: 'application/octet-stream' });
if (fileTransfer.mode === 'file') {
const fileName = get(transferStats).currentFileName;
saveLocalFile(fileName, fileBlob, fileTransfer.totalBytes)
.then(() => {
refreshLocal();
if (fileGetResolve) {
fileGetResolve({ success: true });
}
})
.catch(err => {
console.error("Datenbankfehler:", err);
addToast(`Speichern von ${fileName} fehlgeschlagen.`, 'error');
if (fileGetReject) fileGetReject(err);
})
.finally(() => {
fileGetResolve = null;
fileGetReject = null;
});
} else {
// TAGS Modus: Blob direkt zurückgeben, nichts speichern
if (fileGetResolve) fileGetResolve({ success: true, blob: fileBlob });
fileGetResolve = null;
fileGetReject = null;
}
} else {
console.error("[CRC] Mismatch! Datei beschädigt.");
addToast("CRC Fehler: Datei wurde fehlerhaft übertragen.", "error");
if (fileGetReject) fileGetReject(new Error("CRC Mismatch"));
break;
}
if (fileGetResolve) {
fileGetResolve(true);
fileGetResolve = null;
fileGetReject = null;
}
break;
case FRAME.ACK:
if (uploadState.active && payloadLength >= 2) {
const creditsAdded = view.getUint16(3, true);
uploadState.credits += creditsAdded;
if (uploadState.onCreditsAdded) {
uploadState.onCreditsAdded();
}
}
case FRAME.SUCCESS:
if (payloadLength >= 1) {
const successDataType = view.getUint8(3);
if (uploadState.active && successDataType === DATA.FILE_PUT || successDataType === DATA.TAGS_PUT) {
if (uploadState.onSuccess) uploadState.onSuccess();
}
}
break;
case FRAME.ERROR:
const errorCode = view.getUint16(3, true);
console.error(`Received error frame with code: 0x${errorCode.toString(16)}`);
@@ -249,6 +271,9 @@ case FRAME.FILE_START:
fileGetResolve = null;
fileGetReject = null;
}
if (uploadState.active && uploadState.onError) {
uploadState.onError(new Error(`Buzzer Error 0x${errorCode.toString(16)}`));
}
break;
default:
@@ -304,7 +329,6 @@ async function sendCredits(count: number, send: FrameSender) {
const buffer = new ArrayBuffer(5);
const view = new DataView(buffer);
console.debug(`Sende ${count} Credits für Stream...`);
view.setUint8(0, FRAME.ACK);
view.setUint16(1, 2, true);
view.setUint16(3, count, true);
@@ -332,6 +356,7 @@ export function setLsResolver(resolve: (data: any[]) => void, reject: (error: Er
const fileTransfer = {
active: false,
mode: 'file' as 'file' | 'tags',
startTime: 0,
totalBytes: 0,
receivedBytes: 0,
@@ -341,9 +366,22 @@ const fileTransfer = {
metricsTimer: null as ReturnType<typeof setInterval> | null
};
export function setFileGetResolver(resolve: (success: boolean) => void, reject: (error: Error) => void) {
export const uploadState = {
active: false,
credits: 0,
onCreditsAdded: null as (() => void) | null,
onSuccess: null as (() => void) | null,
onError: null as ((err: Error) => void) | null,
};
export function setFileGetResolver(
resolve: (result: { success: boolean, blob?: Blob }) => void,
reject: (error: Error) => void,
mode: 'file' | 'tags' = 'file' // Standard ist 'file'
) {
fileGetResolve = resolve;
fileGetReject = reject;
fileTransfer.mode = mode;
}
export function buildFileGetRequest(path: string): ArrayBuffer {
@@ -359,5 +397,21 @@ export function buildFileGetRequest(path: string): ArrayBuffer {
const uint8Buffer = new Uint8Array(buffer);
uint8Buffer.set(pathBytes, 4);
return buffer;
}
export function buildTagsGetRequest(path: string): ArrayBuffer {
const encoder = new TextEncoder();
const pathBytes = encoder.encode(path);
const buffer = new ArrayBuffer(4 + pathBytes.length);
const view = new DataView(buffer);
view.setUint8(0, FRAME.REQUEST);
view.setUint16(1, 1 + pathBytes.length, true);
view.setUint8(3, DATA.TAGS_GET);
const uint8Buffer = new Uint8Array(buffer);
uint8Buffer.set(pathBytes, 4);
return buffer;
}

View File

@@ -1,6 +1,7 @@
import { writable, derived } from 'svelte/store';
import type { BuzzerFile } from './types';
import { SETTINGS } from './settings';
import { syncState, type SyncStatus } from './sync';
const CONNECTION_STATE_KEY = 'buzzer_connection_state';
@@ -230,4 +231,96 @@ export const tagEditorState = writable<{show: boolean, type: "local" | "buzzer",
show: false,
type: "buzzer",
fileName: ""
});
});
function tagsAreEqual(tagsA: any, tagsB: any): boolean {
const a = tagsA || {};
const b = tagsB || {};
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) return false;
}
return true;
}
export const syncStateMap = derived(
[localAudioFiles, buzzerAudioFiles],
([$local, $remote]) => {
const result = {
local: {} as Record<string, SyncStatus>,
buzzer: {} as Record<string, SyncStatus>
};
const localByCrc = new Map<number, typeof $local>();
const remoteByCrc = new Map<number, typeof $remote>();
// 1. Gruppierung und Filterung von fehlenden CRCs
for (const file of $local) {
if (!file.sysTags || !file.sysTags.crc32) {
result.local[file.name] = { state: SyncState.UNKNOWN, linkedFiles: [] };
continue;
}
const crc = file.sysTags.crc32;
if (!localByCrc.has(crc)) localByCrc.set(crc, []);
localByCrc.get(crc)!.push(file);
}
for (const file of $remote) {
if (!file.sysTags || !file.sysTags.crc32) {
result.buzzer[file.name] = { state: SyncState.UNKNOWN, linkedFiles: [] };
continue;
}
const crc = file.sysTags.crc32;
if (!remoteByCrc.has(crc)) remoteByCrc.set(crc, []);
remoteByCrc.get(crc)!.push(file);
}
// 2. Auswertung der Gruppen
const allCrcs = new Set([...localByCrc.keys(), ...remoteByCrc.keys()]);
for (const crc of allCrcs) {
const locals = localByCrc.get(crc) || [];
const remotes = remoteByCrc.get(crc) || [];
const remoteNames = remotes.map(f => f.name);
const localNames = locals.map(f => f.name);
// Regel 5: Duplikate
if (locals.length > 1 || remotes.length > 1) {
locals.forEach(f => result.local[f.name] = { state: SyncState.DUPLICATE, linkedFiles: localNames.filter(n => n !== f.name) });
remotes.forEach(f => result.buzzer[f.name] = { state: SyncState.DUPLICATE, linkedFiles: remoteNames.filter(n => n !== f.name) });
continue;
}
// Regel 2: Einseitig vorhanden
if (locals.length === 1 && remotes.length === 0) {
result.local[locals[0].name] = { state: SyncState.SINGLE_SIDED, linkedFiles: [] };
continue;
}
if (locals.length === 0 && remotes.length === 1) {
result.buzzer[remotes[0].name] = { state: SyncState.SINGLE_SIDED, linkedFiles: [] };
continue;
}
// Regel 3 & 4: Beidseitig vorhanden (genau 1 lokal, genau 1 remote)
if (locals.length === 1 && remotes.length === 1) {
const localFile = locals[0];
const remoteFile = remotes[0];
const namesMatch = localFile.name === remoteFile.name;
const tagsMatch = tagsAreEqual(localFile.metaTags, remoteFile.metaTags);
const finalState = (namesMatch && tagsMatch) ? SyncState.SYNCED : SyncState.CONFLICT;
result.local[localFile.name] = { state: finalState, linkedFiles: remoteNames };
result.buzzer[remoteFile.name] = { state: finalState, linkedFiles: localNames };
}
}
return result;
}
);

View File

@@ -2,11 +2,12 @@ import { get } from 'svelte/store';
import { isConnected, fsInfo, buzzerAudioFiles, buzzerSysFiles, isFetchingRemote, isFetchingLocal, storageUsage, localAudioFiles, transferStats } from './store';
import { requestProtocolInfo, requestFSInfo, fetchDirectory } from './transport';
import type { BuzzerFile } from './types';
import { getFile } from './transport';
import { getFile, putFile } from './transport';
import { addToast } from './toast';
import { getLocalFiles, deleteLocalFile } from './db';
import { parseLocalFileTags } from './tagHandler';
import { getLocalFiles, deleteLocalFile, getLocalFile } from './db';
import { parseAudioFileTags } from './tagHandler';
import { SETTINGS } from './settings';
import { fetchFileTags } from './tagHandler';
function mapToBuzzerFile(rawFile: any): BuzzerFile {
return {
@@ -38,14 +39,30 @@ export async function refreshRemote() {
buzzerSysFiles.set(sysFiles.map(mapToBuzzerFile));
const audioFiles = await fetchDirectory(currentFsInfo?.audioPath || "/lfs/a");
buzzerAudioFiles.set(audioFiles.map(mapToBuzzerFile));
let mappedAudio = audioFiles.map(mapToBuzzerFile);
// Dateien sofort im UI anzeigen, bevor die Tags geladen sind
buzzerAudioFiles.set([...mappedAudio]);
// Tags sequenziell für alle gefundenen Audiodateien laden
for (let i = 0; i < mappedAudio.length; i++) {
const fileName = mappedAudio[i].name;
try {
const tags = await fetchFileTags(fileName, "buzzer");
mappedAudio[i].sysTags = tags.sysTags;
mappedAudio[i].metaTags = tags.metaTags;
mappedAudio[i].tagsLoaded = true;
// Store aktualisieren, um das UI pro Datei neu zu rendern
buzzerAudioFiles.set([...mappedAudio]);
} catch (error) {
console.warn(`Konnte Tags für ${fileName} nicht laden.`);
}
}
console.log("Audiodatein: ", audioFiles);
console.log("Systemdatein: ", sysFiles);
console.log("Aktuelle FS-Info: ", currentFsInfo);
console.log("Storage Usage: ", get(storageUsage));
} catch (error) {
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);
}
@@ -58,7 +75,7 @@ export async function refreshLocal() {
// 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);
const { sysTags, metaTags } = await parseAudioFileTags(record.blob, record.name);
return {
name: record.name,
@@ -74,6 +91,7 @@ export async function refreshLocal() {
localAudioFiles.set(files);
} catch (error) {
console.error("Fehler beim Laden der lokalen Datenbank:", error);
addToast("Fehler beim Laden der lokalen Datenbank: " + (error instanceof Error ? error.message : "Unbekannter Fehler"), "error");
} finally {
isFetchingLocal.set(false);
}
@@ -102,7 +120,7 @@ export async function downloadSelectedFiles() {
try {
for (const file of files) {
console.log(`Starte Download von: ${file.name}`);
console.debug(`Starte Download von: ${file.name}`);
transferStats.update(s => ({ ...s, pendingFileName: file.name }));
@@ -122,9 +140,8 @@ for (const file of files) {
} finally {
transferStats.update(s => ({
...s,
overallDone: s.overallTotal, // Schließt den Ladebalken visuell sauber ab
overallDone: s.overallTotal,
}));
// Das console.log für den Live-Speed wurde entfernt, da es hier falsche Werte liefert
isFetchingRemote.set(false);
}
}
@@ -144,4 +161,61 @@ export async function deleteSelectedLocalFiles() {
} catch (error) {
console.error("Fehler beim Löschen lokaler Dateien:", error);
}
}
export async function uploadSelectedFiles() {
const files = get(localAudioFiles).filter(f => f.selected);
const pathPrefix = get(fsInfo)?.audioPath || "/lfs/a";
if (files.length === 0) {
addToast("Keine Dateien zum Hochladen ausgewählt.", "warning");
return;
}
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
const bulkStart = performance.now();
transferStats.update(s => ({
...s,
overallTotal: totalBytes,
overallDone: 0,
bulkStartTime: bulkStart
}));
// Wir nutzen isFetchingRemote als generischen "Transfer aktiv"-Trigger für das UI TODO: Namensänderung in isTransferring?
isFetchingRemote.set(true);
try {
for (const file of files) {
console.debug(`Starte Upload von: ${file.name} (${(file.size / 1024).toFixed(1)} kB)`);
transferStats.update(s => ({ ...s, pendingFileName: file.name }));
const dbRecord = await getLocalFile(file.name);
if (!dbRecord || !dbRecord.blob) {
throw new Error(`Datei ${file.name} nicht in lokaler Datenbank gefunden.`);
}
const fullPath = `${pathPrefix}/${file.name}`;
await new Promise(r => setTimeout(r, SETTINGS.ui.transferUpdateIntervalMs));
await putFile(dbRecord.blob, fullPath, file.name);
}
const totalTimeSec = (performance.now() - bulkStart) / 1000;
const avgSpeedKbs = ((totalBytes / 1024) / totalTimeSec).toFixed(1);
addToast(` ${files.length === 1 ? "Eine Datei" : files.length + " Dateien"} erfolgreich hochgeladen. (${avgSpeedKbs} kB/s)`, "success");
// Buzzer-Ansicht nach erfolgreichem Upload aktualisieren
refreshRemote();
} catch (error) {
console.error("Bulk-Upload Fehler:", error);
addToast("Upload abgebrochen: " + (error instanceof Error ? error.message : "Unbekannter Fehler"), "error");
} finally {
transferStats.update(s => ({
...s,
overallDone: s.overallTotal, // Schließt den Ladebalken visuell sauber ab
}));
isFetchingRemote.set(false);
}
}

View File

@@ -2,6 +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 { get } from 'svelte/store';
import { fsInfo } from './store';
function getEmptyTags() {
return {
@@ -10,7 +13,7 @@ function getEmptyTags() {
};
}
export async function parseLocalFileTags(blob: Blob, filename: string): Promise<{ sysTags: SystemTags, metaTags: MetadataTags }> {
export async function parseAudioFileTags(blob: Blob, filename: string): Promise<{ sysTags: SystemTags, metaTags: MetadataTags }> {
const MAGIC = "TAG!";
const VERSION = 1;
@@ -125,9 +128,9 @@ function buildTagBlock(sysTags: SystemTags, metaTags: MetadataTags): ArrayBuffer
// 1. Audio Format TLV (Type 0x00, Index 0x00)
if (sysTags.format) {
view.setUint8(offset, 0x00);
view.setUint8(offset, 0x00);
view.setUint8(offset + 1, 0x00);
view.setUint16(offset + 2, 8, true);
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)
@@ -177,7 +180,7 @@ export async function updateLocalFile(oldName: string, newName: string, sysTags:
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) {
@@ -197,6 +200,33 @@ export async function updateLocalFile(oldName: string, newName: string, sysTags:
await saveLocalFile(newName, finalBlob, finalBlob.size);
}
export async function updateRemoteFile(
oldName: string,
newName: string,
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.
if (oldName !== newName) {
throw new Error("Das Umbenennen von Dateien direkt auf dem Buzzer wird noch nicht unterstützt. Bitte speichere nur die Tags.");
}
// 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}...`);
// 3. Über das Protokoll an den Buzzer senden
await putTags(tagsBlob, fullPath);
}
export async function updateFile(
oldName: string,
newName: string,
@@ -207,8 +237,7 @@ export async function updateFile(
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.");
await updateRemoteFile(oldName, newName, sysTags, newMetaTags);
}
}
@@ -224,11 +253,19 @@ export async function fetchFileTags(
const record = files.find(f => f.name === filename);
if (!record) throw new Error(`Datei ${filename} lokal nicht gefunden.`);
return await parseLocalFileTags(record.blob, filename);
return await parseAudioFileTags(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();
try {
const currentFsInfo = get(fsInfo);
const basePath = currentFsInfo?.audioPath || "/lfs/a";
const fullPath = `${basePath}/${filename}`;
const blob = await getTags(fullPath);
return await parseAudioFileTags(blob, filename);
} catch (e) {
console.warn(`Fehler beim Abrufen der Remote-Tags für ${filename}:`, e);
return getEmptyTags();
}
}
}
@@ -256,7 +293,7 @@ export async function calculateLocalAudioCrc(filename: string): Promise<number>
// 2. Audio-Daten einlesen
const audioData = await blob.slice(0, audioSize).arrayBuffer();
// 3. CRC32 berechnen
return crc32(new Uint8Array(audioData));
}
@@ -267,8 +304,8 @@ export async function updateLocalAudioCrc(filename: string): Promise<number> {
if (!record) throw new Error(`Datei ${filename} nicht gefunden.`);
// Tags auslesen
const { sysTags, metaTags } = await parseLocalFileTags(record.blob, filename);
const { sysTags, metaTags } = await parseAudioFileTags(record.blob, filename);
// Neue CRC berechnen
const newCrc = await calculateLocalAudioCrc(filename);
if (newCrc === sysTags.crc32) {
@@ -279,6 +316,6 @@ export async function updateLocalAudioCrc(filename: string): Promise<number> {
// Mit aktualisierter CRC speichern
const updatedSysTags = { ...sysTags, crc32: newCrc };
await updateLocalFile(filename, filename, updatedSysTags, metaTags);
return newCrc;
}

View File

@@ -1,5 +1,8 @@
import { buildLSRequest, buildProtocolInfoRequest, buildFSInfoRequest, setLsResolver } from './protocol/parser';
import { buildFileGetRequest, setFileGetResolver } from './protocol/parser';
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 { DATA, FRAME } from './protocol/constants';
import { isConnected, resetRemote } from './store';
export type FrameSender = (buffer: ArrayBuffer) => Promise<void>;
@@ -78,7 +81,7 @@ export async function getFile(path: string): Promise<boolean> {
return new Promise(async (resolve, reject) => {
setFileGetResolver(
(success) => { isFileTransferring = false; resolve(success); },
(result: any) => { isFileTransferring = false; resolve(result.success); },
(err) => { isFileTransferring = false; reject(err); }
);
@@ -89,4 +92,261 @@ export async function getFile(path: string): Promise<boolean> {
reject(e);
}
});
}
export async function getTags(path: string): Promise<Blob> {
if (isFileTransferring) {
throw new Error("Ein Dateitransfer läuft bereits.");
}
isFileTransferring = true;
return new Promise(async (resolve, reject) => {
setFileGetResolver(
(result: any) => {
isFileTransferring = false;
// Wenn wir erfolgreich sind, geben wir den Blob zurück. Bei 0 Bytes ist er leer.
if (result.success && result.blob) {
resolve(result.blob);
} else {
resolve(new Blob([])); // Fallback
}
},
(err) => {
isFileTransferring = false;
reject(err);
},
'tags' // WICHTIG: Setzt den Parser in den stummen Modus ohne UI-Ladebalken!
);
try {
await sendFrame(buildTagsGetRequest(path));
} catch (e) {
isFileTransferring = false;
reject(e);
}
});
}
export async function putFile(fileBlob: Blob, remotePath: string, fileNameForUI: string): Promise<void> {
if (isFileTransferring) {
throw new Error("Ein Dateitransfer läuft bereits.");
}
isFileTransferring = true;
uploadState.active = true;
uploadState.credits = 0; // Warten auf das initiale ACK
uploadState.onCreditsAdded = null;
uploadState.onSuccess = null;
uploadState.onError = null;
try {
const pathBytes = new TextEncoder().encode(remotePath);
const reqBuffer = new ArrayBuffer(4 + 1 + 4 + pathBytes.length); // Header(3) + DataType(1) + Size(4) + Path
const reqView = new DataView(reqBuffer);
reqView.setUint8(0, FRAME.REQUEST);
reqView.setUint16(1, 1 + 4 + pathBytes.length, true);
reqView.setUint8(3, DATA.FILE_PUT);
reqView.setUint32(4, fileBlob.size, true);
new Uint8Array(reqBuffer).set(pathBytes, 8);
// UI Statistiken initialisieren
const startTime = performance.now();
transferStats.update(s => ({
...s,
bytesTotal: fileBlob.size,
bytesDone: 0,
currentFileName: fileNameForUI,
fileStartTime: startTime,
bulkStartTime: s.bulkStartTime === 0 ? startTime : s.bulkStartTime
}));
await sendFrame(reqBuffer);
// Chunking Loop
const maxChunkSize = get(protocolInfo)?.maxChunkSize || 240;
const fileData = new Uint8Array(await fileBlob.arrayBuffer());
let offset = 0;
let currentCrc = 0;
let lastUiUpdate = 0;
while (offset < fileData.length) {
// Blockieren, falls keine Credits vorhanden sind
if (uploadState.credits <= 0) {
await new Promise<void>((resolve) => {
uploadState.onCreditsAdded = () => {
if (uploadState.credits > 0) {
uploadState.onCreditsAdded = null;
resolve();
}
};
});
}
const chunkLen = Math.min(maxChunkSize, fileData.length - offset);
const chunkData = fileData.subarray(offset, offset + chunkLen);
// CRC32 fortlaufend berechnen
currentCrc = crc32(chunkData, currentCrc);
const chunkBuffer = new ArrayBuffer(3 + chunkLen); // Header(3) + Payload
const chunkView = new DataView(chunkBuffer);
chunkView.setUint8(0, FRAME.FILE_CHUNK);
chunkView.setUint16(1, chunkLen, true);
new Uint8Array(chunkBuffer).set(chunkData, 3);
await sendFrame(chunkBuffer);
uploadState.credits--;
offset += chunkLen;
// UI gedrosselt updaten (z.B. alle 100ms)
const now = performance.now();
if (now - lastUiUpdate > 100) {
transferStats.update(s => ({
...s,
bytesDone: offset,
overallDone: s.overallDone + (offset - s.bytesDone)
}));
lastUiUpdate = now;
}
}
// Abschließendes UI Update
transferStats.update(s => ({ ...s, bytesDone: fileData.length }));
// END Frame senden
const endBuffer = new ArrayBuffer(3 + 4);
const endView = new DataView(endBuffer);
endView.setUint8(0, FRAME.FILE_END);
endView.setUint16(1, 4, true);
endView.setUint32(3, currentCrc, true);
await sendFrame(endBuffer);
// Auf Erfolgsmeldung vom Dateisystem warten
await new Promise<void>((resolve, reject) => {
const finalTimeout = setTimeout(() => {
uploadState.onSuccess = null;
uploadState.onError = null;
reject(new Error("Timeout: Keine Bestätigung (SUCCESS) vom Buzzer erhalten."));
}, 5000); // 5 Sekunden warten auf das Dateisystem
uploadState.onSuccess = () => {
clearTimeout(finalTimeout);
resolve();
};
uploadState.onError = (err) => {
clearTimeout(finalTimeout);
reject(err);
};
});
} finally {
// Cleanup
isFileTransferring = false;
uploadState.active = false;
uploadState.onCreditsAdded = null;
uploadState.onSuccess = null;
uploadState.onError = null;
}
}
export async function putTags(tagsBlob: Blob, remotePath: string): Promise<void> {
if (isFileTransferring) {
throw new Error("Ein Dateitransfer läuft bereits.");
}
isFileTransferring = true;
uploadState.active = true;
uploadState.credits = 0;
uploadState.onCreditsAdded = null;
uploadState.onSuccess = null;
uploadState.onError = null;
try {
const pathBytes = new TextEncoder().encode(remotePath);
const reqBuffer = new ArrayBuffer(4 + 1 + 4 + pathBytes.length);
const reqView = new DataView(reqBuffer);
reqView.setUint8(0, FRAME.REQUEST);
reqView.setUint16(1, 1 + 4 + pathBytes.length, true);
reqView.setUint8(3, DATA.TAGS_PUT);
reqView.setUint32(4, tagsBlob.size, true);
new Uint8Array(reqBuffer).set(pathBytes, 8);
await sendFrame(reqBuffer);
const maxChunkSize = get(protocolInfo)?.maxChunkSize || 240;
const tagsData = new Uint8Array(await tagsBlob.arrayBuffer());
let offset = 0;
let currentCrc = 0;
while (offset < tagsData.length) {
if (uploadState.credits <= 0) {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
uploadState.onCreditsAdded = null;
reject(new Error("Timeout beim Senden der Tags."));
}, 5000);
uploadState.onCreditsAdded = () => {
if (uploadState.credits > 0) {
clearTimeout(timeout);
uploadState.onCreditsAdded = null;
resolve();
}
};
});
}
const chunkLen = Math.min(maxChunkSize, tagsData.length - offset);
const chunkData = tagsData.subarray(offset, offset + chunkLen);
currentCrc = crc32(chunkData, currentCrc);
const chunkBuffer = new ArrayBuffer(3 + chunkLen);
const chunkView = new DataView(chunkBuffer);
chunkView.setUint8(0, FRAME.FILE_CHUNK);
chunkView.setUint16(1, chunkLen, true);
new Uint8Array(chunkBuffer).set(chunkData, 3);
await sendFrame(chunkBuffer);
uploadState.credits--;
offset += chunkLen;
}
const endBuffer = new ArrayBuffer(3 + 4);
const endView = new DataView(endBuffer);
endView.setUint8(0, FRAME.FILE_END);
endView.setUint16(1, 4, true);
endView.setUint32(3, currentCrc, true);
await sendFrame(endBuffer);
await new Promise<void>((resolve, reject) => {
const finalTimeout = setTimeout(() => {
uploadState.onSuccess = null;
uploadState.onError = null;
reject(new Error("Timeout: Keine Bestätigung (SUCCESS) vom Buzzer erhalten."));
}, 5000); // 5 Sekunden warten auf das Dateisystem
uploadState.onSuccess = () => {
clearTimeout(finalTimeout);
resolve();
};
uploadState.onError = (err) => {
clearTimeout(finalTimeout);
reject(err);
};
});
} finally {
isFileTransferring = false;
uploadState.active = false;
uploadState.onCreditsAdded = null;
uploadState.onSuccess = null;
uploadState.onError = null;
}
}

View File

@@ -63,6 +63,10 @@
@apply w-7 h-7 p-1;
}
.buzzer-card .list-menu-icon {
@apply w-8 h-8 p-2;
}
.card-header .btn {
@apply border rounded border-transparent hover:bg-slate-200 hover:border-border-card hover:shadow-sm;
}