diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index dffe0b6..b099185 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -136,6 +136,34 @@ export class ScreenManager { private styles: Record) { this.layer = document.createElement("div"); this.layer.className = "pg-usr"; + // A click needs its button to stay in the page between press and release; a redraw in between would lose it + window.addEventListener("pointerdown", this.onPress, true); + window.addEventListener("pointerup", this.onRelease, true); + window.addEventListener("pointercancel", this.onRelease, true); + } + + private pressed = false; + private refreshAfterPress = false; + private onPress = () => { this.pressed = true; }; + private onRelease = () => { + this.pressed = false; + // after the click handlers of this release have run + window.setTimeout(() => { if (this.refreshAfterPress && !this.pressed) { this.refreshAfterPress = false; this.refresh(); } }, 0); + }; + + /** Videos survive a redraw of their screen: a new element would restart the picture. */ + private movies = new Map(); + private moviesSeen = new Set(); + private movieCount = new Map(); + + private cachedMovie(key: string, make: () => HTMLElement): HTMLElement { + const n = (this.movieCount.get(key) ?? 0) + 1; + this.movieCount.set(key, n); + const id = `${this.rendering?.name ?? ""}|${key}|${n}`; + let element = this.movies.get(id); + if (!element) { element = make(); this.movies.set(id, element); } + this.moviesSeen.add(id); + return element; } has(name: string) { @@ -212,6 +240,9 @@ export class ScreenManager { } destroy() { + window.removeEventListener("pointerdown", this.onPress, true); + window.removeEventListener("pointerup", this.onRelease, true); + window.removeEventListener("pointercancel", this.onRelease, true); this.clear(); this.layer.remove(); } @@ -238,12 +269,14 @@ export class ScreenManager { inst.runners.forEach((r) => r.stop()); inst.runners = []; inst.el.remove(); + for (const id of [...this.movies.keys()]) if (id.startsWith(`${inst.name}|`)) this.movies.delete(id); if (forget) this.shown.delete(inst.name); } /** Re-evaluate every screen; coalesced so many state changes cost one rebuild per frame. */ refresh() { if (this.suspended || this.pending || !this.shown.size) return; + if (this.pressed) { this.refreshAfterPress = true; return; } this.pending = requestAnimationFrame(() => { this.pending = 0; if (this.suspended) return; @@ -316,6 +349,8 @@ export class ScreenManager { // --- rendering ----------------------------------------------------------------------- private render(inst: Instance) { + this.moviesSeen.clear(); + this.movieCount.clear(); const previousScroll = [...inst.el.querySelectorAll(".pg-sviewport")].map((e) => e.scrollTop); inst.timers.forEach((t) => window.clearTimeout(t)); inst.timers = []; @@ -336,6 +371,16 @@ export class ScreenManager { this.nodes(inst.def.body, ctx, children); inst.el.replaceChildren(...children); }); + // a video taken out of the page pauses by itself; carry on, and forget the ones no screen shows now + for (const [id, element] of [...this.movies]) { + if (this.moviesSeen.has(id)) { + const video = element instanceof HTMLVideoElement ? element : element.querySelector("video"); + if (video && video.paused && !video.ended) void video.play().catch(() => {}); + } else if (id.startsWith(`${inst.name}|`)) { + if (element instanceof HTMLVideoElement) element.pause(); + this.movies.delete(id); + } + } for (const [node, clock] of [...inst.clocks]) { if (!inst.clocksSeen.has(node)) { window.clearTimeout(clock.handle); @@ -811,6 +856,28 @@ export class ScreenManager { // --- displayables -------------------------------------------------------------------- + /** The picture of the channel that plays video; one element per shown screen (see cachedMovie). */ + private channelMovie(value: Displayable): HTMLElement { + const box = document.createElement("div"); + const size = value.kw.size; + box.style.width = Array.isArray(size) ? `${size[0]}px` : "100%"; + box.style.height = Array.isArray(size) ? `${size[1]}px` : "100%"; + const channel = String(value.kw.channel ?? "movie"); + const sync = () => { + const video = this.host.audio.mediaOf(channel); + if (video instanceof HTMLVideoElement && video.parentElement !== box) { + video.style.width = video.style.height = "100%"; + box.replaceChildren(video); + } else if (!video && box.firstChild) box.replaceChildren(); + if (box.isConnected || !box.dataset.seen) { + if (box.isConnected) box.dataset.seen = "1"; + window.setTimeout(sync, 200); + } + }; + sync(); + return box; + } + /** An element for an image name/path or for a Crop/Solid/Frame/Transform built by game code. */ private displayable(value: any, depth = 0): HTMLElement | null { if (depth > 8 || value === null || value === undefined) return null; @@ -896,27 +963,10 @@ export class ScreenManager { const src = this.host.fileUrl(pyStr(value.kw.play ?? a)); const mask = value.kw.mask ? this.host.fileUrl(pyStr(value.kw.mask)) : null; if (!src || (value.kw.mask && !mask)) throw new Error("Не найдено видео или маска Movie"); - return movieDisplayable(src, mask, value.kw.loop !== false); + return this.cachedMovie(`${src}|${mask}|${value.kw.loop !== false}`, () => movieDisplayable(src, mask, value.kw.loop !== false)); } // the picture of the channel that plays video (renpy.music.play(..., channel="movie")) - const box = document.createElement("div"); - const size = value.kw.size; - box.style.width = Array.isArray(size) ? `${size[0]}px` : "100%"; - box.style.height = Array.isArray(size) ? `${size[1]}px` : "100%"; - const channel = String(value.kw.channel ?? "movie"); - const sync = () => { - const video = this.host.audio.mediaOf(channel); - if (video instanceof HTMLVideoElement && video.parentElement !== box) { - video.style.width = video.style.height = "100%"; - box.replaceChildren(video); - } else if (!video && box.firstChild) box.replaceChildren(); - if (box.isConnected || !box.dataset.seen) { - if (box.isConnected) box.dataset.seen = "1"; - window.setTimeout(sync, 200); - } - }; - sync(); - return box; + return this.cachedMovie(`channel|${String(value.kw.channel ?? "movie")}|${JSON.stringify(value.kw.size ?? null)}`, () => this.channelMovie(value)); } case "alphamask": { const child = this.displayable(a, depth + 1); diff --git a/frontend/tests/screens.test.mjs b/frontend/tests/screens.test.mjs index af5d0a2..3e09e18 100644 --- a/frontend/tests/screens.test.mjs +++ b/frontend/tests/screens.test.mjs @@ -7,7 +7,7 @@ import { parseHTML } from "linkedom"; const { window, document } = parseHTML(""); Object.assign(globalThis, { window, document, HTMLElement: window.HTMLElement, HTMLInputElement: window.HTMLInputElement, - HTMLImageElement: window.HTMLImageElement, Image: window.Image }); + HTMLImageElement: window.HTMLImageElement, HTMLVideoElement: window.HTMLVideoElement ?? class {}, Image: window.Image }); window.setTimeout = setTimeout; window.clearTimeout = clearTimeout; globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 1); @@ -271,3 +271,26 @@ test("an `if` that only sets properties picks the image; decompiled comma action assert.equal(store.mood_clicked, 1); assert.equal(store.mood_done, true); }); + +test("a redraw keeps the playing video, and one held down button is not rebuilt under the pointer", async () => { + const movie = { w: "add", args: [{ k: "call", f: { k: "name", id: "Movie" }, a: [], kw: { play: { k: "const", v: "intro.webm" } } }], + props: {}, body: [] }; + const { manager } = setup(); + manager.screens.cutscene = { params: [], props: {}, defaults: [], body: [movie] }; + manager.show("cutscene", [], {}); + const first = manager.layer.querySelector("video"); + assert.ok(first, "the video is on the screen"); + for (let i = 0; i < 3; i++) { manager.refresh(); await wait(30); } + assert.equal(manager.layer.querySelector("video"), first, "the same