add: android preview to main video

Signed-off-by: pakintada@gmail.com <Pakin>
This commit is contained in:
pakintada@gmail.com 2026-07-16 15:13:24 +07:00
parent 1156b5e6c3
commit af9a4a2be5

View file

@ -27,6 +27,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';
@ -121,6 +122,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 {
@ -203,7 +206,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) {
@ -286,7 +290,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)'
);
@ -355,7 +366,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;
}
@ -395,7 +408,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;
}
@ -418,7 +432,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);
@ -476,7 +491,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 &amp; brewing-page videos, scheduled by date</p>
<p class="text-sm text-muted-foreground">
Main-page &amp; brewing-page videos, scheduled by date
</p>
</div>
<div class="flex items-center gap-3">
<Badge variant={isAdbConnected ? 'default' : 'secondary'}>
@ -484,7 +501,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>
@ -502,7 +525,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&lt;N&gt;</code> (next free 140, never overwrites a video in use).
<code class="font-mono">brewing_adv&lt;N&gt;</code> (next free 140, never overwrites a video
in use).
</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
@ -518,20 +542,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 &amp; the <code class="font-mono">…VideoEnable</code> variable.</p>
<p class="text-xs text-muted-foreground">
Used for the comment &amp; 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>
@ -543,17 +576,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&lt;N&gt;.mp4</Badge>
<Badge variant="outline" class="ml-auto font-mono text-[10px]"
>brewing_adv&lt;N&gt;.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>
@ -565,23 +608,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&lt;N&gt;_long.mp4</Badge>
<Badge variant="outline" class="ml-auto font-mono text-[10px]"
>brewing_adv&lt;N&gt;_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>
@ -599,13 +659,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>
@ -618,13 +694,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 &amp; Push{/if}
{#if submitting}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload
class="mr-2 h-4 w-4"
/>Create &amp; Push{/if}
</Button>
</Card.Footer>
</Card.Root>
@ -634,7 +714,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>
@ -651,29 +733,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)}>
@ -692,19 +804,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>
@ -721,7 +845,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>
@ -743,37 +868,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>
@ -782,16 +947,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 &amp; Push{/if}
{#if editSaving}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload
class="mr-2 h-4 w-4"
/>Save &amp; Push{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<ScrcpyDialog bind:open={showScreenMirror} />