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

@ -1,5 +1,6 @@
<!-- navbar select menus -->
<script lang="ts">import { logger } from '$lib/core/utils/logger';
<script lang="ts">
import { logger } from '$lib/core/utils/logger';
import favicon from '$lib/assets/favicon.svg';
import AppAccountSelect from '$lib/components/app-account-select.svelte';
@ -87,7 +88,7 @@
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
addNotification(
'ERR:Device is already in use by another program, please close the program and try again'
'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.'
);
}

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>

View file

@ -1,4 +1,5 @@
<script lang="ts">import { logger } from '$lib/core/utils/logger';
<script lang="ts">
import { logger } from '$lib/core/utils/logger';
import { auth } from '$lib/core/stores/auth';
import { addNotification } from '$lib/core/stores/noti';
@ -6,9 +7,20 @@
import * as Select from '$lib/components/ui/select/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
import { Upload, X, CheckCircle, AlertCircle, RefreshCw, Play, Video, MonitorPlay, Eye } from '@lucide/svelte/icons';
import {
Upload,
X,
CheckCircle,
AlertCircle,
RefreshCw,
Play,
Video,
MonitorPlay,
Eye
} from '@lucide/svelte/icons';
import * as adb from '$lib/core/adb/adb';
import { AdbInstance } from '../../../state.svelte';
import ScrcpyDialog from '$lib/components/scrcpy-dialog.svelte';
const UPLOAD_PROXY_ENDPOINT = '/api/adv-upload';
const LIST_PROXY_ENDPOINT = '/api/adv-list';
@ -78,11 +90,15 @@
let pushProgress = $state({ current: 0, total: 0, name: '', percent: 0 });
let connecting = $state(false);
let showScreenMirror = $state(false);
const anyBusy = $derived(
uploadingAll || pushingToMachine || slots.some((s) => s.status === 'uploading')
);
// How many of the 21 slot names currently have a video on the server.
const onServerCount = $derived(slots.filter((s) => serverFilesByName[s.name] !== undefined).length);
const onServerCount = $derived(
slots.filter((s) => serverFilesByName[s.name] !== undefined).length
);
// Left-accent colour that encodes a slot's state at a glance.
function slotAccent(s: Slot): string {
@ -382,7 +398,9 @@
await autoLoadAllVideos();
} catch (error) {
logger.error('[Adv] list server videos error:', error);
addNotification(`ERR:Load videos failed: ${error instanceof Error ? error.message : 'unknown'}`);
addNotification(
`ERR:Load videos failed: ${error instanceof Error ? error.message : 'unknown'}`
);
} finally {
loadingServerVideos = false;
}
@ -421,7 +439,9 @@
videoBlobUrls = { ...videoBlobUrls, [name]: URL.createObjectURL(blob) };
} catch (error) {
logger.error('[Adv] fetch server video error:', error);
addNotification(`ERR:Load "${name}" failed: ${error instanceof Error ? error.message : 'unknown'}`);
addNotification(
`ERR:Load "${name}" failed: ${error instanceof Error ? error.message : 'unknown'}`
);
} finally {
loadingVideoName = null;
}
@ -448,7 +468,9 @@
<div class="mx-auto flex max-w-6xl flex-col gap-4 px-8 py-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<div
class="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary"
>
<Video class="h-6 w-6" />
</div>
<div>
@ -459,7 +481,8 @@
<div class="flex flex-wrap items-center gap-2">
<Badge variant={isAdbConnected ? 'default' : 'secondary'} class="gap-1">
<span class="h-1.5 w-1.5 rounded-full {isAdbConnected ? 'bg-white' : 'bg-muted-foreground'}"
<span
class="h-1.5 w-1.5 rounded-full {isAdbConnected ? 'bg-white' : 'bg-muted-foreground'}"
></span>
{isAdbConnected ? 'Machine connected' : 'Machine offline'}
</Badge>
@ -490,6 +513,10 @@
{/if}
</Button>
<Button variant="outline" onclick={() => (showScreenMirror = true)}>
Show Android Screen
</Button>
<Select.Root type="single" bind:value={selectedCountry}>
<Select.Trigger class="h-9 w-40">
{COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Country'}
@ -546,7 +573,7 @@
</div>
{#if onServerCount > 0}
<Badge
class="gap-1 whitespace-nowrap border-green-500/40 bg-green-500/15 text-green-600 hover:bg-green-500/15 dark:text-green-400"
class="gap-1 border-green-500/40 bg-green-500/15 whitespace-nowrap text-green-600 hover:bg-green-500/15 dark:text-green-400"
>
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
{onServerCount} on server
@ -622,7 +649,9 @@
{slot.width ?? '?'}×{slot.height ?? '?'} · exp {MENU_SPEC.width}×{MENU_SPEC.height}
</Badge>
{/if}
<span class="text-[10px] text-muted-foreground">{formatBytes(slot.file.size)}</span>
<span class="text-[10px] text-muted-foreground"
>{formatBytes(slot.file.size)}</span
>
</div>
{#if slot.error}
<p
@ -809,4 +838,6 @@
</div>
</div>
{/if}
<ScrcpyDialog bind:open={showScreenMirror} />
</div>

View file

@ -32,7 +32,12 @@
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
import { afterNavigate, goto } from '$app/navigation';
import { env } from '$env/dynamic/public';
import { adbWriter, isAdbWriterAlive, wasAndroidSocketEverAlive, sendToAndroid } from '$lib/core/stores/adbWriter';
import {
adbWriter,
isAdbWriterAlive,
wasAndroidSocketEverAlive,
sendToAndroid
} from '$lib/core/stores/adbWriter';
import { adbReconnect } from '$lib/core/stores/adbReconnectStore';
import { AdbInstance } from '../../../state.svelte';
import {
@ -227,7 +232,14 @@
await loadBrewDataFromConnectedAdb();
}
} catch (e: any) {
addNotification(`ERROR:${e}`);
// addNotification(`ERROR:${e}`);
GlobalEventBus.emit('announce', {
title: 'Device is busy',
subtitle: 'Expect `adb` or some related program has been using the device.',
message: 'Help: kill the process & try again',
buttonText: 'Ok',
type: 'error'
});
}
}
@ -274,7 +286,7 @@
}
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
addNotification(
'ERR:Device is already in use by another program, please close the program and try again'
'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.'
);
}