DUNGEONS-34: keep host screen awake while streaming

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-06 18:47:42 +05:00
parent ae3a3c5def
commit 1b55a86ba5
12 changed files with 307 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
const enabled = useKeepHostAwakePreference();
</script>
<template>
<label class="setting-row keep-awake-setting">
<span>
<strong>{{ $t("wake.title") }}</strong>
<span class="keep-awake-hint">{{ $t("wake.hint") }}</span>
</span>
<input v-model="enabled" type="checkbox" role="switch" />
</label>
</template>
<style scoped>
.keep-awake-setting { cursor: pointer; flex-direction: row; align-items: center; }
.keep-awake-setting strong { font-size: 0.9rem; }
.keep-awake-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 0.83rem; }
.keep-awake-setting input { width: 24px; height: 24px; min-height: 24px; padding: 0; flex-shrink: 0; accent-color: var(--accent); }
</style>

View File

@@ -0,0 +1,38 @@
import { createScreenWakeLock, type WakeStatus } from "../utils/screenWakeLock";
import type { Ref } from "vue";
export function useKeepHostAwakePreference() {
return useCookie<boolean>("webka_keep_host_awake", {
default: () => false,
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
path: "/",
});
}
export function useHostWakeLock(hosting: Ref<boolean>) {
const enabled = useKeepHostAwakePreference();
const status = ref<WakeStatus>("off");
let controller: ReturnType<typeof createScreenWakeLock> | undefined;
let unwatch: (() => void) | undefined;
const refresh = () => controller?.refresh();
onMounted(() => {
controller = createScreenWakeLock({
request: window.isSecureContext && navigator.wakeLock?.request
? () => navigator.wakeLock.request("screen")
: undefined,
visible: () => document.visibilityState === "visible",
status: (value) => { status.value = value; },
});
unwatch = watch([enabled, hosting], () => {
controller?.update(enabled.value === true && hosting.value);
}, { immediate: true, flush: "sync" });
document.addEventListener("visibilitychange", refresh);
});
onBeforeUnmount(() => {
unwatch?.();
document.removeEventListener("visibilitychange", refresh);
controller?.dispose();
});
return { status, retry: refresh };
}

View File

@@ -173,5 +173,17 @@
"OverconstrainedError": "This camera source is unavailable. Try the front camera.",
"clipboard": "Couldn’t copy automatically. Select and copy the address above.",
"fullscreen": "Fullscreen isn’t available in this browser."
},
"wake": {
"section": "Host screen",
"title": "Do not turn off the host",
"hint": "Keep this device’s screen on while hosting. Leave Webka visible. Saved in this browser; uses more battery.",
"off": "Screen keep-awake is off or hosting has not started.",
"waiting": "Return to Webka to keep the screen awake.",
"requesting": "Requesting screen keep-awake…",
"active": "Screen stays on while hosting.",
"unavailable": "The browser or power-saving settings released or denied keep-awake. Keep this page visible and try again.",
"unsupported": "Screen keep-awake is unavailable here. Use HTTPS and a supported browser, or adjust your device’s auto-lock setting.",
"retry": "Try keep-awake again"
}
}

View File

@@ -173,5 +173,17 @@
"OverconstrainedError": "Esta fuente no está disponible. Prueba la cámara frontal.",
"clipboard": "No se pudo copiar automáticamente. Selecciona y copia la dirección.",
"fullscreen": "La pantalla completa no está disponible en este navegador."
},
"wake": {
"section": "Pantalla del anfitrión",
"title": "No apagar la pantalla del anfitrión",
"hint": "Mantén encendida la pantalla de este dispositivo durante la transmisión. Deja Webka visible. Se guarda en este navegador; consume más batería.",
"off": "Mantener la pantalla encendida está desactivado o la transmisión no ha comenzado.",
"waiting": "Vuelve a Webka para mantener la pantalla encendida.",
"requesting": "Solicitando mantener la pantalla encendida…",
"active": "La pantalla permanece encendida durante la transmisión.",
"unavailable": "El navegador o el ahorro de energía ha desactivado o rechazado esta función. Mantén la página visible e inténtalo de nuevo.",
"unsupported": "Esta función no está disponible aquí. Usa HTTPS y un navegador compatible, o ajusta el bloqueo automático del dispositivo.",
"retry": "Volver a intentarlo"
}
}

