guter zwischenstand

This commit is contained in:
2026-03-14 16:23:35 +01:00
parent 5bb0d345da
commit 1a4a22eafd
28 changed files with 1486 additions and 1231 deletions

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import Header from "./components/Header.svelte";
</script>
<div class="flex flex-col min-h-screen">
<Header />
<main class="main-layout flex-grow">
<section class="buzzer-card p-6 h-64">
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Dateiverarbeitung</h3>
</section>
<section class="buzzer-card p-6 h-64">
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Buzzer Status</h3>
</section>
<section class="buzzer-card p-6 h-96 lg:col-span-1">
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Lokale Sounds</h3>
</section>
<section class="buzzer-card p-6 h-96 lg:col-span-1">
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Buzzer Sounds</h3>
</section>
</main>
<footer class="hidden sm:flex justify-center items-center py-4 bg-slate-50 border-t border-slate-200 text-[10px] text-slate-400 uppercase tracking-widest">
&copy; 2026 Edis Buzzer Management Studio | Nerd Mode Active
</footer>
</div>

View File

@@ -1,61 +1,48 @@
<script lang="ts">
import { onMount } from "svelte";
import { isInitializing, isBluetoothSupported, isSerialSupported } from "../lib/store";
import { performHardwareCheck, getBrowserName } from "../lib/init";
import { isInitializing, isBluetoothSupported } from "../lib/store";
import { performHardwareCheck } from "../lib/init";
import ToastContainer from "./ToastContainer.svelte";
import { injectDummyDevices } from "../lib/store";
let browserName = "";
onMount(() => {
browserName = getBrowserName();
onMount(async () => {
performHardwareCheck();
injectDummyDevices(); // Fügt Dummy-Geräte für Testzwecke hinzu
if ($isBluetoothSupported) {
const { restoreSession } = await import("../lib/bluetooth");
await restoreSession();
}
});
</script>
{#if $isInitializing}
<div class="fixed inset-0 bg-slate-50 flex items-center justify-center z-[100]">
<p class="text-slate-600 font-mono animate-pulse">SYSTEM_CHECK_RUNNING...</p>
<div class="fixed inset-0 bg-surface flex items-center justify-center z-[100]">
<p class="text-on-surface font-mono animate-pulse text-base md:text-lg text-center">
Browserkompatibilität wird geprüft...
</p>
</div>
{:else if !$isBluetoothSupported && !$isSerialSupported}
<div class="min-h-[60vh] flex items-center justify-center p-4">
<div class="max-w-md w-full bg-white border-2 border-red-600 shadow-2xl rounded-sm p-8 pb-4">
<h1 class="text-2xl font-black text-red-600 mb-4 uppercase italic">Inkompatibler Browser</h1>
{:else if !$isBluetoothSupported}
<div
class="fixed lg:h-screen inset-0 flex flex-col items-center justify-center p-0 lg:p-4 z-[100] bg-white lg:bg-transparent" style="hyphens:auto;">
<div
class="w-full h-full lg:h-auto lg:max-w-md bg-red-50 lg:border border-red-600 lg:shadow-xl lg:rounded-lg p-6 lg:p-8 text-red-600 flex flex-col justify-center"
>
<h1 class="text-2xl font-bold mb-2 text-center">Dein Browser ist... suboptimal</h1>
<div class="text-center text-7xl md:text-9xl font-bold mb-4">🥺</div>
<p class="text-slate-800 mb-6">
Du nutzt aktuell <strong>{browserName}</strong>
. Dieser Browser unterstützt weder Bluetooth noch serielle USB-Verbindungen.
</p>
<div class="space-y-2 mb-4 text-sm font-mono">
<div class="flex justify-between border-b border-slate-100 pb-1">
<span>Web Bluetooth:</span>
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span>
</div>
<div class="flex justify-between border-b border-slate-100 pb-1">
<span>Web Serial:</span>
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span>
</div>
<div class="space-y-4 text-base md:text-lg text-center md:text-left">
<p>
Leider unterstützt dein Browser die benötigten Bluetooth-Funktionen nicht. Bitte versuche
es mit einem aktuellen <span class="font-semibold">Chrome</span>
oder einem andern Chromium-basierten Browser.
<span class="font-semibold">Winzigweich Kante</span> soll gerüchteweise auch Chromium-basiert sein...
</p>
<p>
Rundreise auf iOS unterstützt Bluetooth leider nicht, aber du kannst es mit einem vernünftigen Gerät oder Browser versuchen.
</p>
</div>
<p class="text-xs text-slate-500 mb-6 italic">
(Info: Firefox und Safari blockieren diese Hardware-Schnittstellen aus Prinzip.)
</p>
<a
href="https://www.google.com/chrome/"
class="block w-full text-center bg-blue-600 text-white py-3 font-bold hover:bg-blue-700 transition uppercase tracking-widest text-sm mb-4"
>
Googles Glanzeisen installieren
</a>
<p class="text-xs text-slate-500 mb-6 italic">
Gerüchten zufolge soll <b>Winzigweichs Kante</b>
-Browser diese Technologien auch unterstützen. Aber wer nutzt schon diese Weichware?
</p>
</div>
</div>
{:else}
<ToastContainer client:load />
<ToastContainer />
<slot />
{/if}

View File

@@ -9,9 +9,11 @@
fsInfo,
loadConnectionState,
availableDevices,
transferStats,
resetTransferStats,
} from "../lib/store";
import { refreshRemote } from "../lib/sync";
import { fetchFileThroughputTest } from "../lib/transport";
import { getFile } from "../lib/transport";
onMount(() => {
restoreSession();
@@ -127,11 +129,40 @@
{/if}
</div>
<button
class="mt-4 w-full text-left text-xs text-slate-500 hover:text-slate-700 transition"
on:click={() => {
fetchFileThroughputTest("/lfs/a/countdown");
class="mt-4 w-full text-left text-xs text-slate-500 hover:text-slate-700 transition-all"
on:click={async () => {
// 1. Alles auf Null setzen
resetTransferStats();
// 2. Gesamtgröße für beide Dateien zusammen setzen (z. B. 320000 + 224800)
const sizeFile1 = 320000;
const sizeFile2 = 224800;
transferStats.update((s) => ({
...s,
overallTotal: sizeFile1 + sizeFile2,
currentFileName: "countdown",
}));
try {
// 3. Erste Datei laden und auf Abschluss warten
const success1 = await getFile("/lfs/a/countdown");
if (success1) {
// 4. Name für die zweite Datei aktualisieren
transferStats.update((s) => ({ ...s, currentFileName: "404" }));
// 5. Zweite Datei laden und auf Abschluss warten
await getFile("/lfs/a/404");
transferStats.update((s) => ({ ...s, overallDone: s.overallTotal }));
}
} catch (err) {
console.error("Fehler beim Test-Transfer:", err);
} finally {
await new Promise(r => setTimeout(r, 2000))
resetTransferStats();
}
}}
>
Durchsatztest mit /lfs/a/countdown
Durchsatztest (Mehrere Dateien)
</button>
</div>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import FlashUsage from "./FlashUsage.svelte"
import { BatteryEmptyIcon, BatteryLowIcon, BatteryMediumIcon, BatteryHighIcon, BatteryFullIcon, BatteryChargingIcon } from "phosphor-svelte"
</script>
<div class="text-sm">
<table>
<tbody>
<tr>
<td class="key">
Modell
</td>
<td class="value">
nrf52840dk-prototyp
</td>
</tr>
<tr>
<td class="key">
Version
</td>
<td class="value">
2.3.22-debug
</td>
</tr>
<tr>
<td class="key">
HW-ID
</td>
<td class="value">
<span class="font-mono">DEAD-BEAF-0102-3456</span>
</td>
</tr>
<tr>
<td class="key">
Batterie
</td>
<td class="value flex items-center gap-2">
85% <BatteryChargingIcon weight="bold" class="w-5 h-5"/> 1200mAh
</td>
</tr>
<tr>
<td class="key">
Speicher
</td>
<td class="value">
<div class="py-1">
<FlashUsage/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<style>
@reference "../styles/app.css";
table {
@apply w-full text-left;
}
tr {
@apply even:bg-slate-100 border-b border-slate-200 last:border-0;
}
.key {
@apply p-1 pl-4 text-right text-text-muted;
}
.value {
@apply p-1 pr-4 font-semibold;
}

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import FileListItem from "./FileListItem.svelte";
import { buzzerAudioFiles } from "../lib/store";
import type { BuzzerFile } from "../lib/types";
export let type: "local" | "buzzer" = "buzzer";
</script>
<div>
{#each $buzzerAudioFiles as file, index(index)}
<FileListItem bind:file={file}/>
{/each}
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import type { BuzzerFile } from "../lib/types";
import { FileAudioIcon } from "phosphor-svelte";
export let file: BuzzerFile;
</script>
<div>
<button
class="w-full text-left flex-1 px-3 py-1 flex items-center cursor-pointer border-l-4 transition-colors border-b border-b-border-card
{file.selected ? 'border-l-blue-600 bg-blue-50 hover:bg-blue-100' : 'border-l-transparent hover:bg-slate-100 hover:border-l-blue-200'} "
on:click={() => {file.selected = !file.selected;}}
>
<FileAudioIcon class="text-blue-600 mr-3 w-5 h-5" />
<div class="flex flex-col">
<span class="font-light">
{file.name || "Unbekannte Datei"}
{#if file.metaTags?.t}
&thinsp;-&thinsp;
<span class="font-medium">{file.metaTags.t}</span>
{/if}
</span>
<div class="text-xs">
<span class="font-light text-text-muted text-xs">
{parseFloat((file.size/1024).toFixed(1))}&thinsp;kB
</span>
<span>
{#if file.metaTags.a}<span class="text-text-muted"> | Author:</span>&thinsp;{file.metaTags.a}{/if}
</span>
</div>
</div>
</button>
</div>
<style>
</style>

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import { storageUsage } from "../lib/store";
</script>
<div class="mt-4 w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner">
<div
class="h-full bg-slate-400 transition-all duration-500"
style="width: {$storageUsage?.systemPercent ?? 0}%"
></div>
<div class="mt-1 text-xs text-slate-400 flex justify-between uppercase tracking-tighter font-medium">
{#if $storageUsage}
<div class="flex gap-4">
<div>
<span class="font-semibold text-slate-400">
Rate: {($storageUsage.systemBytes / 1048576).toFixed(2)} MB
</span>
</div>
<div>
<span class="font-semibold text-slate-400">
{($storageUsage.freeBytes / 1048576).toFixed(2)} Sekunden
</span>
</div>
{:else}
<div class="text-slate-400">Kein Transfer aktiv</div>
{/if}
</div>

View File

@@ -2,39 +2,37 @@
import { storageUsage } from "../lib/store";
</script>
<div class="mt-4 w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner">
<div class="w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner shadow-sm">
<div
class="h-full bg-slate-400 transition-all duration-500"
class="h-full bg-gradient-to-b from-slate-300 to-slate-400 transition-all duration-500"
style="width: {$storageUsage?.systemPercent ?? 0}%"
></div>
<div
class="h-full bg-indigo-500 transition-all duration-500"
class="h-full bg-gradient-to-b from-indigo-300 to-indigo-500 transition-all duration-500"
style="width: {$storageUsage?.audioPercent ?? 0}%"
></div>
<div
class="h-full bg-emerald-500 transition-all duration-500"
class="h-full bg-gradient-to-b from-emerald-300 to-emerald-500 transition-all duration-500"
style="width: {$storageUsage?.freePercent ?? 0}%"
></div>
</div>
<div class="mt-1 text-xs text-slate-400 flex justify-between uppercase tracking-tighter font-medium">
<div class="text-xs text-slate-400 flex justify-between">
{#if $storageUsage}
<div class="flex gap-4">
<div>
<span class="font-semibold text-slate-400">System:
{($storageUsage.systemBytes / 1048576).toFixed(2)} MB</span>
</div>
<div>
{($storageUsage.systemBytes / 1048576).toFixed(2)}&thinsp;MB</span>
<span class="font-semibold text-indigo-500">Audio:
{($storageUsage.audioBytes / 1048576).toFixed(2)} MB</span>
{($storageUsage.audioBytes / 1048576).toFixed(2)}&thinsp;MB</span>
</div>
</div>
<div>
<span class="font-semibold text-emerald-500">Frei:
{($storageUsage.freeBytes / 1048576).toFixed(2)} MB</span>
{($storageUsage.freeBytes / 1048576).toFixed(2)}&thinsp;MB</span>
</div>
{:else}
<div class="text-slate-400">Speicherdaten nicht verfügbar</div>

View File

@@ -0,0 +1,205 @@
<script lang="ts">
import HeaderDeviceListItem from "./HeaderDeviceListItem.svelte";
import {
PlugsIcon,
PlugsConnectedIcon,
BluetoothIcon,
CaretDownIcon,
LinkIcon,
UsbIcon,
SquareIcon,
CheckSquareIcon,
} from "phosphor-svelte";
import { slide } from "svelte/transition";
import {
isConnected, isConnecting, availableDevices, pairedDevices,
activeDeviceId, autoConnect, loadConnectionState, fsInfo
} from "../lib/store";
import { connectBuzzer, disconnectBuzzer, pairBuzzer, forgetDevice, getPairedDevices } from "../lib/bluetooth";
let showDropdown = false;
$: lastDeviceId = loadConnectionState()?.deviceId;
$: targetDevice = $pairedDevices.find(d => d.id === lastDeviceId);
$: canQuickConnect = targetDevice ? $availableDevices.has(targetDevice.id) : false;
$: autoConnectIcon = $autoConnect ? CheckSquareIcon : SquareIcon;
async function handleMainAction() {
showDropdown = false;
if ($isConnected) {
disconnectBuzzer();
} else if (canQuickConnect && targetDevice) {
await connectBuzzer(targetDevice);
} else {
await pairBuzzer();
}
}
function clickOutside(node: HTMLElement) {
const handleClick = (event: MouseEvent) => {
if (!node.contains(event.target as Node) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent("outclick"));
}
};
document.addEventListener("click", handleClick, true);
return {
destroy() {
document.removeEventListener("click", handleClick, true);
},
};
}
</script>
<header
class="fixed top-0 left-0 w-full h-16 bg-surface-card border-b border-border-card z-50 flex items-center justify-between px-4 lg:px-8 bg-gradient-to-b from-white to-slate-100"
>
<div class="flex items-center">
<span class="text-lg uppercase text-brand">
<span class="font-extrabold">Edis&nbsp;Buzzer</span>
&nbsp;
<span class="font-light">CONTROL</span>
</span>
</div>
<div class="relative" class:disabled={$isConnecting} use:clickOutside on:outclick={() => (showDropdown = false)}>
<div
class="btn-connect-group"
class:connected={$isConnected}
class:last-available={!$isConnected && canQuickConnect}
class:last-unavailable={!$isConnected && !canQuickConnect}
>
<button class="btn-main" on:click={handleMainAction} disabled={$isConnecting}>
{#if $isConnected}
<PlugsIcon weight="fill" class="w-4 h-4" />
<span class="hidden sm:inline">Trennen</span>
{:else if canQuickConnect}
<PlugsConnectedIcon weight="fill" class="w-4 h-4" />
<span class="hidden sm:inline">Verbinden</span>
{:else}
<LinkIcon weight="bold" class="w-4 h-4" />
<span class="hidden sm:inline">Neues Gerät</span>
{/if}
</button>
<button class="btn-dropdown" on:click={() => (showDropdown = !showDropdown)} disabled={$isConnecting}>
<CaretDownIcon
weight="bold"
class="w-4 h-4 transition-transform duration-300 {showDropdown ? '-scale-y-100' : ''}"
/>
</button>
</div>
{#if showDropdown}
<div
transition:slide={{ duration: 300 }}
class="absolute right-0 top-12 bg-surface-card shadow-xl rounded-lg border border-border-card p-0 z-50 overflow-hidden
w-max max-w-[calc(100vw-2rem)] sm:max-w-md min-w-[16rem]"
>
{#each $pairedDevices as dev (dev.id)}
<HeaderDeviceListItem
device={dev}
name={dev.name || ''}
isConnected={$activeDeviceId === dev.id}
isLastConnectedDevice={lastDeviceId === dev.id}
isAvailable={$availableDevices.has(dev.id)}
on:connect={(e) => { connectBuzzer(e.detail); showDropdown = false; }}
on:forget={(e) => forgetDevice(e.detail)}
/>
{/each}
{#if $pairedDevices.length === 0}
<div class="px-4 py-3 text-xs text-text-muted text-center italic border-b border-border-card">
Keine Geräte gekoppelt
</div>
{/if}
<div
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-connect relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
>
<button class="flex items-center w-full text-left" on:click={() => { pairBuzzer(); showDropdown = false; }}>
<LinkIcon weight="bold" class="mr-2 w-5 h-5" />
<div class="flex-1">
<div class="flex flex-col">
<span class="py-2">
Buzzer über Bluetooth <BluetoothIcon weight="bold" class="w-4 h-4 flex inline" /> verbinden
</span>
</div>
</div>
</button>
</div>
<div
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-connect relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
>
<button class="flex items-center w-full text-left">
<LinkIcon weight="bold" class="mr-2 w-5 h-5 opacity-50" />
<div class="flex-1 opacity-50">
<div class="flex flex-col">
<span class="py-2">
Buzzer über USB <UsbIcon weight="bold" class="w-4 h-4 flex inline" /> verbinden
</span>
</div>
</div>
</button>
</div>
<div
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-auto relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
>
<button class="flex items-center w-full text-left" on:click={() => $autoConnect = !$autoConnect}>
<svelte:component this={autoConnectIcon} class="mr-2 w-5 h-5" />
<div class="flex-1">
<div class="flex flex-col">
<span class="py-2">Automatisch verbinden</span>
</div>
</div>
</button>
</div>
</div>
{/if}
</div>
</header>
<style>
@reference "../styles/app.css";
.connected {
@apply border-border-card;
}
.connected .btn-main, .connected .btn-dropdown {
@apply hover:bg-surface-hover text-slate-700;
}
.last-available, .last-unavailable {
@apply border-transparent;
}
.last-available .btn-main, .last-available .btn-dropdown {
@apply bg-emerald-600 hover:bg-emerald-700 text-white;
}
.last-unavailable .btn-main, .last-unavailable .btn-dropdown {
@apply bg-indigo-600 hover:bg-indigo-700 text-white;
}
.btn-connect-group {
@apply flex items-stretch rounded-lg overflow-hidden shadow-sm border;
}
.btn-main {
@apply flex items-center gap-2 p-2 text-sm font-semibold transition-colors outline-none cursor-pointer disabled:opacity-50;
}
.btn-dropdown {
@apply flex items-center justify-center px-2 py-2 transition-colors outline-none cursor-pointer disabled:opacity-50;
}
.menu-connect {
@apply text-blue-700 hover:bg-surface-hover font-semibold border-b last:border-b-0 border-border-card cursor-pointer;
}
.menu-auto {
@apply hover:bg-surface-hover font-semibold border-b last:border-b-0 border-border-card cursor-pointer;
}
</style>

View File

@@ -0,0 +1,66 @@
<!-- HeaderDeviceListItem.svelte -->
<script lang="ts">
import { BluetoothIcon, BluetoothSlashIcon, LinkBreakIcon } from "phosphor-svelte";
import { createEventDispatcher } from "svelte";
export let device: any = null;
export let isAvailable: boolean = false;
export let isLastConnectedDevice: boolean = false;
export let isConnected: boolean = false;
export let name: string = "";
export let type: string = "ble";
let isHovered = false;
const dispatch = createEventDispatcher();
</script>
<div
class="flex-1 pr-3 pl-4 py-1 flex items-center relative
border-b border-border-card
before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1
{isAvailable || isConnected ? '' : 'text-text-muted cursor-not-allowed'}
{isConnected && isLastConnectedDevice ? 'bg-bg-selected ' : ''}
{isAvailable && !isConnected ? 'hover:bg-surface-hover' : ''}
{isLastConnectedDevice ? 'before:bg-border-selected' : ''}
"
>
<button
class="flex-1 flex items-center text-left min-2-0"
on:click={() => dispatch("connect", device)}
disabled={!isAvailable || isConnected}
>
{#if type === "ble"}
{#if isAvailable || isConnected}
<BluetoothIcon class="text-blue-600 mr-2 w-5 h-5" />
{:else}
<BluetoothSlashIcon class="mr-2 w-5 h-5" />
{/if}
{/if}
<div class="flex-1 min-w-0">
<div class="flex flex-col">
<span class="font-medium text-sm truncate">
{name || "Unbekanntes Gerät"}
</span>
{#if isConnected}
<span class="text-xs font-semibold">Verbunden</span>
{:else if isAvailable}
<span class="text-xs">In Reichweite</span>
{:else if !isAvailable}
<span class="text-xs">Nicht in Reichweite</span>
{/if}
</div>
</div>
</button>
<button
on:mouseenter={() => (isHovered = true)}
on:mouseleave={() => (isHovered = false)}
title="'{name}' entfernen"
class="flex-shrink-0"
>
<LinkBreakIcon
weight={isHovered ? "bold" : "regular"}
class="w-7 h-7 p-1 ml-3 rounded text-red-600 hover:bg-red-600 hover:text-white"
/>
</button>
</div>

View File

@@ -0,0 +1,301 @@
<script lang="ts">
import { isConnected, fsInfo } from "../lib/store";
import {
GearIcon,
CloudArrowUpIcon,
ListIcon,
ArrowClockwiseIcon,
DotsThreeVerticalIcon,
CheckSquareOffsetIcon,
SquareIcon,
DownloadIcon,
XIcon,
} from "phosphor-svelte";
import FileList from "./FileList.svelte";
import DeviceInfo from "./DeviceInfo.svelte";
import { refreshRemote, downloadSelectedFiles } from "../lib/sync";
import {
buzzerAudioFiles,
buzzerFilesCount,
selectedBuzzerFilesCount,
transferStats,
isFetchingRemote,
pairedDevices,
activeDeviceId,
transferDetails,
} from "../lib/store";
import { SETTINGS } from "../lib/settings";
import { fade } from "svelte/transition";
let showOverlay = false;
let isTransferFinished = false;
let overlayTimeout: ReturnType<typeof setTimeout>;
$: currentDevice = $pairedDevices.find((d) => d.id === $activeDeviceId);
$: if ($isConnected) {
refreshRemote();
}
$: if ($isFetchingRemote && $transferStats.overallTotal > 0) {
// Transfer startet oder läuft
showOverlay = true;
isTransferFinished = false;
clearTimeout(overlayTimeout);
} else if (showOverlay && !$isFetchingRemote && $transferStats.overallDone > 0) {
// Transfer wurde soeben abgeschlossen
isTransferFinished = true;
overlayTimeout = setTimeout(closeOverlay, SETTINGS.ui.transferOverlayPersistMs);
}
function closeOverlay() {
clearTimeout(overlayTimeout);
showOverlay = false;
isTransferFinished = false;
// Optional: resetTransferStats() aufrufen, um die Werte zu nullen
}
function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return "∞";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")} Min.`;
}
</script>
<div class="main-layout mt-16 lg:mt-20 pb-12">
<section class="buzzer-card flex flex-col">
<div class="card-header">
<h3 class="card-title">Dateiverarbeitung</h3>
<button class="btn" aria-label="Einstellungen">
<GearIcon class="icon" />
</button>
</div>
<div class="card-body p-4">
<div class="flex justify-center items-center">
<div class="text-center text-2xs tracking-tight font-mono font-semibold">
16kHz 16bit MONO | <span class="text-emerald-700">NORMALIZER ON</span>
|
<span class="text-red-700">COMPRESSOR OFF</span>
</div>
</div>
<div class="flex items-center justify-center">
<CloudArrowUpIcon class="w-24 h-24 text-slate-500" />
</div>
</div>
</section>
<section class="buzzer-card flex flex-col">
<div class="card-header">
<h3 class="card-title">
{#if $isConnected && currentDevice?.name}
Geräteinfos: <span class="font-normal">{currentDevice.name}</span>
{/if}
</h3>
<button class="btn" aria-label="Einstellungen" disabled={!$isConnected}>
<GearIcon class="icon" />
</button>
</div>
<div class="relative overflow-hidden h-full">
<div class="card-body transition-all duration-500 h-full" class:disconnected={!$isConnected}>
<DeviceInfo />
</div>
{#if showOverlay}
<div
class="absolute inset-0 z-10 bg-white/95 backdrop-blur-[2px] p-4 flex flex-col justify-end"
transition:fade={{ duration: 300 }}
>
{#if isTransferFinished}
<button
class="absolute top-2 right-2 p-1 text-slate-400 hover:text-slate-700 transition-colors"
on:click={closeOverlay}
aria-label="Overlay schließen"
>
<XIcon class="w-5 h-5" />
</button>
{/if}
<div class="w-full flex flex-col gap-1">
<div class="flex flex-col gap-1">
<div
class="relative w-full h-3 bg-slate-100 rounded-sm overflow-hidden shadow-inner shadow-sm"
>
<div
class="absolute top-0 left-0 h-full bg-gradient-to-b from-indigo-300 to-indigo-500"
style="width: {$transferDetails.filePercent}%; transition: {$transferDetails.filePercent ===
0
? 'none'
: `width ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
></div>
<div
class="absolute inset-0 flex items-center justify-center text-[10px] text-slate-500"
>
{$transferDetails.filePercent}%
</div>
<div
class="absolute inset-0 flex items-center justify-center text-[10px] text-white"
style="clip-path: inset(0 {100 -
$transferDetails.filePercent}% 0 0); transition: {$transferDetails.filePercent ===
0
? 'none'
: `clip-path ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
>
{$transferDetails.filePercent}%
</div>
</div>
<div class="flex justify-between text-[10px] px-1">
<span class="truncate max-w-[60%]">
{$transferStats.currentFileName || "Lade..."}
</span>
<span>{formatTime($transferDetails.fileEta)}</span>
</div>
</div>
<div class="flex flex-col gap-1">
<div
class="relative w-full h-3 bg-slate-100 rounded-sm overflow-hidden shadow-inner shadow-sm"
>
<div
class="absolute top-0 left-0 h-full bg-gradient-to-b from-indigo-300 to-indigo-500"
style="width: {$transferDetails.totalPercent}%; transition: {$transferDetails.totalPercent ===
0
? 'none'
: `width ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
></div>
<div
class="absolute inset-0 flex items-center justify-center text-[10px] text-slate-500"
>
{$transferDetails.totalPercent}%
</div>
<div
class="absolute inset-0 flex items-center justify-center text-[10px] text-white"
style="clip-path: inset(0 {100 -
$transferDetails.totalPercent}% 0 0); transition: {$transferDetails.totalPercent ===
0
? 'none'
: `clip-path ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
>
{$transferDetails.totalPercent}%
</div>
</div>
<div class="flex justify-between text-[10px] px-1">
<span>{$transferDetails.speedKbs} kB/s</span>
<span>{formatTime($transferDetails.totalEta)}</span>
</div>
</div>
<div class="flex justify-center mt-1">
<button
class="px-4 py-1.5 text-xs font-semibold text-red-600 bg-red-50 hover:bg-red-100 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={true}
>
Transfer abbrechen
</button>
</div>
</div>
</div>
{/if}
</div>
</section>
<section class="buzzer-card">
<div class="card-header">
<h3 class="card-title">Lokale Bibliothek</h3>
<button class="btn" aria-label="Menu">
<ListIcon class="icon" />
</button>
</div>
<div class="card-body">
<div class="py-10 text-center">
<span class="text-slate-300 uppercase italic tracking-widest">Bibliothek leer</span>
</div>
</div>
</section>
<section class="buzzer-card relative">
<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">
<DotsThreeVerticalIcon weight="bold" class="icon" />
</button>
</div>
</div>
</div>
<div class="card-body" class:disconnected={!$isConnected}>
<FileList type="buzzer" />
</div>
</section>
</div>
<style>
@reference "../styles/app.css";
.btn-connect-group {
@apply flex items-stretch rounded 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;
&: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

@@ -0,0 +1,61 @@
<script lang="ts">
import { transferDetails, transferStats } from "../lib/store";
import { fade, slide } from 'svelte/transition';
// Formatiert Sekunden zu "M:SS m" oder "S s" mit schmalem Leerzeichen
$: formatTime = (seconds: number): string => {
if (seconds === Infinity || seconds > 3600) return '∞';
const narrowNbsp = '\u202F';
if (seconds >= 60) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}${narrowNbsp}m`;
}
return `${Math.floor(seconds)}${narrowNbsp}s`;
};
</script>
<div class="mt-4 min-h-[100px]">
{#if $transferStats.bytesTotal > 0}
<div class="flex flex-col gap-3 mt-4 w-full" transition:slide={{ duration: 400 }}>
<div class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] tracking-tighter font-bold text-slate-500">
<span class="truncate">Datei: {$transferStats.currentFileName || 'Übertragung...'}</span>
<span>{formatTime($transferDetails.fileEta)}</span>
</div>
<div class="w-full h-2 bg-slate-100 rounded-full overflow-hidden shadow-inner">
<div
class="h-full bg-blue-500 transition-all duration-500 ease-out"
style="width: {$transferDetails.filePercent}%"
></div>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between text-[10px] tracking-tighter font-bold text-slate-500">
<span>Gesamtfortschritt</span>
<span>{formatTime($transferDetails.totalEta)}</span>
</div>
<div class="w-full h-3 bg-slate-100 rounded-full overflow-hidden shadow-inner">
<div
class="h-full bg-slate-400 transition-all duration-500 ease-out"
style="width: {$transferDetails.totalPercent}%"
></div>
</div>
</div>
<div class="flex justify-between text-[10px] tracking-tighter font-medium text-slate-400">
<span>Rate: {$transferDetails.speedKbs.toFixed(0)}kBps</span>
<span>{$transferDetails.totalPercent}% abgeschlossen</span>
</div>
</div>
{:else}
<div class="mt-4 text-xs text-slate-400 italic text-center tracking-widest" transition:slide={{ duration: 400 }}>
Kein Transfer aktiv
</div>
{/if}
</div>