Merge remote-tracking branch 'origin/master' into dev
This commit is contained in:
commit
96999334f8
18 changed files with 2317 additions and 121 deletions
|
|
@ -1,24 +1,47 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
import { XIcon } from '@lucide/svelte/icons';
|
||||
import { XIcon, ChevronDown, ChevronRight, FileText } from '@lucide/svelte/icons';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
|
||||
interface HistoryItem {
|
||||
filename: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AnnouncementPayload {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
message: string;
|
||||
message: string | HistoryItem[];
|
||||
buttonText?: string;
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
callbackAccept?: () => void;
|
||||
callbackReject?: () => void;
|
||||
}
|
||||
|
||||
let visible = $state(false);
|
||||
let payload = $state<AnnouncementPayload | null>(null);
|
||||
let animating = $state(false);
|
||||
let expandedFilenames = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleItem(filename: string) {
|
||||
const next = new Set(expandedFilenames);
|
||||
if (next.has(filename)) {
|
||||
next.delete(filename);
|
||||
} else {
|
||||
next.add(filename);
|
||||
}
|
||||
expandedFilenames = next;
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
expandedFilenames = new Set();
|
||||
}
|
||||
|
||||
function show(p: AnnouncementPayload) {
|
||||
payload = p;
|
||||
expandedFilenames = new Set();
|
||||
visible = true;
|
||||
// Trigger enter animation on next frame
|
||||
requestAnimationFrame(() => {
|
||||
animating = true;
|
||||
});
|
||||
|
|
@ -26,17 +49,17 @@
|
|||
|
||||
function dismiss() {
|
||||
animating = false;
|
||||
// Wait for exit animation
|
||||
setTimeout(() => {
|
||||
visible = false;
|
||||
payload = null;
|
||||
expandedFilenames = new Set();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && visible) {
|
||||
dismiss();
|
||||
}
|
||||
function formattedItemCount(): string {
|
||||
if (!payload?.message || typeof payload.message === 'string') return '';
|
||||
const n = payload.message.length;
|
||||
return `${n} file${n !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
|
@ -45,12 +68,10 @@
|
|||
unsubscribe = GlobalEventBus.on('announce', (data: AnnouncementPayload) => {
|
||||
show(data);
|
||||
});
|
||||
// if (window) window.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe?.();
|
||||
// if (window) window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
const typeStyles: Record<string, { border: string; badge: string; icon: string }> = {
|
||||
|
|
@ -90,7 +111,6 @@
|
|||
aria-labelledby="announcement-title"
|
||||
tabindex="0"
|
||||
onclick={(e) => {
|
||||
// Close on backdrop click
|
||||
if (e.target === e.currentTarget) dismiss();
|
||||
}}
|
||||
>
|
||||
|
|
@ -103,20 +123,21 @@
|
|||
|
||||
<!-- Card -->
|
||||
<div
|
||||
class="relative w-full max-w-lg overflow-hidden rounded-2xl border bg-white shadow-2xl transition-all duration-200 dark:border-neutral-700 dark:bg-neutral-900"
|
||||
class="relative flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl transition-all duration-200 dark:border-neutral-700 dark:bg-neutral-900"
|
||||
class:opacity-100={animating}
|
||||
class:opacity-0={!animating}
|
||||
class:scale-100={animating}
|
||||
class:scale-95={!animating}
|
||||
>
|
||||
<!-- Colored top border accent -->
|
||||
<div class="h-1.5 w-full {currentStyles().border}"></div>
|
||||
<div class="h-1.5 w-full shrink-0 {currentStyles().border}"></div>
|
||||
|
||||
<div class="p-6 sm:p-8">
|
||||
<!-- Scrollable body -->
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto p-6 sm:p-8">
|
||||
<!-- Close button -->
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="absolute top-4 right-4 rounded-full p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
|
||||
class="absolute top-4 right-4 z-10 rounded-full p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
|
||||
aria-label="Close announcement"
|
||||
>
|
||||
<XIcon size={20} />
|
||||
|
|
@ -125,7 +146,7 @@
|
|||
<!-- Type badge -->
|
||||
{#if payload?.type}
|
||||
<span
|
||||
class="mb-4 inline-block rounded-full px-3 py-1 text-xs font-semibold tracking-wider uppercase {currentStyles()
|
||||
class="mb-4 inline-block shrink-0 rounded-full px-3 py-1 text-xs font-semibold tracking-wider uppercase {currentStyles()
|
||||
.badge}"
|
||||
>
|
||||
{payload.type}
|
||||
|
|
@ -136,7 +157,7 @@
|
|||
{#if payload?.title}
|
||||
<h2
|
||||
id="announcement-title"
|
||||
class="pr-8 text-2xl font-bold text-neutral-900 dark:text-white"
|
||||
class="shrink-0 pr-8 text-2xl font-bold text-neutral-900 dark:text-white"
|
||||
>
|
||||
{payload.title}
|
||||
</h2>
|
||||
|
|
@ -144,29 +165,88 @@
|
|||
|
||||
<!-- Subtitle -->
|
||||
{#if payload?.subtitle}
|
||||
<p class="mt-1 text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||
<p class="mt-1 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{payload.subtitle}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Message body -->
|
||||
{#if payload?.message}
|
||||
<div
|
||||
class="mt-4 text-base leading-relaxed whitespace-pre-wrap text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
{payload.message}
|
||||
</div>
|
||||
{@const isStructured = Array.isArray(payload.message)}
|
||||
|
||||
{#if isStructured}
|
||||
{@const items = payload.message as HistoryItem[]}
|
||||
<!-- File count + collapse-all -->
|
||||
<div class="mt-4 mb-2 flex items-center justify-between shrink-0">
|
||||
<span class="text-xs font-medium text-neutral-400 dark:text-neutral-500">
|
||||
{formattedItemCount()}
|
||||
</span>
|
||||
{#if expandedFilenames.size > 0}
|
||||
<button
|
||||
onclick={collapseAll}
|
||||
class="text-xs text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Collapse all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Scrollable item list -->
|
||||
<div class="flex-1 space-y-1 overflow-y-auto pr-1 min-h-0">
|
||||
{#each items as item (item.filename)}
|
||||
{@const expanded = expandedFilenames.has(item.filename)}
|
||||
<div
|
||||
class="rounded-lg border transition-colors dark:border-neutral-700 {expanded
|
||||
? 'border-neutral-300 dark:border-neutral-600'
|
||||
: 'hover:border-neutral-200 dark:hover:border-neutral-600'}"
|
||||
>
|
||||
<!-- Collapsed header — click to expand -->
|
||||
<button
|
||||
onclick={() => toggleItem(item.filename)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm font-medium text-neutral-800 hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 dark:text-neutral-200 dark:hover:bg-neutral-800/50"
|
||||
>
|
||||
{#if expanded}
|
||||
<ChevronDown class="h-4 w-4 shrink-0 text-neutral-400" />
|
||||
{:else}
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-neutral-400" />
|
||||
{/if}
|
||||
<FileText class="h-4 w-4 shrink-0 text-neutral-400" />
|
||||
<span class="truncate font-mono text-xs">{item.filename}</span>
|
||||
</button>
|
||||
|
||||
<!-- Expandable content -->
|
||||
{#if expanded}
|
||||
<div class="border-t border-neutral-100 px-3 py-3 dark:border-neutral-700">
|
||||
<pre
|
||||
class="max-h-48 overflow-y-auto whitespace-pre-wrap break-words rounded bg-neutral-50 p-3 text-xs leading-relaxed text-neutral-700 dark:bg-neutral-800/60 dark:text-neutral-300"
|
||||
>{item.message}</pre
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Plain string message -->
|
||||
<div
|
||||
class="mt-4 text-base leading-relaxed whitespace-pre-wrap text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
{payload.message}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Acknowledge / action button -->
|
||||
<!-- Action buttons -->
|
||||
{#if payload?.buttonText !== undefined}
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="inline-flex items-center justify-center rounded-lg bg-neutral-900 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-neutral-800 focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:outline-none dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
|
||||
>
|
||||
<div class="mt-6 flex shrink-0 justify-end gap-3">
|
||||
{#if payload?.callbackReject}
|
||||
<Button variant="reject" onclick={() => { payload!.callbackReject!(); dismiss(); }}>
|
||||
Reject
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="accept" onclick={() => { payload?.callbackAccept?.(); dismiss(); }}>
|
||||
{payload.buttonText || 'Acknowledge'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 * as Sidebar from '$lib/components/ui/sidebar/index';
|
||||
import { onDestroy, type ComponentProps } from 'svelte';
|
||||
|
|
@ -20,7 +21,8 @@
|
|||
Video,
|
||||
MonitorPlay,
|
||||
Sun,
|
||||
Moon
|
||||
Moon,
|
||||
ArchiveIcon
|
||||
} from '@lucide/svelte/icons';
|
||||
import TaobinLogo from '$lib/assets/logo.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -64,6 +66,12 @@
|
|||
icon: LayoutDashboard,
|
||||
requirePerm: ''
|
||||
}
|
||||
// {
|
||||
// title: 'Firmware Request',
|
||||
// url: '/firmware',
|
||||
// icon: ArchiveIcon,
|
||||
// requirePerm: ''
|
||||
// }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
|
||||
import { file } from 'zod/mini';
|
||||
import { addNotification } from '$lib/core/stores/noti';
|
||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||
|
||||
let { enableComponent = true } = $props();
|
||||
|
||||
|
|
@ -328,8 +329,15 @@
|
|||
|
||||
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.'
|
||||
);
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
|||
847
src/lib/components/scrcpy-dialog.svelte
Normal file
847
src/lib/components/scrcpy-dialog.svelte
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
<script lang="ts">
|
||||
import { AndroidMotionEventAction, AndroidKeyEventAction } from '@yume-chan/scrcpy';
|
||||
import type { ScrcpyControlMessageWriter, AndroidKeyCode } from '@yume-chan/scrcpy';
|
||||
import { ScrcpyVideoCodecNameMap } from '@yume-chan/scrcpy';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { scrcpyManager, type ScrcpyStatusStep } from '$lib/core/scrcpy/scrcpy-manager';
|
||||
import { logger } from '$lib/core/utils/logger';
|
||||
import { cn } from '$lib/utils';
|
||||
import { XIcon } from '@lucide/svelte/icons';
|
||||
|
||||
// ── Props & State ───────────────────────────────────────────────────────
|
||||
let { open = $bindable(false) }: { open: boolean } = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement | null = $state(null);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Connection step animation
|
||||
const STEPS: { key: ScrcpyStatusStep; label: string }[] = [
|
||||
{ key: 'fetching', label: 'Downloading scrcpy-server' },
|
||||
{ key: 'pushing', label: 'Pushing to device' },
|
||||
{ key: 'starting', label: 'Starting session' },
|
||||
{ key: 'ready', label: 'Connected' }
|
||||
];
|
||||
let currentStep = $state<ScrcpyStatusStep | ''>('');
|
||||
let stepDetail = $state('');
|
||||
let completedSteps = $state<Set<ScrcpyStatusStep>>(new Set());
|
||||
|
||||
// Derived step states — recomputes reactively when currentStep/completedSteps change
|
||||
let stepStates = $derived(
|
||||
STEPS.map((step) => ({
|
||||
...step,
|
||||
isCompleted: completedSteps.has(step.key),
|
||||
isActive: currentStep === step.key
|
||||
}))
|
||||
);
|
||||
|
||||
// Status log
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
time: string;
|
||||
type: 'info' | 'warn' | 'error' | 'ok';
|
||||
message: string;
|
||||
}
|
||||
let logEntries = $state<LogEntry[]>([]);
|
||||
let logId = 0;
|
||||
|
||||
function log(type: LogEntry['type'], message: string) {
|
||||
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
logEntries = [...logEntries, { id: ++logId, time, type, message }];
|
||||
// Keep last 80 entries
|
||||
if (logEntries.length > 80) logEntries = logEntries.slice(-80);
|
||||
logger.info(`[ScrcpyDialog] [${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
|
||||
// ── Decoder state ───────────────────────────────────────────────────────
|
||||
let decoder: VideoDecoder | null = null;
|
||||
let controlWriter: ScrcpyControlMessageWriter | null = null;
|
||||
let lastFrame: VideoFrame | null = null;
|
||||
let rendering = false;
|
||||
let rendererHandle = 0;
|
||||
let frameCount = 0;
|
||||
let closed = false;
|
||||
let stopping = false;
|
||||
let starting = false;
|
||||
let sessionStarted = false;
|
||||
|
||||
// Touch state
|
||||
let pointerId = -1;
|
||||
let pointerX = 0;
|
||||
let pointerY = 0;
|
||||
let videoWidth = 0;
|
||||
let videoHeight = 0;
|
||||
let activeKeyAction: 'down' | 'up' | null = null;
|
||||
let touchLocked = $state(true);
|
||||
|
||||
// Session metadata
|
||||
let sessionCodec: string = '';
|
||||
let sessionSize: string = '';
|
||||
|
||||
// ── H.264 NALU parsing ──────────────────────────────────────────────────
|
||||
/**
|
||||
* Parse NALUs from a byte buffer. Handles two formats:
|
||||
* 1. Annex B: NALUs separated by 00 00 00 01 or 00 00 01 start codes
|
||||
* 2. AVCC: 4-byte big-endian length prefix before each NALU
|
||||
*
|
||||
* Auto-detects format by checking if data starts with a start code.
|
||||
*/
|
||||
function parseNalus(data: Uint8Array): Uint8Array[] {
|
||||
// Detect Annex B: starts with 00 00 00 01 or 00 00 01
|
||||
const isAnnexB =
|
||||
(data.length >= 4 && data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 1) ||
|
||||
(data.length >= 3 && data[0] === 0 && data[1] === 0 && data[2] === 1);
|
||||
|
||||
if (isAnnexB) {
|
||||
return parseAnnexBNalus(data);
|
||||
}
|
||||
|
||||
// AVCC: 4-byte length-prefixed NALUs
|
||||
const nalus: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
while (offset + 4 <= data.length) {
|
||||
const naluLen = (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
|
||||
offset += 4;
|
||||
if (naluLen <= 0 || offset + naluLen > data.length) break;
|
||||
nalus.push(data.slice(offset, offset + naluLen));
|
||||
offset += naluLen;
|
||||
}
|
||||
if (nalus.length > 0) return nalus;
|
||||
|
||||
// Fallback: treat entire data as a single raw NALU (no start codes, no length prefix)
|
||||
return [data];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Annex B byte stream to extract NALUs (without start codes).
|
||||
* Each NALU's type is (firstByte & 0x1F): 7=SPS, 8=PPS.
|
||||
*/
|
||||
function parseAnnexBNalus(data: Uint8Array): Uint8Array[] {
|
||||
const nalus: Uint8Array[] = [];
|
||||
let i = 0;
|
||||
let naluStart = -1;
|
||||
|
||||
while (i < data.length) {
|
||||
// 4-byte start code: 00 00 00 01
|
||||
if (i + 3 < data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 0 && data[i + 3] === 1) {
|
||||
if (naluStart >= 0) nalus.push(data.slice(naluStart, i));
|
||||
naluStart = i + 4;
|
||||
i += 4;
|
||||
}
|
||||
// 3-byte start code: 00 00 01
|
||||
else if (i + 2 < data.length && data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 1) {
|
||||
if (naluStart >= 0) nalus.push(data.slice(naluStart, i));
|
||||
naluStart = i + 3;
|
||||
i += 3;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (naluStart >= 0 && naluStart < data.length) {
|
||||
nalus.push(data.slice(naluStart));
|
||||
}
|
||||
return nalus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AVDecoderConfigurationRecord (avcC box) from collected
|
||||
* SPS and PPS NALUs (raw NALU data including the header byte).
|
||||
*/
|
||||
function buildAvcCBox(spsNalu: Uint8Array, ppsNalu: Uint8Array): ArrayBuffer {
|
||||
const spsLen = spsNalu.length;
|
||||
const ppsLen = ppsNalu.length;
|
||||
// avcC header (6) + SPS count(1) + SPS length(2) + SPS data + PPS count(1) + PPS length(2) + PPS data
|
||||
const box = new Uint8Array(6 + 1 + 2 + spsLen + 1 + 2 + ppsLen);
|
||||
let off = 0;
|
||||
|
||||
box[off++] = 1; // configurationVersion
|
||||
box[off++] = spsNalu[1]; // AVCProfileIndication (profile_idc)
|
||||
box[off++] = spsNalu[2]; // profile_compatibility
|
||||
box[off++] = spsNalu[3]; // AVCLevelIndication (level_idc)
|
||||
box[off++] = 0xFF; // lengthSizeMinusOne = 3 (4-byte NALU lengths)
|
||||
|
||||
// SPS
|
||||
box[off++] = 0xE1; // numOfSequenceParameterSets = 1
|
||||
box[off++] = (spsLen >> 8) & 0xff;
|
||||
box[off++] = spsLen & 0xff;
|
||||
box.set(spsNalu, off);
|
||||
off += spsLen;
|
||||
|
||||
// PPS
|
||||
box[off++] = 1; // numOfPictureParameterSets = 1
|
||||
box[off++] = (ppsLen >> 8) & 0xff;
|
||||
box[off++] = ppsLen & 0xff;
|
||||
box.set(ppsNalu, off);
|
||||
off += ppsLen;
|
||||
|
||||
const hex = Array.from(box).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
log('ok', `avcC box built: ${box.length} bytes [${hex}]`);
|
||||
return box.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array of configuration packets (from scrcpy stream), parse NALUs
|
||||
* from each packet, find SPS and PPS, and build an avcC box.
|
||||
* Returns { description, codec } or throws.
|
||||
*/
|
||||
function buildDecoderConfig(configPackets: Uint8Array[]): { description: ArrayBuffer; codec: string } {
|
||||
let sps: Uint8Array | null = null;
|
||||
let pps: Uint8Array | null = null;
|
||||
|
||||
for (const packet of configPackets) {
|
||||
// Each config packet may contain one or more NALUs (Annex B or AVCC)
|
||||
const nalus = parseNalus(packet);
|
||||
log('info', `Config packet: ${packet.length} bytes → ${nalus.length} NALUs`);
|
||||
for (const nalu of nalus) {
|
||||
if (nalu.length === 0) continue;
|
||||
const naluType = nalu[0] & 0x1f;
|
||||
if (naluType === 7) {
|
||||
sps = nalu;
|
||||
log('info', ` SPS found: ${nalu.length} bytes, profile=${nalu[1]}, level=${nalu[3]}`);
|
||||
} else if (naluType === 8) {
|
||||
pps = nalu;
|
||||
log('info', ` PPS found: ${nalu.length} bytes`);
|
||||
} else {
|
||||
log('info', ` Config NALU type=${naluType}, ${nalu.length} bytes (ignored)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sps || !pps) {
|
||||
throw new Error(`Missing SPS/PPS in config data`);
|
||||
}
|
||||
|
||||
const profileIdc = sps[1];
|
||||
const compat = sps[2];
|
||||
const levelIdc = sps[3];
|
||||
const codec = `avc1.${profileIdc.toString(16).padStart(2, '0')}${compat.toString(16).padStart(2, '0')}${levelIdc.toString(16).padStart(2, '0')}`;
|
||||
log('info', `Codec string: ${codec}`);
|
||||
|
||||
const description = buildAvcCBox(sps, pps);
|
||||
return { description, codec };
|
||||
}
|
||||
|
||||
// ── Decode loop ─────────────────────────────────────────────────────────
|
||||
async function startDecoding(
|
||||
videoStream: ReadableStream<any>,
|
||||
sWidth: number,
|
||||
sHeight: number
|
||||
) {
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const reader = videoStream.getReader();
|
||||
|
||||
log('info', 'Stream reader acquired, waiting for packets…');
|
||||
|
||||
// Collect configuration NALUs from the stream
|
||||
const configNalus: Uint8Array[] = [];
|
||||
let gotConfig = false;
|
||||
let firstDataPacket = true;
|
||||
|
||||
try {
|
||||
while (!closed) {
|
||||
const { done, value: rawPacket } = await reader.read();
|
||||
if (done) {
|
||||
log('warn', 'Stream ended');
|
||||
break;
|
||||
}
|
||||
if (!rawPacket) continue;
|
||||
|
||||
const packet = rawPacket as { type: string; data?: Uint8Array; pts?: bigint; keyframe?: boolean };
|
||||
|
||||
// ── Configuration packets (SPS/PPS) ─────────────────────
|
||||
if (packet.type === 'configuration' && packet.data) {
|
||||
// Log first 48 bytes as hex to diagnose format
|
||||
const hex = Array.from(packet.data.slice(0, 48)).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
log('info', `Config NALU #${configNalus.length}: ${packet.data.length} bytes [${hex}${packet.data.length > 48 ? '…' : ''}]`);
|
||||
configNalus.push(packet.data);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── First data packet → initialize decoder ──────────────
|
||||
if (packet.type === 'data' && packet.data && !gotConfig) {
|
||||
gotConfig = true;
|
||||
log('ok', `Got ${configNalus.length} config NALUs, first data packet arrived`);
|
||||
|
||||
if (configNalus.length === 0) {
|
||||
log('error', 'No config NALUs received before data — cannot initialize decoder');
|
||||
error = 'No H.264 SPS/PPS received from device';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { description, codec } = buildDecoderConfig(configNalus);
|
||||
sessionCodec = codec;
|
||||
|
||||
decoder = new VideoDecoder({
|
||||
output: (frame: VideoFrame) => {
|
||||
lastFrame?.close();
|
||||
lastFrame = frame;
|
||||
if (loading) {
|
||||
loading = false;
|
||||
log('ok', 'First frame rendered');
|
||||
}
|
||||
scheduleRender();
|
||||
},
|
||||
error: (e) => {
|
||||
log('error', `VideoDecoder error: ${e.message} (code=${e.code})`);
|
||||
}
|
||||
});
|
||||
|
||||
const cfg: VideoDecoderConfig = {
|
||||
codec,
|
||||
codedWidth: sWidth || 480,
|
||||
codedHeight: sHeight || 854,
|
||||
description
|
||||
};
|
||||
|
||||
log('info', `Configuring decoder: ${cfg.codedWidth}×${cfg.codedHeight}, codec=${cfg.codec}`);
|
||||
await decoder.configure(cfg);
|
||||
log('ok', 'VideoDecoder configured successfully');
|
||||
sessionSize = `${sWidth || 480}×${sHeight || 854}`;
|
||||
} catch (e) {
|
||||
log('error', `Decoder init failed: ${e instanceof Error ? e.message : e}`);
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data packets → decode ───────────────────────────────
|
||||
if (packet.type === 'data' && packet.data && decoder) {
|
||||
if (firstDataPacket) {
|
||||
firstDataPacket = false;
|
||||
const hex = Array.from(packet.data.slice(0, 32)).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
log('info', `First data packet: ${packet.data.length} bytes, keyframe=${packet.keyframe}, pts=${packet.pts} [${hex}…]`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert Annex B → AVCC if needed (WebCodecs expects AVCC with avcC description)
|
||||
const raw = packet.data;
|
||||
const isAnnexB = raw.length >= 4 && raw[0] === 0 && raw[1] === 0 && raw[2] === 0 && raw[3] === 1;
|
||||
const chunkData = isAnnexB ? annexBToAvcc(raw) : raw;
|
||||
|
||||
decoder.decode(
|
||||
new EncodedVideoChunk({
|
||||
type: packet.keyframe ? 'key' : 'delta',
|
||||
timestamp: Number(packet.pts ?? 0n),
|
||||
duration: 0,
|
||||
data: chunkData
|
||||
})
|
||||
);
|
||||
frameCount++;
|
||||
} catch (e) {
|
||||
if (frameCount === 0) {
|
||||
log('error', `First frame decode failed: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log('error', `Stream read error: ${e instanceof Error ? e.message : e}`);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
function scheduleRender() {
|
||||
if (rendering) return;
|
||||
rendering = true;
|
||||
rendererHandle = requestAnimationFrame(() => {
|
||||
rendering = false;
|
||||
if (!lastFrame || !ctx || !canvas) return;
|
||||
// Resize canvas buffer to match frame dimensions
|
||||
if (canvas.width !== lastFrame.displayWidth || canvas.height !== lastFrame.displayHeight) {
|
||||
log('info', `Canvas resize: ${canvas.width}×${canvas.height} → ${lastFrame.displayWidth}×${lastFrame.displayHeight}`);
|
||||
canvas.width = lastFrame.displayWidth;
|
||||
canvas.height = lastFrame.displayHeight;
|
||||
}
|
||||
ctx.drawImage(lastFrame, 0, 0);
|
||||
lastFrame.close();
|
||||
lastFrame = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert H.264 Annex B data (start-code prefixed) to AVCC format
|
||||
* (4-byte big-endian length prefixed). WebCodecs with avcC description
|
||||
* expects AVCC format in EncodedVideoChunk.
|
||||
*/
|
||||
function annexBToAvcc(data: Uint8Array): Uint8Array {
|
||||
const nalus = parseAnnexBNalus(data);
|
||||
if (nalus.length === 0) return data; // already AVCC or empty
|
||||
|
||||
// Calculate total size: 4 bytes length prefix per NALU
|
||||
let totalSize = 0;
|
||||
for (const nalu of nalus) totalSize += 4 + nalu.length;
|
||||
|
||||
const result = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const nalu of nalus) {
|
||||
const len = nalu.length;
|
||||
result[offset++] = (len >>> 24) & 0xff;
|
||||
result[offset++] = (len >>> 16) & 0xff;
|
||||
result[offset++] = (len >>> 8) & 0xff;
|
||||
result[offset++] = len & 0xff;
|
||||
result.set(nalu, offset);
|
||||
offset += len;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Touch helpers ────────────────────────────────────────────────────────
|
||||
function pointerToVideo(e: PointerEvent) {
|
||||
if (!canvas || !videoWidth || !videoHeight) return { x: 0, y: 0 };
|
||||
const r = canvas.getBoundingClientRect();
|
||||
|
||||
// Account for object-contain: the rendered content is centered within the
|
||||
// bounding rect with potential letterbox/pillarbox margins.
|
||||
const containerRatio = r.width / r.height;
|
||||
const contentRatio = videoWidth / videoHeight;
|
||||
let contentW: number, contentH: number, offsetX: number, offsetY: number;
|
||||
if (containerRatio > contentRatio) {
|
||||
// Container wider → height-constrained → horizontal letterbox
|
||||
contentH = r.height;
|
||||
contentW = r.height * contentRatio;
|
||||
offsetX = (r.width - contentW) / 2;
|
||||
offsetY = 0;
|
||||
} else {
|
||||
// Container taller → width-constrained → vertical pillarbox
|
||||
contentW = r.width;
|
||||
contentH = r.width / contentRatio;
|
||||
offsetX = 0;
|
||||
offsetY = (r.height - contentH) / 2;
|
||||
}
|
||||
|
||||
const x = Math.round(((e.clientX - r.left - offsetX) / contentW) * videoWidth);
|
||||
const y = Math.round(((e.clientY - r.top - offsetY) / contentH) * videoHeight);
|
||||
return {
|
||||
x: Math.max(0, Math.min(videoWidth, x)),
|
||||
y: Math.max(0, Math.min(videoHeight, y))
|
||||
};
|
||||
}
|
||||
|
||||
function sendTouch(action: AndroidMotionEventAction, e: PointerEvent) {
|
||||
if (touchLocked || !controlWriter || !videoWidth || !videoHeight) return;
|
||||
const { x, y } = pointerToVideo(e);
|
||||
controlWriter.injectTouch({
|
||||
action,
|
||||
pointerId: BigInt(pointerId),
|
||||
pointerX: x,
|
||||
pointerY: y,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
pressure: action === AndroidMotionEventAction.Move ? 1 : 0.5,
|
||||
actionButton: 1,
|
||||
buttons: 1
|
||||
});
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
pointerId = e.pointerId;
|
||||
canvas?.setPointerCapture(e.pointerId);
|
||||
sendTouch(AndroidMotionEventAction.Down, e);
|
||||
}
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
sendTouch(AndroidMotionEventAction.Up, e);
|
||||
pointerId = -1;
|
||||
canvas?.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (pointerId === -1) return;
|
||||
sendTouch(AndroidMotionEventAction.Move, e);
|
||||
}
|
||||
|
||||
// ── Keyboard helpers ─────────────────────────────────────────────────────
|
||||
const KEY_MAP = {
|
||||
Backspace: 67,
|
||||
Home: 3,
|
||||
'Alt+ArrowUp': 82,
|
||||
'Alt+ArrowDown': 82,
|
||||
'Alt+ArrowLeft': 88,
|
||||
'Alt+ArrowRight': 89
|
||||
} as const;
|
||||
|
||||
function onKeyActionStart(code: string, combo: string) {
|
||||
if (!controlWriter || activeKeyAction) return;
|
||||
const mapped = (combo in KEY_MAP) ? KEY_MAP[combo as keyof typeof KEY_MAP] : undefined;
|
||||
if (mapped === undefined) return;
|
||||
activeKeyAction = 'down';
|
||||
controlWriter.injectKeyCode({
|
||||
keyCode: mapped as AndroidKeyCode,
|
||||
action: AndroidKeyEventAction.Down,
|
||||
repeat: 0,
|
||||
metaState: 0
|
||||
});
|
||||
}
|
||||
|
||||
function onKeyActionEnd() {
|
||||
if (!controlWriter || activeKeyAction !== 'down') return;
|
||||
controlWriter.injectKeyCode({
|
||||
keyCode: 67 as AndroidKeyCode,
|
||||
action: AndroidKeyEventAction.Up,
|
||||
repeat: 0,
|
||||
metaState: 0
|
||||
});
|
||||
activeKeyAction = null;
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
const combo = `${e.altKey ? 'Alt+' : ''}${e.code}`;
|
||||
if (e.type === 'keydown' && !e.repeat) onKeyActionStart(e.code, combo);
|
||||
if (e.type === 'keyup') onKeyActionEnd();
|
||||
}
|
||||
|
||||
// ── Session lifecycle ───────────────────────────────────────────────────
|
||||
async function startSession() {
|
||||
if (starting) return;
|
||||
starting = true;
|
||||
loading = true;
|
||||
error = '';
|
||||
logEntries = [];
|
||||
logId = 0;
|
||||
currentStep = '';
|
||||
completedSteps = new Set();
|
||||
stepDetail = '';
|
||||
log('info', 'Starting scrcpy session…');
|
||||
|
||||
try {
|
||||
const client = await scrcpyManager.start(
|
||||
{
|
||||
maxSize: 1024,
|
||||
videoBitRate: 8_000_000,
|
||||
control: true
|
||||
},
|
||||
(step, detail) => {
|
||||
// Mark previous step as completed
|
||||
if (currentStep && currentStep !== 'error' && currentStep !== 'ready') {
|
||||
completedSteps = new Set([...completedSteps, currentStep]);
|
||||
}
|
||||
currentStep = step;
|
||||
stepDetail = detail ?? '';
|
||||
if (detail) log(step === 'error' ? 'error' : 'info', detail);
|
||||
}
|
||||
);
|
||||
|
||||
log('ok', 'Scrcpy session started');
|
||||
|
||||
// Access the video stream — this must be the first read of the stream
|
||||
const videoStream = await client.videoStream;
|
||||
if (!videoStream) {
|
||||
throw new Error('videoStream is null');
|
||||
}
|
||||
|
||||
const { metadata } = videoStream;
|
||||
videoWidth = metadata.width ?? 0;
|
||||
videoHeight = metadata.height ?? 0;
|
||||
sessionCodec = ScrcpyVideoCodecNameMap.get(metadata.codec) ?? `codec=${metadata.codec}`;
|
||||
sessionSize = `${videoWidth}×${videoHeight}`;
|
||||
log('info', `Video metadata: ${sessionSize}, codec=${sessionCodec}`);
|
||||
|
||||
controlWriter = client.controller ?? null;
|
||||
log('info', `Control: ${controlWriter ? 'enabled' : 'disabled'}`);
|
||||
|
||||
// Set canvas to reported size (or fallback)
|
||||
if (canvas) {
|
||||
canvas.width = videoWidth || 480;
|
||||
canvas.height = videoHeight || 854;
|
||||
}
|
||||
|
||||
// Start decoding — this reads from the stream
|
||||
log('info', 'Starting decode loop…');
|
||||
await startDecoding(videoStream.stream as ReadableStream<any>, videoWidth, videoHeight);
|
||||
|
||||
log('info', 'Session ended');
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
log('error', msg);
|
||||
error = msg;
|
||||
currentStep = 'error';
|
||||
} finally {
|
||||
loading = false;
|
||||
starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSession() {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
log('info', 'Stopping session…');
|
||||
closed = true;
|
||||
currentStep = '';
|
||||
completedSteps = new Set();
|
||||
stepDetail = '';
|
||||
|
||||
cancelAnimationFrame(rendererHandle);
|
||||
rendering = false;
|
||||
|
||||
if (decoder) {
|
||||
try { await decoder.flush(); } catch { /* */ }
|
||||
decoder.close();
|
||||
decoder = null;
|
||||
}
|
||||
lastFrame?.close();
|
||||
lastFrame = null;
|
||||
|
||||
controlWriter = null;
|
||||
videoWidth = 0;
|
||||
videoHeight = 0;
|
||||
frameCount = 0;
|
||||
|
||||
await scrcpyManager.close();
|
||||
log('ok', 'Session closed');
|
||||
stopping = false;
|
||||
}
|
||||
|
||||
async function reconnect() {
|
||||
log('info', 'Reconnecting…');
|
||||
// Force-reset decoder state
|
||||
if (decoder) {
|
||||
try { await decoder.flush(); } catch { /* */ }
|
||||
decoder.close();
|
||||
decoder = null;
|
||||
}
|
||||
lastFrame?.close();
|
||||
lastFrame = null;
|
||||
cancelAnimationFrame(rendererHandle);
|
||||
rendering = false;
|
||||
controlWriter = null;
|
||||
touchLocked = true;
|
||||
// Force-close any lingering scrcpy session
|
||||
if (scrcpyManager.isActive) {
|
||||
await scrcpyManager.close();
|
||||
}
|
||||
// Reset all flags
|
||||
closed = false;
|
||||
stopping = false;
|
||||
starting = false;
|
||||
loading = false;
|
||||
frameCount = 0;
|
||||
videoWidth = 0;
|
||||
videoHeight = 0;
|
||||
error = '';
|
||||
sessionCodec = '';
|
||||
sessionSize = '';
|
||||
logEntries = [];
|
||||
logId = 0;
|
||||
currentStep = '';
|
||||
completedSteps = new Set();
|
||||
stepDetail = '';
|
||||
// Start fresh
|
||||
sessionStarted = false;
|
||||
startSession();
|
||||
}
|
||||
|
||||
// ── Open / close binding ─────────────────────────────────────────────────
|
||||
// sessionStarted prevents re-entry: startSession() mutates $state which
|
||||
// re-triggers this effect body — without the guard it would loop forever.
|
||||
$effect(() => {
|
||||
if (open && !sessionStarted) {
|
||||
sessionStarted = true;
|
||||
// Force-reset all state so startSession() doesn't bail early
|
||||
// (previous stopSession() may still be running async from cleanup)
|
||||
closed = false;
|
||||
stopping = false;
|
||||
starting = false;
|
||||
loading = false;
|
||||
frameCount = 0;
|
||||
error = '';
|
||||
touchLocked = true;
|
||||
startSession();
|
||||
}
|
||||
// Cleanup runs when open transitions true→false (dialog closes)
|
||||
return () => {
|
||||
if (scrcpyManager.isActive && !stopping) {
|
||||
stopSession();
|
||||
}
|
||||
// Reset guard so next open triggers a fresh session
|
||||
if (!open) {
|
||||
sessionStarted = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKey} onkeyup={handleKey} />
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="bg-black/60 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
class="max-h-[90vh] w-[95vw] max-w-[960px] overflow-hidden p-0 sm:max-w-[1080px]"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<!-- ── Header ─────────────────────────────────────────── -->
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<h2 class="text-sm font-semibold truncate">Screen Mirror</h2>
|
||||
{#if sessionCodec}
|
||||
<span class="shrink-0 inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{sessionCodec}
|
||||
</span>
|
||||
{/if}
|
||||
{#if sessionSize}
|
||||
<span class="shrink-0 inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{sessionSize}
|
||||
</span>
|
||||
{/if}
|
||||
{#if frameCount > 0}
|
||||
<span class="shrink-0 inline-flex items-center rounded-md bg-emerald-100 dark:bg-emerald-900/40 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{frameCount} frames
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Close
|
||||
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<!-- ── Canvas ─────────────────────────────────────────── -->
|
||||
<div class="relative flex items-center justify-center bg-black min-h-[300px]">
|
||||
{#if loading && frameCount === 0 && !error}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 p-6">
|
||||
{#each stepStates as step (step.key)}
|
||||
<div class={cn(
|
||||
'flex items-center gap-3 text-xs transition-all duration-300',
|
||||
step.isCompleted ? 'text-emerald-400' : step.isActive ? 'text-foreground' : 'text-muted-foreground/40'
|
||||
)}>
|
||||
{#if step.isCompleted}
|
||||
<div class="flex size-5 shrink-0 items-center justify-center rounded-full bg-emerald-500/20">
|
||||
<svg class="size-3 text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>
|
||||
</div>
|
||||
{:else if step.isActive}
|
||||
<div class="size-5 shrink-0 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||
{:else}
|
||||
<div class="size-5 shrink-0 rounded-full border border-muted-foreground/30"></div>
|
||||
{/if}
|
||||
<span class="font-medium">{step.label}</span>
|
||||
{#if step.isActive && stepDetail}
|
||||
<span class="text-muted-foreground/60">— {stepDetail}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-4">
|
||||
<div class="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-center">
|
||||
<p class="text-xs font-medium text-destructive">{error}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={reconnect}>
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-current mr-1"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
{:else if !loading && frameCount === 0 && sessionStarted && !stopping}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<Button variant="outline" size="sm" onclick={reconnect}>
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-current mr-1"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="max-h-[65vh] w-full object-contain"
|
||||
onpointerdown={onPointerDown}
|
||||
onpointerup={onPointerUp}
|
||||
onpointermove={onPointerMove}
|
||||
role="img"
|
||||
aria-label="Android screen"
|
||||
></canvas>
|
||||
{#if touchLocked && frameCount > 0}
|
||||
<div class="absolute bottom-2 right-2 flex items-center gap-1 rounded-md bg-black/60 px-2 py-1 text-[10px] text-white/70">
|
||||
<svg viewBox="0 0 24 24" class="size-3 fill-none stroke-current" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
Touch locked
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Nav bar ─────────────────────────────────────────── -->
|
||||
<div class="flex items-center justify-between border-t px-4 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon-xs" onclick={() => controlWriter?.injectKeyCode({ keyCode: 3, action: AndroidKeyEventAction.Down, repeat: 0, metaState: 0 })} disabled={!controlWriter} title="Home">
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-current"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onclick={() => controlWriter?.injectKeyCode({ keyCode: 67, action: AndroidKeyEventAction.Down, repeat: 0, metaState: 0 })} disabled={!controlWriter} title="Back">
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-current"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onclick={() => controlWriter?.injectKeyCode({ keyCode: 187, action: AndroidKeyEventAction.Down, repeat: 0, metaState: 0 })} disabled={!controlWriter} title="Recent Apps">
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-current"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant={touchLocked ? 'outline' : 'ghost'}
|
||||
size="sm"
|
||||
onclick={() => { touchLocked = !touchLocked; }}
|
||||
>
|
||||
{#if touchLocked}
|
||||
<!-- Hand icon: click to unlock -->
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-none stroke-current" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 11V6a2 2 0 0 0-4 0v1"/>
|
||||
<path d="M14 10V4a2 2 0 0 0-4 0v2"/>
|
||||
<path d="M10 10.5V6a2 2 0 0 0-4 0v8"/>
|
||||
<path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>
|
||||
</svg>
|
||||
<span class="text-xs">Unlock Touch</span>
|
||||
{:else}
|
||||
<!-- Hand with slash: click to lock -->
|
||||
<svg viewBox="0 0 24 24" class="size-3.5 fill-none stroke-current" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 11V6a2 2 0 0 0-4 0v1"/>
|
||||
<path d="M14 10V4a2 2 0 0 0-4 0v2"/>
|
||||
<path d="M10 10.5V6a2 2 0 0 0-4 0v8"/>
|
||||
<path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>
|
||||
<line x1="2" y1="2" x2="22" y2="22"/>
|
||||
</svg>
|
||||
<span class="text-xs">Lock Touch</span>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- ── Status log ───────────────────────────────────────── -->
|
||||
<div class="border-t bg-muted/30">
|
||||
<div class="max-h-[140px] overflow-y-auto px-3 py-2 font-mono text-[10px] leading-relaxed">
|
||||
{#each logEntries as entry (entry.id)}
|
||||
{@const iconClass = entry.type === 'ok'
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: entry.type === 'error'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: entry.type === 'warn'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-muted-foreground'}
|
||||
{@const msgClass = entry.type === 'ok'
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: entry.type === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: entry.type === 'warn'
|
||||
? 'text-yellow-700 dark:text-yellow-300'
|
||||
: 'text-foreground'}
|
||||
<div class="flex gap-2">
|
||||
<span class="shrink-0 text-muted-foreground/60">{entry.time}</span>
|
||||
<span class={cn('shrink-0 w-8 text-right', iconClass)}>
|
||||
{entry.type === 'ok' ? '✓' : entry.type === 'error' ? '✗' : entry.type === 'warn' ? '!' : '·'}
|
||||
</span>
|
||||
<span class={cn('min-w-0 break-all', msgClass)}>
|
||||
{entry.message}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if logEntries.length === 0}
|
||||
<span class="text-muted-foreground/40">Waiting…</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
accept: "bg-emerald-600 text-white shadow-sm hover:bg-emerald-500 dark:bg-emerald-600 dark:hover:bg-emerald-500",
|
||||
reject: "bg-red-600 text-white shadow-sm hover:bg-red-500 dark:bg-red-600 dark:hover:bg-red-500",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { AdbScrcpyClient } from '@yume-chan/adb-scrcpy';
|
|||
import { addNotification } from '../stores/noti';
|
||||
import { handleAdbPayload } from '../handlers/adbPayloadHandler';
|
||||
import { GlobalEventBus } from '../utils/eventBus';
|
||||
import { adbWriter } from '../stores/adbWriter';
|
||||
import { adbWriter, sendToAndroid } from '../stores/adbWriter';
|
||||
import { adbReconnect } from '../stores/adbReconnectStore';
|
||||
import { WritableStream } from '@yume-chan/stream-extra';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
|
@ -27,6 +27,8 @@ let syncConnection: any = null;
|
|||
|
||||
let worker: Worker | undefined;
|
||||
|
||||
let healthCheckRoutine: any;
|
||||
|
||||
/**
|
||||
* Centralized connection function that all pages and components should use.
|
||||
*
|
||||
|
|
@ -220,11 +222,9 @@ export async function connnectViaWebUSB(connectAndroidServer = true) {
|
|||
// save device info
|
||||
await deviceCredentialManager.saveDeviceInfo(device);
|
||||
} catch (e: any) {
|
||||
logger.error('error on connect', e);
|
||||
|
||||
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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +301,7 @@ export async function connectRecipeMenuViaWebUSB() {
|
|||
|
||||
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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -707,10 +707,18 @@ async function connectToAndroidServer(maxRetries = 5) {
|
|||
};
|
||||
}
|
||||
adbWriter.set(writer);
|
||||
if (healthCheckRoutine) {
|
||||
clearInterval(healthCheckRoutine);
|
||||
healthCheckRoutine = null;
|
||||
}
|
||||
|
||||
if (writer) {
|
||||
addNotification('INFO:Enable Brewing Mode T on machine');
|
||||
|
||||
healthCheckRoutine = setInterval(() => {
|
||||
sendToAndroid({ type: 'status', payload: {} });
|
||||
}, 1000);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
|
|
@ -725,6 +733,11 @@ async function connectToAndroidServer(maxRetries = 5) {
|
|||
logger.error('Android server read error', e);
|
||||
} finally {
|
||||
adbWriter.set(null);
|
||||
|
||||
if (healthCheckRoutine) {
|
||||
clearInterval(healthCheckRoutine);
|
||||
healthCheckRoutine = null;
|
||||
}
|
||||
addNotification('WARN:Android server channel offline ...');
|
||||
reader.cancel().catch(() => {});
|
||||
// Prompt the user to reconnect instead of auto-reconnecting
|
||||
|
|
@ -798,6 +811,9 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
adbWriter.set(writer);
|
||||
if (writer) {
|
||||
addNotification('INFO:Enable Android recipe menu channel');
|
||||
healthCheckRoutine = setInterval(() => {
|
||||
sendToAndroid({ type: 'status', payload: {} });
|
||||
}, 1000);
|
||||
} else {
|
||||
addNotification('WARN:Android recipe menu channel unavailable');
|
||||
|
||||
|
|
@ -840,6 +856,11 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
|
|||
logger.error('recipe menu read error', e);
|
||||
} finally {
|
||||
adbWriter.set(null);
|
||||
|
||||
if (healthCheckRoutine) {
|
||||
clearInterval(healthCheckRoutine);
|
||||
healthCheckRoutine = null;
|
||||
}
|
||||
addNotification('WARN:Android recipe menu channel offline ...');
|
||||
reader.cancel().catch(() => {});
|
||||
// Prompt the user to reconnect instead of auto-scheduling
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ type AdbPayload = { type: string; payload: any };
|
|||
|
||||
let queuedPromises = new Array<Promise<void>>();
|
||||
|
||||
// 10 secs
|
||||
const SLOW_PAYLOAD_RESPONSE_MS = 10000;
|
||||
|
||||
async function handleAdbPayload(raw_payload: string) {
|
||||
// logger.info('[ADB] Received payload:', raw_payload.slice(0, 300));
|
||||
const APP_VERSION = env.PUBLIC_APP_SEMVER;
|
||||
|
|
@ -156,6 +159,22 @@ async function handleAdbPayload(raw_payload: string) {
|
|||
// show error to user from brew app
|
||||
addNotification(`ERR:${payload.payload}`);
|
||||
// send message to server if needed
|
||||
break;
|
||||
case 'status':
|
||||
let current_ts = Date.now();
|
||||
let source_ts = payload.payload?.timestamp ?? current_ts;
|
||||
|
||||
let diff_ts = current_ts - source_ts;
|
||||
|
||||
if (diff_ts == 0) {
|
||||
// no source
|
||||
addNotification('WARN:Unknown time detected in status check.');
|
||||
} else if (diff_ts > 0 && diff_ts < SLOW_PAYLOAD_RESPONSE_MS) {
|
||||
// is acceptable
|
||||
} else {
|
||||
addNotification('WARN:Slow adb response detected');
|
||||
}
|
||||
|
||||
break;
|
||||
case 'recipe-export':
|
||||
if (payload.payload?.content) {
|
||||
|
|
|
|||
|
|
@ -577,6 +577,26 @@ const handlers: Record<string, (payload: any) => void> = {
|
|||
remote_shell: (p) => {
|
||||
// Server requests command execution on connected Android device
|
||||
handleIncomingRemoteShell(p);
|
||||
},
|
||||
firmware_confirm: (p) => {
|
||||
// console.log(`received firmware confirmation: ${JSON.stringify(p)}`);
|
||||
const items = Object.entries(p.history ?? {}).map(([filename, msg]) => ({
|
||||
filename,
|
||||
message: Object.entries(msg as any).map((x) => x[1])
|
||||
}));
|
||||
GlobalEventBus.emit('announce', {
|
||||
title: 'Firmware Confirmation',
|
||||
subtitle: `Please confirm this build contents. Session Ref: ${p.session}`,
|
||||
message: items,
|
||||
buttonText: 'Confirm',
|
||||
type: 'info'
|
||||
});
|
||||
},
|
||||
firmware_report: (p) => {
|
||||
console.log(`received firmware report: ${JSON.stringify(p)}`);
|
||||
},
|
||||
firmware_versions: (p) => {
|
||||
GlobalEventBus.emit('firmware-versions', p.versions ?? {});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -591,7 +611,7 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey?: Cry
|
|||
|
||||
sharedKey.set(await WebCryptoHelper.deriveSharedKey(clientPrivateKey, ack.server_public_key));
|
||||
|
||||
addNotification('INFO:Secured Connection');
|
||||
// addNotification('INFO:Secured Connection');
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -605,9 +625,9 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey?: Cry
|
|||
parsedMessage.iv
|
||||
);
|
||||
let actual_message: WSMessage = JSON.parse(decrypted_string);
|
||||
if (actual_message.type !== 'heartbeat') {
|
||||
// logger.debug(`[WS MSG] type=${actual_message.type}`, actual_message.payload);
|
||||
}
|
||||
|
||||
// preprocess type
|
||||
actual_message.type = actual_message.type.replace('-', '_');
|
||||
|
||||
handlers[actual_message.type]?.(actual_message.payload);
|
||||
}
|
||||
|
|
|
|||
178
src/lib/core/scrcpy/scrcpy-manager.ts
Normal file
178
src/lib/core/scrcpy/scrcpy-manager.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* ScrcpyManager — Lifecycle manager for @yume-chan/adb-scrcpy screen mirroring.
|
||||
*
|
||||
* Handles:
|
||||
* - Fetching the scrcpy-server binary (cached after first download)
|
||||
* - Pushing the binary to the Android device
|
||||
* - Starting a scrcpy session (video + optional control)
|
||||
* - Exposing the AdbScrcpyClient for the dialog to consume the video stream
|
||||
* - Clean teardown on close
|
||||
*
|
||||
* IMPORTANT: This manager does NOT consume the video stream. The dialog is
|
||||
* responsible for reading packets from client.videoStream.stream.
|
||||
*/
|
||||
|
||||
import { AdbScrcpyClient, AdbScrcpyOptions2_3 } from '@yume-chan/adb-scrcpy';
|
||||
import { ReadableStream } from '@yume-chan/stream-extra';
|
||||
import { AdbInstance } from '../../../routes/state.svelte';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ScrcpyStatusStep = 'fetching' | 'pushing' | 'starting' | 'ready' | 'error';
|
||||
|
||||
export interface ScrcpyStartOptions {
|
||||
/** Max video width (0 = no limit). Default 1024. */
|
||||
maxSize?: number;
|
||||
/** Video bitrate in bits/s. Default 8 000 000. */
|
||||
videoBitRate?: number;
|
||||
/** Video codec. Default "h264". */
|
||||
videoCodec?: 'h264' | 'h265' | 'av1';
|
||||
/** Max FPS (0 = no limit). Default 0. */
|
||||
maxFps?: number;
|
||||
/** Enable touch / key control. Default true. */
|
||||
control?: boolean;
|
||||
/** Push a specific server version tag. */
|
||||
serverVersion?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Path where the scrcpy-server binary lives on the Android device. */
|
||||
const SCRCPY_SERVER_DEVICE_PATH = '/data/local/tmp/scrcpy-server.jar';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary cache (module-level — persists across session starts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let cachedBinaryData: Uint8Array | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createOptions(init: ScrcpyStartOptions) {
|
||||
return new AdbScrcpyOptions2_3({
|
||||
tunnelForward: true,
|
||||
video: true,
|
||||
audio: false,
|
||||
control: init.control ?? true,
|
||||
maxSize: init.maxSize ?? 1024,
|
||||
videoCodec: init.videoCodec ?? 'h264',
|
||||
videoBitRate: init.videoBitRate ?? 8_000_000,
|
||||
maxFps: init.maxFps ?? 0,
|
||||
sendFrameMeta: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the scrcpy-server binary. Uses the local SvelteKit endpoint
|
||||
* (/api/scrcpy-server) to avoid CORS issues with GitHub.
|
||||
* Binary data is cached in memory after the first fetch.
|
||||
*/
|
||||
async function fetchServerBinary(): Promise<ReadableStream<Uint8Array>> {
|
||||
if (cachedBinaryData) {
|
||||
logger.info('[ScrcpyManager] Using cached scrcpy-server binary');
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(cachedBinaryData!);
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('[ScrcpyManager] Fetching scrcpy-server from /api/scrcpy-server…');
|
||||
const response = await fetch('/api/scrcpy-server');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch scrcpy-server: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
cachedBinaryData = new Uint8Array(buffer);
|
||||
logger.info(`[ScrcpyManager] Binary cached (${cachedBinaryData.length} bytes)`);
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(cachedBinaryData!);
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScrcpyManager class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ScrcpyManager {
|
||||
private _client: AdbScrcpyClient<ReturnType<typeof createOptions>> | null = null;
|
||||
|
||||
get client(): AdbScrcpyClient<ReturnType<typeof createOptions>> | null {
|
||||
return this._client;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this._client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a scrcpy screen-mirroring session.
|
||||
*
|
||||
* @param opts Configuration overrides (bitrate, codec, maxSize, …).
|
||||
* @param onStatus Optional callback for step-by-step progress reporting.
|
||||
* @returns The AdbScrcpyClient instance for the active session.
|
||||
*/
|
||||
async start(
|
||||
opts: ScrcpyStartOptions = {},
|
||||
onStatus?: (step: ScrcpyStatusStep, detail?: string) => void
|
||||
): Promise<AdbScrcpyClient<ReturnType<typeof createOptions>>> {
|
||||
// Clean up any existing session first.
|
||||
if (this._client) {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
const adb = AdbInstance.instance;
|
||||
if (!adb) {
|
||||
throw new Error('No ADB device connected');
|
||||
}
|
||||
|
||||
// 1. Fetch the scrcpy-server binary (cached after first download).
|
||||
onStatus?.('fetching', cachedBinaryData ? 'Using cached binary' : 'Downloading scrcpy-server…');
|
||||
const serverBinary = await fetchServerBinary();
|
||||
|
||||
// 2. Push the binary to the device.
|
||||
onStatus?.('pushing', 'Pushing to device…');
|
||||
await AdbScrcpyClient.pushServer(adb, serverBinary, SCRCPY_SERVER_DEVICE_PATH);
|
||||
logger.info('[ScrcpyManager] Server binary pushed.');
|
||||
|
||||
// 3. Start the scrcpy session.
|
||||
onStatus?.('starting', 'Starting session…');
|
||||
const options = createOptions(opts);
|
||||
const client = await AdbScrcpyClient.start(adb, SCRCPY_SERVER_DEVICE_PATH, options);
|
||||
logger.info('[ScrcpyManager] Session started.');
|
||||
|
||||
this._client = client;
|
||||
onStatus?.('ready', 'Connected');
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Tear down the current session. */
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
if (this._client) {
|
||||
await this._client.close();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('[ScrcpyManager] Error closing session', e);
|
||||
} finally {
|
||||
this._client = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance — import and use anywhere. */
|
||||
export const scrcpyManager = new ScrcpyManager();
|
||||
287
src/lib/core/stores/firmwareStore.ts
Normal file
287
src/lib/core/stores/firmwareStore.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
/**
|
||||
* Firmware request store — manages per-country firmware build requests
|
||||
* and available firmware files.
|
||||
*
|
||||
* The data structure is designed to be wired up to a backend API later.
|
||||
* For now it uses a writable store with mock data so the UI can be
|
||||
* developed and tested independently.
|
||||
*/
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { sendMessage } from '../handlers/ws_messageSender';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FirmwareBuildStatus = 'idle' | 'queued' | 'building' | 'ready' | 'failed';
|
||||
|
||||
export interface FirmwareFile {
|
||||
/** Unique identifier. */
|
||||
id: string;
|
||||
/** Filename e.g. `supra-v1.2.3-tha.bin` */
|
||||
filename: string;
|
||||
/** File size in bytes. */
|
||||
size: number;
|
||||
/** Build date ISO string. */
|
||||
builtAt: string;
|
||||
/** Firmware version string. */
|
||||
version: string;
|
||||
/** Download URL (populated when status === 'ready'). */
|
||||
downloadUrl?: string;
|
||||
/** Status of the build. */
|
||||
status: FirmwareBuildStatus;
|
||||
/** Optional status message / error detail. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface FirmwareCountryState {
|
||||
/** Country code (e.g. 'tha', 'sgp'). */
|
||||
country: string;
|
||||
/** Human-readable country label. */
|
||||
label: string;
|
||||
/** Current build status for this country. */
|
||||
buildStatus: FirmwareBuildStatus;
|
||||
/** List of firmware files for this country. */
|
||||
files: FirmwareFile[];
|
||||
}
|
||||
|
||||
export interface FirmwareBuildRequest {
|
||||
country: string;
|
||||
version: string;
|
||||
notes?: string;
|
||||
requester: string;
|
||||
param?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Country list (derived from the same codes used in productCode.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FIRMWARE_COUNTRIES = [
|
||||
{ code: 'tha', label: 'Thailand' },
|
||||
{ code: 'mys', label: 'Malaysia' },
|
||||
{ code: 'idr', label: 'Indonesia' },
|
||||
{ code: 'aus', label: 'Australia' },
|
||||
{ code: 'sgp', label: 'Singapore' },
|
||||
{ code: 'dubai', label: 'UAE Dubai' },
|
||||
{ code: 'hkg', label: 'Hong Kong' },
|
||||
{ code: 'gbr', label: 'United Kingdom' },
|
||||
{ code: 'rou', label: 'Romania' },
|
||||
{ code: 'lva', label: 'Latvia' },
|
||||
{ code: 'est', label: 'Estonia' },
|
||||
{ code: 'ltu', label: 'Lithuania' }
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createFirmwareStore() {
|
||||
const { subscribe, set, update } = writable<Record<string, FirmwareCountryState>>({});
|
||||
|
||||
// Initialise with empty state for each country
|
||||
const initial: Record<string, FirmwareCountryState> = {};
|
||||
for (const c of FIRMWARE_COUNTRIES) {
|
||||
initial[c.code] = {
|
||||
country: c.code,
|
||||
label: c.label,
|
||||
buildStatus: 'idle',
|
||||
files: []
|
||||
};
|
||||
}
|
||||
set(initial);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
update,
|
||||
|
||||
/**
|
||||
* Request a new firmware build for a country.
|
||||
*/
|
||||
requestBuild(req: FirmwareBuildRequest) {
|
||||
const countryState = get(firmwareStore)[req.country];
|
||||
if (!countryState) return;
|
||||
if (countryState.buildStatus === 'queued' || countryState.buildStatus === 'building') {
|
||||
return; // Already in progress
|
||||
}
|
||||
|
||||
// Set to queued
|
||||
update((state) => ({
|
||||
...state,
|
||||
[req.country]: {
|
||||
...state[req.country],
|
||||
buildStatus: 'queued',
|
||||
files: [
|
||||
{
|
||||
id: `${req.country}-${Date.now()}`,
|
||||
filename: '',
|
||||
size: 0,
|
||||
builtAt: new Date().toISOString(),
|
||||
version: 'unknown',
|
||||
status: 'queued',
|
||||
message: req.notes
|
||||
},
|
||||
...state[req.country].files
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
// Simulate build lifecycle (replace with real WS call later)
|
||||
// simulateBuild(req.country);
|
||||
|
||||
sendMessage({
|
||||
type: 'firmware-request',
|
||||
payload: {
|
||||
mode: 'not_full-delay-build',
|
||||
brand: req.country,
|
||||
files: [],
|
||||
param: (req.param as any[]) ?? [],
|
||||
requester: req.requester
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the file list for a country.
|
||||
*/
|
||||
refreshFiles(country: string) {
|
||||
// In production, fetch from server. No-op for now.
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the store (useful for testing).
|
||||
*/
|
||||
reset() {
|
||||
const fresh: Record<string, FirmwareCountryState> = {};
|
||||
for (const c of FIRMWARE_COUNTRIES) {
|
||||
fresh[c.code] = {
|
||||
country: c.code,
|
||||
label: c.label,
|
||||
buildStatus: 'idle',
|
||||
files: []
|
||||
};
|
||||
}
|
||||
set(fresh);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const firmwareStore = createFirmwareStore();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Simulate a build lifecycle: queued → building → ready.
|
||||
* This is a placeholder until the real backend is wired up.
|
||||
*/
|
||||
function simulateBuild(country: string) {
|
||||
// queued → building (1s)
|
||||
setTimeout(() => {
|
||||
firmwareStore.update((state) => ({
|
||||
...state,
|
||||
[country]: {
|
||||
...state[country],
|
||||
buildStatus: 'building',
|
||||
files: state[country].files.map((f: FirmwareFile, i: number) =>
|
||||
i === 0 ? { ...f, status: 'building' as FirmwareBuildStatus } : f
|
||||
)
|
||||
}
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
// building → ready (3s)
|
||||
setTimeout(() => {
|
||||
const mockSize = 2_400_000 + Math.floor(Math.random() * 800_000); // ~2.4–3.2 MB
|
||||
firmwareStore.update((state: Record<string, FirmwareCountryState>) => ({
|
||||
...state,
|
||||
[country]: {
|
||||
...state[country],
|
||||
buildStatus: 'ready',
|
||||
files: state[country].files.map((f: FirmwareFile, i: number) =>
|
||||
i === 0
|
||||
? {
|
||||
...f,
|
||||
status: 'ready' as FirmwareBuildStatus,
|
||||
size: mockSize,
|
||||
builtAt: new Date().toISOString(),
|
||||
downloadUrl: `#/download/${f.filename}`
|
||||
}
|
||||
: f
|
||||
)
|
||||
}
|
||||
}));
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes into a human-readable string.
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let size = bytes;
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO date string into a short local date-time.
|
||||
*/
|
||||
export function formatBuildDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a build status to a badge variant for the UI.
|
||||
*/
|
||||
export function statusBadgeVariant(
|
||||
status: FirmwareBuildStatus
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'default';
|
||||
case 'building':
|
||||
return 'secondary';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
case 'queued':
|
||||
case 'idle':
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a build status to a human-readable label.
|
||||
*/
|
||||
export function statusLabel(status: FirmwareBuildStatus): string {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return 'Idle';
|
||||
case 'queued':
|
||||
return 'Queued';
|
||||
case 'building':
|
||||
return 'Building';
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
}
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ export async function connectToWebsocket(id_token?: string) {
|
|||
|
||||
socket.addEventListener('open', async () => {
|
||||
socketStore.set(socket);
|
||||
addNotification('INFO:Connected!');
|
||||
// addNotification('INFO:Connected!');
|
||||
|
||||
if (socket) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
|
|
|
|||
|
|
@ -91,4 +91,25 @@ export type OutMessage =
|
|||
timestamp: number;
|
||||
timedOut?: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'firmware-request';
|
||||
payload: {
|
||||
mode: 'not_full' | 'not_full-delay-build';
|
||||
brand: string;
|
||||
files: string[];
|
||||
param: ('new-auto-inc-version' | 'save-build-date' | 'save-build-version-override')[];
|
||||
requester: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'firmware-confirm';
|
||||
payload: {
|
||||
confirm: boolean;
|
||||
session: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'firmware-versions';
|
||||
payload: {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
450
src/routes/(authed)/firmware/+page.svelte
Normal file
450
src/routes/(authed)/firmware/+page.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -79,11 +91,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 {
|
||||
|
|
@ -426,7 +442,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;
|
||||
}
|
||||
|
|
@ -465,7 +483,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;
|
||||
}
|
||||
|
|
@ -493,7 +513,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>
|
||||
|
|
@ -504,7 +526,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>
|
||||
|
|
@ -535,6 +558,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'}
|
||||
|
|
@ -591,7 +618,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
|
||||
|
|
@ -698,7 +725,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
|
||||
|
|
@ -885,4 +914,6 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ScrcpyDialog bind:open={showScreenMirror} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
import * as adb from '$lib/core/adb/adb';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { AdbInstance } from '../../../state.svelte';
|
||||
import ScrcpyDialog from '$lib/components/scrcpy-dialog.svelte';
|
||||
|
||||
const CREATE_ENDPOINT = '/api/video-mainpage';
|
||||
const LIST_ENDPOINT = '/api/video-mainpage/list';
|
||||
|
|
@ -144,6 +145,8 @@
|
|||
let editBrewingTxtEnFile = $state<File | null>(null);
|
||||
let editSaving = $state(false);
|
||||
|
||||
let showScreenMirror = $state(false);
|
||||
|
||||
const editBrewingPlaySeconds = $derived(Math.max(1, Math.round(editBrewingRaw) - DURATION_TRIM));
|
||||
|
||||
function toIso(d: string): string {
|
||||
|
|
@ -226,7 +229,8 @@
|
|||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.png')) return addNotification('WARN:Text overlay must be .png');
|
||||
if (!file.name.toLowerCase().endsWith('.png'))
|
||||
return addNotification('WARN:Text overlay must be .png');
|
||||
set(file, URL.createObjectURL(file));
|
||||
}
|
||||
function pickBrewingTxt(e: Event) {
|
||||
|
|
@ -352,7 +356,14 @@
|
|||
const user = $auth;
|
||||
if (!user) return addNotification('ERR:Not logged in');
|
||||
if (!AdbInstance.instance) return addNotification('ERR:Connect a machine first');
|
||||
if (!name.trim() || !startDate || !mainFile || !brewingFile || !brewingTxtFile || !brewingTxtEnFile)
|
||||
if (
|
||||
!name.trim() ||
|
||||
!startDate ||
|
||||
!mainFile ||
|
||||
!brewingFile ||
|
||||
!brewingTxtFile ||
|
||||
!brewingTxtEnFile
|
||||
)
|
||||
return addNotification(
|
||||
'ERR:Need a name, start date, both videos, and both brewing text overlays (TH + EN)'
|
||||
);
|
||||
|
|
@ -427,7 +438,9 @@
|
|||
managed = data.managed ?? [];
|
||||
readonlyList = data.readonly ?? [];
|
||||
} catch (error) {
|
||||
addNotification(`ERR:Load list failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||
addNotification(
|
||||
`ERR:Load list failed: ${error instanceof Error ? error.message : 'unknown'}`
|
||||
);
|
||||
} finally {
|
||||
loadingList = false;
|
||||
}
|
||||
|
|
@ -467,7 +480,8 @@
|
|||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.png')) return addNotification('WARN:Text overlay must be .png');
|
||||
if (!file.name.toLowerCase().endsWith('.png'))
|
||||
return addNotification('WARN:Text overlay must be .png');
|
||||
if (en) editBrewingTxtEnFile = file;
|
||||
else editBrewingTxtFile = file;
|
||||
}
|
||||
|
|
@ -490,7 +504,8 @@
|
|||
fd.append('start', toIso(editStart));
|
||||
fd.append('end', editEnd ? toIso(editEnd) : 'NONE');
|
||||
if (editBrewingFile) fd.append('brewing_duration', String(editBrewingPlaySeconds));
|
||||
else if (target.brewing?.duration) fd.append('brewing_duration', String(target.brewing.duration));
|
||||
else if (target.brewing?.duration)
|
||||
fd.append('brewing_duration', String(target.brewing.duration));
|
||||
if (editMainFile) fd.append('video', editMainFile);
|
||||
if (editBrewingFile) fd.append('brewing_video', editBrewingFile);
|
||||
if (editBrewingTxtFile) fd.append('brewing_txt', editBrewingTxtFile);
|
||||
|
|
@ -548,7 +563,9 @@
|
|||
<div class="flex items-center justify-between px-8 py-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Advertisement Videos</h1>
|
||||
<p class="text-sm text-muted-foreground">Main-page & brewing-page videos, scheduled by date</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Main-page & brewing-page videos, scheduled by date
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge variant={isAdbConnected ? 'default' : 'secondary'}>
|
||||
|
|
@ -556,7 +573,13 @@
|
|||
</Badge>
|
||||
{#if !isAdbConnected}
|
||||
<Button variant="outline" onclick={connectMachine} disabled={connecting}>
|
||||
{#if connecting}<Spinner class="mr-2 h-4 w-4" />Connecting...{:else}<MonitorPlay class="mr-2 h-4 w-4" />Connect Machine{/if}
|
||||
{#if connecting}<Spinner class="mr-2 h-4 w-4" />Connecting...{:else}<MonitorPlay
|
||||
class="mr-2 h-4 w-4"
|
||||
/>Connect Machine{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={() => (showScreenMirror = true)}>
|
||||
Show Android Screen
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -574,7 +597,8 @@
|
|||
<Card.Description>
|
||||
Upload the same clip twice — the main-page version and the brewing-page
|
||||
<code class="font-mono">_long</code> version. Auto-named
|
||||
<code class="font-mono">brewing_adv<N></code> (next free 1–40, never overwrites a video in use).
|
||||
<code class="font-mono">brewing_adv<N></code> (next free 1–40, never overwrites a video
|
||||
in use).
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
|
|
@ -590,20 +614,29 @@
|
|||
</Select.Content>
|
||||
</Select.Root>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Writes to <code class="font-mono">inter/{country}/video</code>{country === 'tha' ? ' + flat video/' : ''}.
|
||||
Writes to <code class="font-mono">inter/{country}/video</code>{country === 'tha'
|
||||
? ' + flat video/'
|
||||
: ''}.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="v-name">Name</Label>
|
||||
<Input id="v-name" bind:value={name} placeholder="e.g. Bas Bew Bow Brewing" />
|
||||
<p class="text-xs text-muted-foreground">Used for the comment & the <code class="font-mono">…VideoEnable</code> variable.</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Used for the comment & the <code class="font-mono">…VideoEnable</code> variable.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="v-start" class="flex items-center gap-1.5"><CalendarDays class="h-3.5 w-3.5" /> Start date</Label>
|
||||
<Label for="v-start" class="flex items-center gap-1.5"
|
||||
><CalendarDays class="h-3.5 w-3.5" /> Start date</Label
|
||||
>
|
||||
<Input id="v-start" type="date" bind:value={startDate} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="v-end" class="flex items-center gap-1.5"><CalendarDays class="h-3.5 w-3.5" /> End date <span class="text-muted-foreground">(optional)</span></Label>
|
||||
<Label for="v-end" class="flex items-center gap-1.5"
|
||||
><CalendarDays class="h-3.5 w-3.5" /> End date
|
||||
<span class="text-muted-foreground">(optional)</span></Label
|
||||
>
|
||||
<Input id="v-end" type="date" bind:value={endDate} />
|
||||
{#if !endDate}<p class="text-xs text-muted-foreground">Blank = open-ended</p>{/if}
|
||||
</div>
|
||||
|
|
@ -615,17 +648,27 @@
|
|||
<div class="rounded-xl border p-3">
|
||||
<div class="mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||
<Film class="h-4 w-4 text-muted-foreground" /> Main-page video
|
||||
<Badge variant="outline" class="ml-auto font-mono text-[10px]">brewing_adv<N>.mp4</Badge>
|
||||
<Badge variant="outline" class="ml-auto font-mono text-[10px]"
|
||||
>brewing_adv<N>.mp4</Badge
|
||||
>
|
||||
</div>
|
||||
{#if mainFile}
|
||||
<div class="relative aspect-video overflow-hidden rounded-lg bg-black">
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={mainPreview} class="h-full w-full object-contain" muted controls></video>
|
||||
<button class="absolute right-1.5 top-1.5 rounded-full bg-black/60 p-1 text-white" onclick={clearMain}><X class="h-3.5 w-3.5" /></button>
|
||||
<video src={mainPreview} class="h-full w-full object-contain" muted controls
|
||||
></video>
|
||||
<button
|
||||
class="absolute top-1.5 right-1.5 rounded-full bg-black/60 p-1 text-white"
|
||||
onclick={clearMain}><X class="h-3.5 w-3.5" /></button
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-xs text-muted-foreground" title={mainFile.name}>{mainFile.name} · {fmtMB(mainFile.size)}</p>
|
||||
<p class="mt-2 truncate text-xs text-muted-foreground" title={mainFile.name}>
|
||||
{mainFile.name} · {fmtMB(mainFile.size)}
|
||||
</p>
|
||||
{:else}
|
||||
<label class="flex aspect-video cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground transition hover:bg-muted/50 hover:text-foreground">
|
||||
<label
|
||||
class="flex aspect-video cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickMain} />
|
||||
<Film class="mb-2 h-8 w-8" />
|
||||
<span class="text-sm font-medium">Click to select .mp4</span>
|
||||
|
|
@ -637,23 +680,40 @@
|
|||
<div class="rounded-xl border p-3">
|
||||
<div class="mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||
<CoffeeIcon class="h-4 w-4 text-muted-foreground" /> Brewing-page video
|
||||
<Badge variant="outline" class="ml-auto font-mono text-[10px]">brewing_adv<N>_long.mp4</Badge>
|
||||
<Badge variant="outline" class="ml-auto font-mono text-[10px]"
|
||||
>brewing_adv<N>_long.mp4</Badge
|
||||
>
|
||||
</div>
|
||||
{#if brewingFile}
|
||||
<div class="relative aspect-video overflow-hidden rounded-lg bg-black">
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={brewingPreview} class="h-full w-full object-contain" muted controls></video>
|
||||
<button class="absolute right-1.5 top-1.5 rounded-full bg-black/60 p-1 text-white" onclick={clearBrewing}><X class="h-3.5 w-3.5" /></button>
|
||||
<video src={brewingPreview} class="h-full w-full object-contain" muted controls
|
||||
></video>
|
||||
<button
|
||||
class="absolute top-1.5 right-1.5 rounded-full bg-black/60 p-1 text-white"
|
||||
onclick={clearBrewing}><X class="h-3.5 w-3.5" /></button
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-xs text-muted-foreground" title={brewingFile.name}>{brewingFile.name} · {fmtMB(brewingFile.size)}</p>
|
||||
<p class="mt-2 truncate text-xs text-muted-foreground" title={brewingFile.name}>
|
||||
{brewingFile.name} · {fmtMB(brewingFile.size)}
|
||||
</p>
|
||||
<p class="mt-1 flex items-center gap-1.5 text-xs font-medium">
|
||||
<Clock class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Plays {brewingPlaySeconds}s
|
||||
<span class="text-muted-foreground">(length {Math.round(brewingRawSeconds)}s − {DURATION_TRIM})</span>
|
||||
<span class="text-muted-foreground"
|
||||
>(length {Math.round(brewingRawSeconds)}s − {DURATION_TRIM})</span
|
||||
>
|
||||
</p>
|
||||
{:else}
|
||||
<label class="flex aspect-video cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground transition hover:bg-muted/50 hover:text-foreground">
|
||||
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickBrewing} />
|
||||
<label
|
||||
class="flex aspect-video cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".mp4,video/mp4"
|
||||
class="hidden"
|
||||
onchange={pickBrewing}
|
||||
/>
|
||||
<CoffeeIcon class="mb-2 h-8 w-8" />
|
||||
<span class="text-sm font-medium">Click to select _long .mp4</span>
|
||||
</label>
|
||||
|
|
@ -671,13 +731,29 @@
|
|||
{#if t.file}
|
||||
<div class="relative overflow-hidden rounded-md border bg-muted/30">
|
||||
<img src={t.preview} alt={t.label} class="h-20 w-full object-contain" />
|
||||
<button class="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white" onclick={t.clear}><X class="h-3 w-3" /></button>
|
||||
<button
|
||||
class="absolute top-1 right-1 rounded-full bg-black/60 p-0.5 text-white"
|
||||
onclick={t.clear}><X class="h-3 w-3" /></button
|
||||
>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-[10px] text-muted-foreground" title={t.file.name}>{t.file.name}</p>
|
||||
<p
|
||||
class="mt-1 truncate text-[10px] text-muted-foreground"
|
||||
title={t.file.name}
|
||||
>
|
||||
{t.file.name}
|
||||
</p>
|
||||
{:else}
|
||||
<label class="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed text-[11px] text-muted-foreground transition hover:bg-muted/50">
|
||||
<input type="file" accept=".png,image/png" class="hidden" onchange={t.pick} />
|
||||
<ImageIcon class="h-4 w-4" /> {t.label} .png
|
||||
<label
|
||||
class="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed text-[11px] text-muted-foreground transition hover:bg-muted/50"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,image/png"
|
||||
class="hidden"
|
||||
onchange={t.pick}
|
||||
/>
|
||||
<ImageIcon class="h-4 w-4" />
|
||||
{t.label} .png
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -690,13 +766,17 @@
|
|||
{#if pushProgress.active && submitting}
|
||||
<div class="space-y-1">
|
||||
<Progress value={pushProgress.percent} max={100} class="h-2" />
|
||||
<p class="text-center text-xs text-muted-foreground">Pushing {pushProgress.name} ({pushProgress.percent}%)</p>
|
||||
<p class="text-center text-xs text-muted-foreground">
|
||||
Pushing {pushProgress.name} ({pushProgress.percent}%)
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
<Card.Footer class="justify-end gap-2 border-t bg-muted/30 py-4">
|
||||
<Button size="lg" onclick={handleSubmit} disabled={submitting || !isAdbConnected}>
|
||||
{#if submitting}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload class="mr-2 h-4 w-4" />Create & Push{/if}
|
||||
{#if submitting}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload
|
||||
class="mr-2 h-4 w-4"
|
||||
/>Create & Push{/if}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
|
@ -800,7 +880,9 @@
|
|||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Existing videos</h2>
|
||||
<Button variant="ghost" size="sm" onclick={loadList} disabled={loadingList}>
|
||||
{#if loadingList}<Spinner class="mr-2 h-3.5 w-3.5" />{:else}<RefreshCw class="mr-2 h-3.5 w-3.5" />{/if}Refresh
|
||||
{#if loadingList}<Spinner class="mr-2 h-3.5 w-3.5" />{:else}<RefreshCw
|
||||
class="mr-2 h-3.5 w-3.5"
|
||||
/>{/if}Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -817,29 +899,59 @@
|
|||
<div class="bg-black">
|
||||
{#if v.main}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={videoUrl(v.main.video)} class="aspect-video w-full object-contain" preload="metadata" muted controls></video>
|
||||
<video
|
||||
src={videoUrl(v.main.video)}
|
||||
class="aspect-video w-full object-contain"
|
||||
preload="metadata"
|
||||
muted
|
||||
controls
|
||||
></video>
|
||||
{:else}
|
||||
<div class="flex aspect-video items-center justify-center text-xs text-muted-foreground">no main</div>
|
||||
<div
|
||||
class="flex aspect-video items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
no main
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bg-black">
|
||||
{#if v.brewing}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={videoUrl(v.brewing.video)} class="aspect-video w-full object-contain" preload="metadata" muted controls></video>
|
||||
<video
|
||||
src={videoUrl(v.brewing.video)}
|
||||
class="aspect-video w-full object-contain"
|
||||
preload="metadata"
|
||||
muted
|
||||
controls
|
||||
></video>
|
||||
{:else}
|
||||
<div class="flex aspect-video items-center justify-center text-xs text-muted-foreground">no brewing</div>
|
||||
<div
|
||||
class="flex aspect-video items-center justify-center text-xs text-muted-foreground"
|
||||
>
|
||||
no brewing
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold" title={v.name}>{v.name}</p>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<div
|
||||
class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<Badge variant="secondary" class="font-mono">brewing_adv{v.n}</Badge>
|
||||
<span class="flex items-center gap-1"><CalendarDays class="h-3 w-3" />{v.range_label}</span>
|
||||
{#if v.brewing?.duration}<span class="flex items-center gap-1"><Clock class="h-3 w-3" />{v.brewing.duration}s</span>{/if}
|
||||
<Badge variant={v.main ? 'default' : 'outline'} class="text-[10px]">main</Badge>
|
||||
<Badge variant={v.brewing ? 'default' : 'outline'} class="text-[10px]">brewing</Badge>
|
||||
<span class="flex items-center gap-1"
|
||||
><CalendarDays class="h-3 w-3" />{v.range_label}</span
|
||||
>
|
||||
{#if v.brewing?.duration}<span class="flex items-center gap-1"
|
||||
><Clock class="h-3 w-3" />{v.brewing.duration}s</span
|
||||
>{/if}
|
||||
<Badge variant={v.main ? 'default' : 'outline'} class="text-[10px]"
|
||||
>main</Badge
|
||||
>
|
||||
<Badge variant={v.brewing ? 'default' : 'outline'} class="text-[10px]"
|
||||
>brewing</Badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={() => openEdit(v)}>
|
||||
|
|
@ -858,19 +970,31 @@
|
|||
class="flex w-full items-center gap-2 p-3 text-sm font-semibold text-muted-foreground transition hover:bg-muted/40"
|
||||
onclick={() => (showReadonly = !showReadonly)}
|
||||
>
|
||||
<ChevronDown class="h-4 w-4 shrink-0 transition-transform {showReadonly ? 'rotate-180' : ''}" />
|
||||
<ChevronDown
|
||||
class="h-4 w-4 shrink-0 transition-transform {showReadonly ? 'rotate-180' : ''}"
|
||||
/>
|
||||
<Lock class="h-3.5 w-3.5" />
|
||||
Hand-maintained videos ({readonlyList.length})
|
||||
<Badge variant="secondary" class="ml-1 text-[10px]">read-only</Badge>
|
||||
<span class="ml-auto text-xs font-normal">{showReadonly ? 'Click to hide' : 'Click to show'}</span>
|
||||
<span class="ml-auto text-xs font-normal"
|
||||
>{showReadonly ? 'Click to hide' : 'Click to show'}</span
|
||||
>
|
||||
</button>
|
||||
{#if showReadonly}
|
||||
<div class="grid grid-cols-2 gap-3 p-3 pt-0 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{#each readonlyList as v (v.filename)}
|
||||
<div class="overflow-hidden rounded-md border bg-muted/30">
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={videoUrl(v.video)} class="aspect-video w-full bg-black object-contain" preload="metadata" muted controls></video>
|
||||
<p class="truncate px-1.5 py-1 font-mono text-[10px]" title={v.filename}>{v.filename}</p>
|
||||
<video
|
||||
src={videoUrl(v.video)}
|
||||
class="aspect-video w-full bg-black object-contain"
|
||||
preload="metadata"
|
||||
muted
|
||||
controls
|
||||
></video>
|
||||
<p class="truncate px-1.5 py-1 font-mono text-[10px]" title={v.filename}>
|
||||
{v.filename}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -887,7 +1011,8 @@
|
|||
<Dialog.Header>
|
||||
<Dialog.Title>Edit video</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if editTarget}<code class="font-mono">brewing_adv{editTarget.n}</code> · change dates, rename, or replace either clip{/if}
|
||||
{#if editTarget}<code class="font-mono">brewing_adv{editTarget.n}</code> · change dates, rename,
|
||||
or replace either clip{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
|
|
@ -909,37 +1034,77 @@
|
|||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg border p-2">
|
||||
<p class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold"><Film class="h-3.5 w-3.5 text-muted-foreground" /> Main-page</p>
|
||||
<p class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold">
|
||||
<Film class="h-3.5 w-3.5 text-muted-foreground" /> Main-page
|
||||
</p>
|
||||
{#if editTarget?.main}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={videoUrl(editTarget.main.video)} class="aspect-video w-full rounded bg-black object-contain" preload="metadata" muted controls></video>
|
||||
<video
|
||||
src={videoUrl(editTarget.main.video)}
|
||||
class="aspect-video w-full rounded bg-black object-contain"
|
||||
preload="metadata"
|
||||
muted
|
||||
controls
|
||||
></video>
|
||||
{/if}
|
||||
<label class="mt-2 flex cursor-pointer items-center justify-center gap-1.5 rounded border border-dashed p-1.5 text-xs text-muted-foreground hover:bg-muted/50">
|
||||
<label
|
||||
class="mt-2 flex cursor-pointer items-center justify-center gap-1.5 rounded border border-dashed p-1.5 text-xs text-muted-foreground hover:bg-muted/50"
|
||||
>
|
||||
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickEditMain} />
|
||||
<Upload class="h-3.5 w-3.5" /> {editMainFile ? editMainFile.name : 'Replace (optional)'}
|
||||
<Upload class="h-3.5 w-3.5" />
|
||||
{editMainFile ? editMainFile.name : 'Replace (optional)'}
|
||||
</label>
|
||||
</div>
|
||||
<div class="rounded-lg border p-2">
|
||||
<p class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold"><CoffeeIcon class="h-3.5 w-3.5 text-muted-foreground" /> Brewing-page</p>
|
||||
<p class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold">
|
||||
<CoffeeIcon class="h-3.5 w-3.5 text-muted-foreground" /> Brewing-page
|
||||
</p>
|
||||
{#if editTarget?.brewing}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={videoUrl(editTarget.brewing.video)} class="aspect-video w-full rounded bg-black object-contain" preload="metadata" muted controls></video>
|
||||
<video
|
||||
src={videoUrl(editTarget.brewing.video)}
|
||||
class="aspect-video w-full rounded bg-black object-contain"
|
||||
preload="metadata"
|
||||
muted
|
||||
controls
|
||||
></video>
|
||||
{/if}
|
||||
<label class="mt-2 flex cursor-pointer items-center justify-center gap-1.5 rounded border border-dashed p-1.5 text-xs text-muted-foreground hover:bg-muted/50">
|
||||
<label
|
||||
class="mt-2 flex cursor-pointer items-center justify-center gap-1.5 rounded border border-dashed p-1.5 text-xs text-muted-foreground hover:bg-muted/50"
|
||||
>
|
||||
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickEditBrewing} />
|
||||
<Upload class="h-3.5 w-3.5" /> {editBrewingFile ? editBrewingFile.name : 'Replace (optional)'}
|
||||
<Upload class="h-3.5 w-3.5" />
|
||||
{editBrewingFile ? editBrewingFile.name : 'Replace (optional)'}
|
||||
</label>
|
||||
{#if editBrewingFile}
|
||||
<p class="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground"><Clock class="h-3 w-3" />Plays {editBrewingPlaySeconds}s</p>
|
||||
<p class="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Clock class="h-3 w-3" />Plays {editBrewingPlaySeconds}s
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-2 grid grid-cols-2 gap-1.5">
|
||||
<label class="flex cursor-pointer items-center justify-center gap-1 rounded border border-dashed p-1.5 text-[11px] text-muted-foreground hover:bg-muted/50">
|
||||
<input type="file" accept=".png,image/png" class="hidden" onchange={(e) => pickEditTxt(e, false)} />
|
||||
<ImageIcon class="h-3 w-3" /> {editBrewingTxtFile ? 'TH ✓' : 'Text TH (.png)'}
|
||||
<label
|
||||
class="flex cursor-pointer items-center justify-center gap-1 rounded border border-dashed p-1.5 text-[11px] text-muted-foreground hover:bg-muted/50"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,image/png"
|
||||
class="hidden"
|
||||
onchange={(e) => pickEditTxt(e, false)}
|
||||
/>
|
||||
<ImageIcon class="h-3 w-3" />
|
||||
{editBrewingTxtFile ? 'TH ✓' : 'Text TH (.png)'}
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center justify-center gap-1 rounded border border-dashed p-1.5 text-[11px] text-muted-foreground hover:bg-muted/50">
|
||||
<input type="file" accept=".png,image/png" class="hidden" onchange={(e) => pickEditTxt(e, true)} />
|
||||
<ImageIcon class="h-3 w-3" /> {editBrewingTxtEnFile ? 'EN ✓' : 'Text EN (.png)'}
|
||||
<label
|
||||
class="flex cursor-pointer items-center justify-center gap-1 rounded border border-dashed p-1.5 text-[11px] text-muted-foreground hover:bg-muted/50"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,image/png"
|
||||
class="hidden"
|
||||
onchange={(e) => pickEditTxt(e, true)}
|
||||
/>
|
||||
<ImageIcon class="h-3 w-3" />
|
||||
{editBrewingTxtEnFile ? 'EN ✓' : 'Text EN (.png)'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -948,16 +1113,24 @@
|
|||
{#if pushProgress.active}
|
||||
<div class="space-y-1">
|
||||
<Progress value={pushProgress.percent} max={100} class="h-2" />
|
||||
<p class="text-center text-xs text-muted-foreground">Pushing {pushProgress.name} ({pushProgress.percent}%)</p>
|
||||
<p class="text-center text-xs text-muted-foreground">
|
||||
Pushing {pushProgress.name} ({pushProgress.percent}%)
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (editOpen = false)} disabled={editSaving}>Cancel</Button>
|
||||
<Button variant="outline" onclick={() => (editOpen = false)} disabled={editSaving}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button onclick={submitEdit} disabled={editSaving || !isAdbConnected}>
|
||||
{#if editSaving}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload class="mr-2 h-4 w-4" />Save & Push{/if}
|
||||
{#if editSaving}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload
|
||||
class="mr-2 h-4 w-4"
|
||||
/>Save & Push{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<ScrcpyDialog bind:open={showScreenMirror} />
|
||||
|
|
|
|||
38
src/routes/api/scrcpy-server/+server.ts
Normal file
38
src/routes/api/scrcpy-server/+server.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* GET /api/scrcpy-server
|
||||
*
|
||||
* Proxies the scrcpy-server binary from GitHub releases.
|
||||
* Fixes CORS issues (GitHub doesn't send Access-Control-Allow-Origin for binaries).
|
||||
* Server-side fetch has no CORS restrictions.
|
||||
*
|
||||
* Response is cached by the browser for 24 hours via Cache-Control header.
|
||||
*/
|
||||
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const GITHUB_URL =
|
||||
'https://github.com/Genymobile/scrcpy/releases/download/v2.3/scrcpy-server-v2.3';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const response = await fetch(GITHUB_URL);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[scrcpy-server] GitHub returned ${response.status}`);
|
||||
return new Response(`Failed to fetch scrcpy-server: ${response.status}`, {
|
||||
status: 502
|
||||
});
|
||||
}
|
||||
|
||||
// Stream the response directly — don't buffer the entire binary in memory on the server
|
||||
return new Response(response.body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=86400, immutable'
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[scrcpy-server] Fetch failed:', e);
|
||||
return new Response('Failed to fetch scrcpy-server from GitHub', { status: 500 });
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue