feat: scrcpy

- add: scrcpy for previewing android screen.
- change: notification of android connection fail to dialog.
- wip: firmware packing (on file listing issue)
- add: reject button on announcement dialog with callback.

Signed-off-by: pakintada@gmail.com <Pakin>
This commit is contained in:
pakintada@gmail.com 2026-07-16 14:52:54 +07:00
parent e2aa2848e8
commit 1156b5e6c3
17 changed files with 2080 additions and 57 deletions

View file

@ -0,0 +1,450 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
import { logger } from '$lib/core/utils/logger';
import { addNotification } from '$lib/core/stores/noti';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { Progress } from '$lib/components/ui/progress/index.js';
import {
firmwareStore,
FIRMWARE_COUNTRIES,
formatFileSize,
formatBuildDate,
statusBadgeVariant,
statusLabel,
type FirmwareFile,
type FirmwareBuildStatus
} from '$lib/core/stores/firmwareStore';
import {
ArchiveIcon,
Download,
Plus,
RefreshCw,
HardDrive,
LoaderCircle,
CheckCircle2,
AlertCircle,
Clock
} from '@lucide/svelte/icons';
import { auth as authStore } from '$lib/core/stores/auth';
import { sendMessage } from '$lib/core/handlers/ws_messageSender';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import { GlobalEventBus } from '$lib/core/utils/eventBus';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let selectedCountry = $state<string>(FIRMWARE_COUNTRIES[0].code);
let buildDialogOpen = $state(false);
let buildVersion = $state('');
let buildNotes = $state('');
let isRequesting = $state(false);
// All countries state from the store
let allCountries = $derived($firmwareStore);
// Currently selected country state
let currentCountryState = $derived(allCountries[selectedCountry] ?? null);
// Files for the current country
let currentFiles = $derived(currentCountryState?.files ?? []);
// Is a build currently in progress for this country?
let isBuildInProgress = $derived(
currentCountryState?.buildStatus === 'queued' || currentCountryState?.buildStatus === 'building'
);
// Options per country
let increaseVersion: boolean = $state(false);
let saveThisVersionBuildDate: boolean = $state(false);
let saveThisVersionAsLatest: boolean = $state(false);
// Subscriptions
let unsubscribe_fw_version: any;
// Latest Versions, allow refresh by re-entering page or from refresh
let latest_versions: {
[key: string]: {
version: string;
date: string;
};
} = $state({});
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
function openBuildDialog() {
// Pre-fill version with a suggestion
buildVersion = `v1.${new Date().getMonth() + 1}.${new Date().getDate()}`;
buildNotes = '';
buildDialogOpen = true;
}
async function confirmRequestBuild() {
if (!buildVersion.trim()) {
addNotification('ERR:Version is required');
return;
}
let requester = get(authStore)?.uid ?? '';
isRequesting = true;
try {
firmwareStore.requestBuild({
country: selectedCountry,
version: buildVersion.trim(),
notes: buildNotes.trim() || undefined,
requester
});
addNotification(`INFO:Firmware build requested for ${selectedCountry.toUpperCase()}`);
buildDialogOpen = false;
} catch (e) {
addNotification('ERR:Failed to request firmware build');
logger.error('firmware build request failed', e);
} finally {
isRequesting = false;
}
}
function handleDownload(file: FirmwareFile) {
if (file.status !== 'ready' || !file.downloadUrl) {
addNotification('ERR:Firmware file is not ready for download');
return;
}
// In production, this would be a real URL or trigger a WS download.
// For now, just notify.
addNotification(`INFO:Downloading ${file.filename}…`);
logger.info('download firmware', file.filename, formatFileSize(file.size));
}
function handleRefresh() {
firmwareStore.refreshFiles(selectedCountry);
addNotification('INFO:Refreshed firmware list');
}
function statusIcon(status: FirmwareBuildStatus) {
switch (status) {
case 'ready':
return CheckCircle2;
case 'building':
return LoaderCircle;
case 'queued':
return Clock;
case 'failed':
return AlertCircle;
default:
return HardDrive;
}
}
onMount(() => {
logger.info('firmware page mounted');
unsubscribe_fw_version = GlobalEventBus.on('firmware-versions', (data: any) => {
latest_versions = data;
logger.info(`firmware versions: ${JSON.stringify(latest_versions)}`);
});
sendMessage({
type: 'firmware-versions',
payload: {}
});
});
onDestroy(() => {
unsubscribe_fw_version?.();
});
</script>
<div class="mx-8 flex flex-col gap-6 p-4">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<ArchiveIcon class="h-8 w-8 text-muted-foreground" />
<div>
<h1 class="text-3xl font-bold">Firmware Requests</h1>
<p class="text-sm text-muted-foreground">
Request and download firmware builds per country
</p>
</div>
</div>
<div class="flex gap-2">
<Button variant="outline" onclick={handleRefresh} disabled={isBuildInProgress}>
<RefreshCw class="mr-2 h-4 w-4" />
Refresh
</Button>
<Button onclick={openBuildDialog} disabled={isBuildInProgress}>
<Plus class="mr-2 h-4 w-4" />
Request Build
</Button>
</div>
</div>
<!-- Country Tabs + Content -->
<Tabs.Root bind:value={selectedCountry}>
<Tabs.List class="flex w-full flex-wrap gap-1">
{#each FIRMWARE_COUNTRIES as country (country.code)}
<Tabs.Trigger value={country.code} class="flex-1">
{country.label}
{#if allCountries[country.code]?.buildStatus === 'building'}
<LoaderCircle class="ml-1 h-3 w-3 animate-spin" />
{:else if allCountries[country.code]?.buildStatus === 'ready'}
<CheckCircle2 class="ml-1 h-3 w-3 text-emerald-500" />
{/if}
</Tabs.Trigger>
{/each}
</Tabs.List>
{#each FIRMWARE_COUNTRIES as country (country.code)}
<Tabs.Content value={country.code}>
{@render countryPanel(country.code)}
</Tabs.Content>
{/each}
</Tabs.Root>
</div>
<!-- Country Panel Snippet -->
{#snippet countryPanel(countryCode: string)}
{@const state = allCountries[countryCode]}
{@const files = state?.files ?? []}
{@const buildInProgress = state?.buildStatus === 'queued' || state?.buildStatus === 'building'}
<div class="flex flex-col gap-4">
<!-- Build Status Card -->
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title class="flex items-center gap-2">
{#if buildInProgress}
<LoaderCircle class="h-5 w-5 animate-spin text-blue-500" />
{:else if state?.buildStatus === 'ready'}
<CheckCircle2 class="h-5 w-5 text-emerald-500" />
{:else if state?.buildStatus === 'failed'}
<AlertCircle class="h-5 w-5 text-red-500" />
{:else}
<HardDrive class="h-5 w-5 text-muted-foreground" />
{/if}
{state?.label ?? countryCode}
</Card.Title>
<Card.Description>Firmware build status and available files</Card.Description>
</div>
<Badge variant={statusBadgeVariant(state?.buildStatus ?? 'idle')}>
{statusLabel(state?.buildStatus ?? 'idle')}
</Badge>
</div>
</Card.Header>
<Card.Content>
{#if buildInProgress}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Building firmware…</span>
<span class="font-mono text-xs text-muted-foreground"
>~{state?.files[0]?.version ?? ''}</span
>
</div>
<Progress value={50} class="h-2" />
</div>
{:else if files.length === 0}
<div class="flex flex-col items-center justify-center gap-3 py-12 text-center">
<ArchiveIcon class="h-12 w-12 text-muted-foreground/40" />
<div>
<p class="text-sm font-medium">No firmware builds yet</p>
<p class="text-xs text-muted-foreground">
Click "Request Build" to create a new firmware for {state?.label}
</p>
</div>
</div>
{:else}
<!-- File Table -->
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[40%]">Filename</Table.Head>
<Table.Head class="w-[15%]">Version</Table.Head>
<Table.Head class="w-[15%]">Size</Table.Head>
<Table.Head class="w-[15%]">Built</Table.Head>
<Table.Head class="w-[10%]">Status</Table.Head>
<Table.Head class="w-[5%] text-right">Download</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each files as file (file.id)}
{@const StatusIcon = statusIcon(file.status)}
<Table.Row>
<Table.Cell class="font-mono text-sm">
{file.filename}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground">
{file.version}
</Table.Cell>
<Table.Cell class="font-mono text-sm">
{formatFileSize(file.size)}
</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">
{formatBuildDate(file.builtAt)}
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-1.5">
<StatusIcon
class="h-3.5 w-3.5 {file.status === 'building'
? 'animate-spin text-blue-500'
: file.status === 'ready'
? 'text-emerald-500'
: file.status === 'failed'
? 'text-red-500'
: 'text-muted-foreground'}"
/>
<span class="text-xs">{statusLabel(file.status)}</span>
</div>
</Table.Cell>
<Table.Cell class="text-right">
{#if file.status === 'ready' && file.downloadUrl}
<Button
variant="ghost"
size="icon"
onclick={() => handleDownload(file)}
title="Download"
>
<Download class="h-4 w-4" />
</Button>
{:else if file.status === 'building'}
<Spinner class="scale-75" />
{:else}
<span class="text-xs text-muted-foreground/50"></span>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
</div>
{/snippet}
<!-- Request Build Dialog -->
<Dialog.Root bind:open={buildDialogOpen}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Request Firmware Build</Dialog.Title>
<Dialog.Description>
Request a new firmware build for
<span class="font-semibold">{allCountries[selectedCountry]?.label ?? selectedCountry}</span
>. You will be notified when the build is ready or need confirmation.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="fw-version">Latest Firmware Version</Label>
<Input
id="fw-version"
bind:value={latest_versions[selectedCountry].version}
placeholder="10.00"
class="font-mono"
disabled={true}
/>
{#if latest_versions[selectedCountry].version == 'NO_DATA'}
<p class="font-sans text-[12px]">
Note: this will use latest version provided from source.
</p>
{/if}
</div>
<div class="grid gap-2">
<Label for="fw-date">Latest Firmware Release Date</Label>
<Input
id="fw-date"
bind:value={latest_versions[selectedCountry].date}
placeholder="..."
class="font-mono"
disabled={true}
/>
{#if latest_versions[selectedCountry].date == 'NO_DATA'}
<p class="font-sans text-[12px]">Note: this will forcefully save today as build date.</p>
{/if}
</div>
<!-- -->
<div class="grid gap-2">
<!-- <Label for="fw-version">Firmware Version</Label>
<Input id="fw-version" bind:value={buildVersion} placeholder="v1.2.3" class="font-mono" /> -->
<Label>
<Checkbox
id="ch-set-new-version"
checked={increaseVersion}
onchange={(e) => {
const checkbox = e.target as HTMLInputElement | null;
if (checkbox) {
increaseVersion = checkbox.checked;
}
}}
/>
<span>Set to new version (Optional)</span>
</Label>
</div>
<div class="grid gap-2">
<!-- <Label for="fw-notes">Notes (optional)</Label>
<Input id="fw-notes" bind:value={buildNotes} placeholder="Bug fixes, new features, etc." /> -->
<Label>
<Checkbox
id="ch-save-new-version-bdate"
checked={saveThisVersionBuildDate}
onchange={(e) => {
const checkbox = e.target as HTMLInputElement | null;
if (checkbox) {
saveThisVersionBuildDate = checkbox.checked;
}
}}
/>
<span>Save this version's build date (Optional)</span>
</Label>
</div>
<div class="grid gap-2">
<!-- <Label for="fw-notes">Notes (optional)</Label>
<Input id="fw-notes" bind:value={buildNotes} placeholder="Bug fixes, new features, etc." /> -->
<Label>
<Checkbox
id="ch-save-new-version"
checked={saveThisVersionAsLatest}
onchange={(e) => {
const checkbox = e.target as HTMLInputElement | null;
if (checkbox) {
saveThisVersionAsLatest = checkbox.checked;
}
}}
/>
<span>Save this version as latest (Optional)</span>
</Label>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (buildDialogOpen = false)}>Cancel</Button>
<Button onclick={confirmRequestBuild} disabled={isRequesting || !buildVersion.trim()}>
{#if isRequesting}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
Requesting…
{:else}
Request Build
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>