View File

@@ -173,5 +173,17 @@
"OverconstrainedError": "Этот источник недоступен. Попробуйте переднюю камеру.",
"clipboard": "Не удалось скопировать автоматически. Выделите и скопируйте адрес выше.",
"fullscreen": "Полноэкранный режим недоступен в этом браузере."
},
"wake": {
"section": "Экран хоста",
"title": "Не выключать экран хоста",
"hint": "Не давать экрану этого устройства гаснуть во время трансляции. Оставьте Webka на экране. Сохраняется в этом браузере; расходует больше заряда.",
"off": "Удержание экрана выключено или трансляция ещё не началась.",
"waiting": "Вернитесь в Webka, чтобы экран не гас.",
"requesting": "Запрашиваем удержание экрана…",
"active": "Экран не гаснет во время трансляции.",
"unavailable": "Браузер или режим энергосбережения отключил или запретил удержание экрана. Оставьте страницу видимой и повторите попытку.",
"unsupported": "Удержание экрана недоступно. Используйте HTTPS и поддерживаемый браузер или измените автоблокировку устройства.",
"retry": "Повторить удержание экрана"
}
}

View File

@@ -47,6 +47,8 @@ const qr = ref(""),
const apiError = useApiError();
let timer: ReturnType<typeof setInterval>;
const live = computed(() => state.value === "live");
const hosting = computed(() => state.value !== "offline");
const { status: wakeStatus, retry: retryWakeLock } = useHostWakeLock(hosting);
const elapsed = computed(() => {
const seconds = status.value?.since
? Math.max(0, Math.floor(tick.value / 1000) - status.value.since)
@@ -239,6 +241,14 @@ async function fullscreen() {
</div>
</section>
<aside class="host-aside">
<section class="content-panel">
<div class="section-heading"><h2>{{ $t("wake.section") }}</h2></div>
<KeepHostAwakeSetting />
<p class="muted" role="status">{{ $t(`wake.${wakeStatus}`) }}</p>
<GlassButton v-if="wakeStatus === 'unavailable'" class="small-button" @click="retryWakeLock">
{{ $t("wake.retry") }}
</GlassButton>
</section>
<GlassCard class="share-panel"
><div class="section-heading">
<h2>{{ $t("host.connectDevice") }}</h2>

View File

@@ -6,6 +6,7 @@ import {
Globe2,
Palette,
ShieldCheck,
Monitor,
} from "lucide-vue-next";
const { locale, setLocale, t } = useI18n();
useHead({ title: computed(() => `${t("general.title")} · Webka`) });
@@ -113,6 +114,12 @@ const colors = [
<span class="status-dot live" />
</div>
</section>
<section class="content-panel">
<div class="section-heading">
<h2><Monitor :size="21" />{{ $t("wake.section") }}</h2>
</div>
<KeepHostAwakeSetting />
</section>
<p class="settings-footnote">{{ $t("general.savedLocally") }}</p>
</div>
</template>

View File

@@ -0,0 +1,122 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScreenWakeLock } from '../utils/screenWakeLock.ts';
const settle = () => new Promise(resolve => setImmediate(resolve));
class Lock extends EventTarget {
released = false;
async release() {
this.released = true;
this.dispatchEvent(new Event('release'));
}
}
function fixture(request) {
const locks = [];
const state = { visible: true, status: undefined, calls: 0 };
const controller = createScreenWakeLock({
visible: () => state.visible,
status: value => { state.status = value; },
request: request === null ? undefined : async () => {
state.calls++;
if (request) return request();
const lock = new Lock();
locks.push(lock);
return lock;
},
});
return { controller, locks, state };
}
test('only holds a lock when enabled and hosting; stop releases it', async () => {
const { controller, locks, state } = fixture();
controller.update(false);
assert.equal(state.calls, 0);
controller.update(true);
controller.refresh();
await settle();
assert.equal(state.calls, 1);
assert.equal(state.status, 'active');
controller.update(false);
assert.equal(locks[0].released, true);
assert.equal(state.status, 'off');
});
test('releases when hidden and reacquires on return while still hosting', async () => {
const { controller, locks, state } = fixture();
controller.update(true);
await settle();
state.visible = false;
controller.refresh();
assert.equal(locks[0].released, true);
assert.equal(state.status, 'waiting');
state.visible = true;
controller.refresh();
await settle();
assert.equal(state.calls, 2);
assert.equal(state.status, 'active');
controller.dispose();
assert.equal(locks[1].released, true);
});
for (const action of ['stop', 'dispose']) {
test(`late acquisition is released after ${action}`, async () => {
let resolve;
const lock = new Lock();
const { controller, state } = fixture(() => new Promise(done => { resolve = done; }));
controller.update(true);
if (action === 'stop') controller.update(false); else controller.dispose();
resolve(lock);
await settle();
assert.equal(lock.released, true);
assert.equal(state.calls, 1);
assert.notEqual(state.status, 'active');
});
}
test('off/on during acquisition discards stale lock and acquires only one new lock', async () => {
const resolvers = [];
const { controller, state } = fixture(() => new Promise(done => resolvers.push(done)));
controller.update(true);
controller.update(false);
controller.update(true);
const old = new Lock();
resolvers.shift()(old);
await settle();
assert.equal(old.released, true);
assert.equal(state.calls, 2);
const current = new Lock();
resolvers.shift()(current);
await settle();
assert.equal(state.status, 'active');
controller.dispose();
assert.equal(current.released, true);
});
test('denial leaves streaming alone and can be retried without an automatic loop', async () => {
let denied = true;
const { controller, state } = fixture(async () => {
if (denied) throw new Error('NotAllowedError');
return new Lock();
});
controller.update(true);
await settle();
assert.equal(state.status, 'unavailable');
assert.equal(state.calls, 1);
denied = false;
controller.refresh();
await settle();
assert.equal(state.status, 'active');
controller.dispose();
});
test('system revocation reports loss without repeatedly requesting another lock', async () => {
const { controller, state, locks } = fixture();
controller.update(true);
await settle();
await locks[0].release();
await settle();
assert.equal(state.status, 'unavailable');
assert.equal(state.calls, 1);
controller.dispose();
});
test('unsupported browser is reported without throwing', () => {
const { controller, state } = fixture(null);
controller.update(true);
assert.equal(state.status, 'unsupported');
controller.update(false);
assert.equal(state.status, 'off');
});

View File

@@ -0,0 +1,62 @@
export type WakeStatus = "off" | "waiting" | "requesting" | "active" | "unavailable" | "unsupported";
type Sentinel = Pick<WakeLockSentinel, "released" | "release" | "addEventListener">;
// Browser access is injected so asynchronous acquisition and release can be tested.
export function createScreenWakeLock(options: {
request?: () => Promise<Sentinel>;
visible: () => boolean;
status: (value: WakeStatus) => void;
}) {
let desired = false;
let disposed = false;
let pending = false;
let generation = 0;
let lock: Sentinel | null = null;
const report = (value: WakeStatus) => { if (!disposed) options.status(value); };
const wanted = () => desired && !disposed && options.visible();
function release() {
generation++;
const old = lock;
lock = null;
if (old) void old.release().catch(() => {});
}
async function sync() {
if (!wanted()) {
release();
report(desired ? "waiting" : "off");
return;
}
if (!options.request) { report("unsupported"); return; }
if (lock && !lock.released) { report("active"); return; }
if (pending) return;
pending = true;
const version = generation;
report("requesting");
try {
const acquired = await options.request();
if (version !== generation || !wanted()) {
await acquired.release();
return;
}
lock = acquired;
acquired.addEventListener("release", () => {
if (lock !== acquired) return;
lock = null;
// Do not repeatedly fight a system/battery decision to revoke the lock.
report(wanted() ? "unavailable" : desired ? "waiting" : "off");
}, { once: true });
report(acquired.released ? "unavailable" : "active");
} catch {
if (version === generation && wanted()) report("unavailable");
} finally {
pending = false;
// An off/on or hidden/visible transition may have invalidated a pending request.
if (version !== generation && wanted()) void sync();
}
}
return {
update(enabled: boolean) { desired = enabled; void sync(); },
refresh() { void sync(); },
dispose() { disposed = true; desired = false; release(); },
};
}