File upload. Yeah
This commit is contained in:
@@ -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))} 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) {
|
||||
|
||||
138
webpage/src/components/FileListMenu.svelte
Normal file
138
webpage/src/components/FileListMenu.svelte
Normal 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>
|
||||
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
)}"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user