DUNGEONS-34: keep host screen awake while streaming
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
18
frontend/components/KeepHostAwakeSetting.vue
Normal file
18
frontend/components/KeepHostAwakeSetting.vue
Normal 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>
|
||||
38
frontend/composables/useKeepHostAwake.ts
Normal file
38
frontend/composables/useKeepHostAwake.ts
Normal 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 };
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,5 +173,17 @@
|
||||
"OverconstrainedError": "Этот источник недоступен. Попробуйте переднюю камеру.",
|
||||
"clipboard": "Не удалось скопировать автоматически. Выделите и скопируйте адрес выше.",
|
||||
"fullscreen": "Полноэкранный режим недоступен в этом браузере."
|
||||
},
|
||||
"wake": {
|
||||
"section": "Экран хоста",
|
||||
"title": "Не выключать экран хоста",
|
||||
"hint": "Не давать экрану этого устройства гаснуть во время трансляции. Оставьте Webka на экране. Сохраняется в этом браузере; расходует больше заряда.",
|
||||
"off": "Удержание экрана выключено или трансляция ещё не началась.",
|
||||
"waiting": "Вернитесь в Webka, чтобы экран не гас.",
|
||||
"requesting": "Запрашиваем удержание экрана…",
|
||||
"active": "Экран не гаснет во время трансляции.",
|
||||
"unavailable": "Браузер или режим энергосбережения отключил или запретил удержание экрана. Оставьте страницу видимой и повторите попытку.",
|
||||
"unsupported": "Удержание экрана недоступно. Используйте HTTPS и поддерживаемый браузер или измените автоблокировку устройства.",
|
||||
"retry": "Повторить удержание экрана"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
122
frontend/scripts/test-wake-lock.mjs
Normal file
122
frontend/scripts/test-wake-lock.mjs
Normal 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');
|
||||
});
|
||||
62
frontend/utils/screenWakeLock.ts
Normal file
62
frontend/utils/screenWakeLock.ts
Normal 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(); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user