diff --git a/package.json b/package.json index 7156bf5..adb59f0 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:appearance": "node --conditions=browser --test scripts/test_appearance.mjs", "test:window": "node --conditions=browser --test scripts/test_window_activity.mjs scripts/test_library_ui.mjs", "test:dialog": "node --conditions=browser --test scripts/test_dialog.mjs", - "test:feedback": "node --conditions=browser --test scripts/test_feedback.mjs scripts/test_game_identity.mjs scripts/test_game_library.mjs", + "test:feedback": "node --conditions=browser --test scripts/test_feedback.mjs scripts/test_game_identity.mjs scripts/test_game_library.mjs scripts/test_steam_video_loop.mjs scripts/test_gallery.mjs scripts/test_steam_trailer.mjs", "test:onboarding": "node --test scripts/test_onboarding_news.mjs", "test:sources": "node --conditions=browser --test scripts/test_sources.mjs scripts/test_hydra_sources.mjs scripts/test_download_mirrors.mjs", "test:catalog": "node --conditions=browser --test scripts/test_catalog.mjs scripts/test_store_filters.mjs", @@ -43,7 +43,8 @@ "@tauri-apps/api": "^2.12.1", "@tauri-apps/plugin-dialog": "^2.8.4", "@tauri-apps/plugin-process": "^2.4.0", - "@tauri-apps/plugin-updater": "^2.13.2" + "@tauri-apps/plugin-updater": "^2.13.2", + "hls.js": "^1.7.4" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a1394b..d9fc49d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: '@tauri-apps/plugin-updater': specifier: ^2.13.2 version: 2.13.2 + hls.js: + specifier: ^1.7.4 + version: 1.7.4 devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^6.0.0 @@ -1176,6 +1179,9 @@ packages: resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} + hls.js@1.7.4: + resolution: {integrity: sha512-3GlxGdPvT2YHvQYOt+yOzT/DEior/qJVNIylHA77kgeoBTdQQqi70GwefNEUmHQeNGyjC8U62+bruqW/Tpm+tg==} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -2461,6 +2467,8 @@ snapshots: dependencies: hookified: 1.15.1 + hls.js@1.7.4: {} + hookified@1.15.1: {} hookified@2.2.0: {} diff --git a/scripts/test_dialog.mjs b/scripts/test_dialog.mjs index 0ecc3e8..66c9189 100644 --- a/scripts/test_dialog.mjs +++ b/scripts/test_dialog.mjs @@ -94,16 +94,20 @@ test("dialog fades its surface and content without isolating the surface backdro try { assert.equal(dom.window.getComputedStyle(dialog).opacity, "1", "opening dialog must not isolate the blurred surface"); const content = dialog.querySelector(".legio-dialog-content"); + const surface = dialog.querySelector(".legio-dialog-surface"); + assert.equal(dom.window.getComputedStyle(surface).opacity, "0", "the empty panel must not appear before its content"); assert.equal(dom.window.getComputedStyle(content).opacity, "0"); advanceFrame(); advanceFrame(); flushSync(); assert.equal(dom.window.getComputedStyle(dialog).opacity, "1"); assert.equal(dom.window.getComputedStyle(content).opacity, "1"); + assert.equal(dom.window.getComputedStyle(surface).opacity, "1"); [...dialog.querySelectorAll("button")].find(button => button.textContent === "Close").click(); flushSync(); assert.equal(dom.window.getComputedStyle(dialog).opacity, "1", "closing dialog must retain access to the page backdrop"); assert.equal(dom.window.getComputedStyle(content).opacity, "0"); + assert.equal(dom.window.getComputedStyle(surface).opacity, "0", "the empty panel must not remain after its content fades"); } finally { await unmount(instance); } diff --git a/scripts/test_gallery.mjs b/scripts/test_gallery.mjs new file mode 100644 index 0000000..c0c6c62 --- /dev/null +++ b/scripts/test_gallery.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { JSDOM } from "jsdom"; +import { compile } from "svelte/compiler"; +import { createModuleLoader, dataModule } from "./test_module_loader.mjs"; +const dom = new JSDOM(""); +for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLMediaElement", "Element", "Node", "Text", "Comment", "Event", "MutationObserver"]) Object.defineProperty(globalThis, key, { configurable: true, value: dom.window[key] }); +dom.window.HTMLElement.prototype.scrollIntoView = function () {}; +globalThis.matchMedia = () => ({ matches: true }); +const { mount, unmount, flushSync } = await import("svelte"); +const stub = async source => dataModule(await createModuleLoader({}).resolveImports(compile(source, { generate: "client" }).js.code, "stub.svelte")); +const store = import.meta.resolve("svelte/store"); +const { load } = createModuleLoader({ + "src/lib/i18n": dataModule(`import { writable } from ${JSON.stringify(store)}; export const language=writable('it'); export const t=(text,_lang,args=[])=>text.replace(/\\{(\\d+)\\}/g,(_,i)=>args[i]);`), + "src/lib/features/library/SteamArtwork.svelte": await stub(''), + "src/lib/features/library/ArtworkViewer.svelte": await stub('
'), + "src/lib/services/steam-trailer": dataModule('export function loadSteamTrailer(video,movie,onError){globalThis.galleryFixture.opened.push(movie.id);globalThis.galleryFixture.onError=onError;return ()=>globalThis.galleryFixture.closed.push(movie.id);}'), +}); + +test("trailers and screenshots share a gallery and changing media stops playback", async () => { + globalThis.galleryFixture = { opened: [], closed: [] }; + const Component = (await import(await load("src/lib/features/library/ScreenshotGallery.svelte"))).default; + const target = document.createElement("div");document.body.append(target); + const instance = mount(Component, {target,props:{steamAppId:400,name:"Portal",screenshots:[{thumbnail:"https://cdn.steamstatic.com/s.jpg",full:"https://cdn.steamstatic.com/full.jpg"}],movies:[{id:922,name:"Portal Trailer",thumbnail:"https://cdn.steamstatic.com/m.jpg",hls:"https://video.akamai.steamstatic.com/movie.m3u8",mp4:null,webm:null}]}}); + try { + flushSync(); + const video = target.querySelector("video"); + assert.ok(video, "the first trailer appears alongside the screenshots"); + assert.equal(video.controls, false); + assert.equal(video.autoplay, false); + const screenshots = [...target.querySelectorAll("button")].filter(button=>button.getAttribute("aria-label")==="Mostra schermata 1"); + screenshots[0].click();flushSync(); + assert.equal(target.querySelector("video"), null); + assert.deepEqual(globalThis.galleryFixture.closed, [922]); + [...target.querySelectorAll("button")].find(button=>button.getAttribute("aria-label")==="Ingrandisci schermata 1 di 1").click();flushSync(); + assert.equal(target.querySelector('[role="dialog"]').dataset.index, "0", "trailer slots must not offset screenshot zoom indices"); + target.querySelector('[role="dialog"] button').click();flushSync(); + [...target.querySelectorAll("button")].find(button=>button.getAttribute("aria-label")==="Mostra trailer: Portal Trailer").click();flushSync(); + assert.ok(target.querySelector("video")); + } finally { await unmount(instance);target.remove();delete globalThis.galleryFixture; } +}); + + +test("custom trailer controls play, seek, mute and change volume", async () => { + globalThis.galleryFixture = { opened: [], closed: [] }; + const Component = (await import(await load("src/lib/features/library/TrailerPlayer.svelte"))).default; + const target = document.createElement("div"); document.body.append(target); + const instance = mount(Component, {target,props:{movie:{id:922,name:"Portal",thumbnail:null,hls:null,mp4:"https://cdn.steamstatic.com/movie.mp4",webm:null}}}); + let plays=0;let pauses=0; + try { + flushSync();const video=target.querySelector("video"); + video.play=async()=>{plays++;video.dispatchEvent(new Event("play"));}; + video.pause=()=>{pauses++;video.dispatchEvent(new Event("pause"));}; + Object.defineProperty(video,"duration",{value:90}); + video.dispatchEvent(new Event("loadedmetadata"));flushSync(); + const button=name=>[...target.querySelectorAll("button")].find(button=>button.getAttribute("aria-label")===name); + assert.equal(video.controls,false); + assert.ok(button("Riproduci trailer")); + button("Riproduci trailer").click();await new Promise(resolve=>setImmediate(resolve));flushSync(); + assert.equal(plays,1);button("Metti in pausa").click();flushSync();assert.equal(pauses,1); + const seek=target.querySelector('input[aria-label="Avanzamento del trailer"]'); + assert.equal(seek.disabled,false);seek.value="45";seek.dispatchEvent(new Event("input",{bubbles:true}));flushSync(); + assert.equal(video.currentTime,45);assert.ok(target.textContent.includes("0:45 / 1:30")); + button("Disattiva audio").click();flushSync();assert.equal(video.muted,true); + const volume=target.querySelector('input[aria-label="Volume del trailer"]');volume.value="0.4"; + volume.dispatchEvent(new Event("input",{bubbles:true}));flushSync();assert.equal(video.volume,0.4);assert.equal(video.muted,false); + } finally { await unmount(instance);target.remove();delete globalThis.galleryFixture; } +}); + + +test("failed trailers show a useful retry action and replace the failed player", async () => { + globalThis.galleryFixture={opened:[],closed:[]}; + const Component=(await import(await load("src/lib/features/library/TrailerPlayer.svelte"))).default; + const target=document.createElement("div");document.body.append(target); + const instance=mount(Component,{target,props:{movie:{id:7,name:"Test",thumbnail:null,hls:"https://video.akamai.steamstatic.com/test.m3u8",mp4:null,webm:null}}}); + try { + flushSync(); + globalThis.galleryFixture.onError("Connection reset by peer");flushSync(); + assert.ok(target.querySelector('[role="alert"]')); + assert.equal(target.textContent.includes("Connection reset by peer"),false,"raw network errors must not replace the page or reach the user"); + const retry=[...target.querySelectorAll("button")].find(button=>button.textContent.trim()==="Riprova"); + assert.ok(retry);retry.click();flushSync(); + assert.equal(target.querySelector('[role="alert"]'),null); + assert.deepEqual(globalThis.galleryFixture.opened,[7,7]); + assert.deepEqual(globalThis.galleryFixture.closed,[7]); + } finally {await unmount(instance);target.remove();delete globalThis.galleryFixture;} +}); diff --git a/scripts/test_game_library.mjs b/scripts/test_game_library.mjs index b53634f..4c63acd 100644 --- a/scripts/test_game_library.mjs +++ b/scripts/test_game_library.mjs @@ -11,6 +11,8 @@ for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputE const { mount, unmount, flushSync } = await import("svelte"); const { writable } = await import("svelte/store"); globalThis.matchMedia = () => ({ matches: false }); +dom.window.HTMLCanvasElement.prototype.getContext = () => null; +dom.window.HTMLMediaElement.prototype.pause = function () {}; const settle = async () => { for (let i = 0; i < 5; i++) { await new Promise(resolve => setImmediate(resolve)); flushSync(); } }; test("uninstall previews the exact directory and requires a second confirmation", async () => { @@ -144,6 +146,139 @@ test("Steam descriptions keep link text without links or scripts", async () => { delete globalThis.DOMParser; }); +test("Steam description images and GIFs survive sanitization", async () => { + const { load } = createModuleLoader({}); + const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts")); + globalThis.DOMParser = dom.window.DOMParser; + const target = document.createElement("div"); + try { + const gif = "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/400/demo.gif"; + const image = "https://images.steamusercontent.com/demo.png"; + const renderer = renderSteamDescription(target, `

GameplayGameplay preview

`); + const images = [...target.querySelectorAll("img")]; + assert.equal(images.length, 2); + assert.equal(images[0].src, gif); + assert.equal(images[0].alt, "Gameplay preview"); + assert.equal(images[1].src, image); + assert.equal(images[1].alt, ""); + assert.equal(images[0].getAttribute("loading"), "lazy"); + assert.equal(images[0].getAttribute("referrerpolicy"), "no-referrer"); + assert.equal(target.querySelector("[onerror],[style],[srcset],a"), null); + renderer.update("

New description

"); + assert.equal(target.querySelector("img"), null); + assert.equal(target.textContent, "New description"); + } finally { delete globalThis.DOMParser; } +}); + +test("Steam GIFs suspend decoding outside the viewport and resume near it", async () => { + const { load } = createModuleLoader({}); + const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts")); + globalThis.DOMParser = dom.window.DOMParser; + const observers = []; + globalThis.IntersectionObserver = class { + constructor(callback, options) { this.callback = callback; this.options = options; this.targets = new Set(); observers.push(this); } + observe(target) { this.targets.add(target); } + disconnect() { this.targets.clear(); this.disconnected = true; } + intersect(target, isIntersecting) { this.callback([{ target, isIntersecting }]); } + }; + const target = document.createElement("div"); + try { + const renderer = renderSteamDescription(target, ''); + const [gif, image] = target.querySelectorAll("img"); + assert.equal(observers.length, 1); + assert.equal(observers[0].options.rootMargin, "300px 0px"); + assert.deepEqual([...observers[0].targets], [gif]); + Object.defineProperties(gif, { complete: { configurable: true, value: true }, naturalWidth: { configurable: true, value: 640 }, naturalHeight: { configurable: true, value: 360 } }); + observers[0].intersect(gif, false); + gif.dispatchEvent(new dom.window.Event("load")); + assert.equal(gif.hasAttribute("src"), false, "offscreen GIFs release their animated source after loading"); + assert.equal(gif.getAttribute("width"), "640"); + assert.equal(gif.getAttribute("height"), "360"); + observers[0].intersect(gif, true); + assert.equal(gif.src, "https://shared.fastly.steamstatic.com/demo.gif"); + assert.equal(image.src, "https://shared.fastly.steamstatic.com/demo.png", "static images stay loaded"); + renderer.destroy(); + assert.equal(observers[0].disconnected, true); + } finally { delete globalThis.DOMParser; delete globalThis.IntersectionObserver; } +}); + +test("Steam descriptions reject untrusted image sources", async () => { + const { load } = createModuleLoader({}); + const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts")); + globalThis.DOMParser = dom.window.DOMParser; + const target = document.createElement("div"); + try { + for (const src of ["http://cdn.steamstatic.com/a.gif", "https://steamstatic.com.evil.test/a.png", "https://evilsteamstatic.com/a.png", "https://user@steamstatic.com/a.png", "https://steamstatic.com:444/a.png", "https://evil.test/a.png", "file:///tmp/a.png", "data:image/svg+xml,test", "javascript:alert(1)", "//cdn.steamstatic.com/a.png", "/image.png", ""]) { + renderSteamDescription(target, `

Safe text

`); + assert.equal(target.querySelector("img"), null, src); + assert.equal(target.textContent, "Safe text"); + } + } finally { delete globalThis.DOMParser; } +}); + +test("Steam descriptions retain PEAK video animations and codec fallbacks", async () => { + const { load } = createModuleLoader({}); + const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts")); + globalThis.DOMParser = dom.window.DOMParser; + const target = document.createElement("div"); + try { + const base = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/3527290/extras/e82d11d9221b2a65adeb68d8cdbc3bd2"; + renderSteamDescription(target, ``); + const video = target.querySelector("video"); + assert.ok(video, "Steam now delivers GIF-like animations as video elements"); + assert.equal(video.autoplay, true); + assert.equal(video.muted, true); + assert.equal(video.defaultMuted, true); + assert.equal(video.loop, true); + assert.equal(video.playsInline, true); + assert.equal(video.controls, false); + assert.equal(video.poster, `${base}.poster.avif`); + assert.equal(video.width, 600); + assert.equal(video.height, 170); + assert.deepEqual([...video.querySelectorAll("source")].map(source => [source.src, source.type]), [[`${base}.webm`, "video/webm; codecs=vp9"], [`${base}.mp4`, "video/mp4"]]); + assert.equal(target.querySelector("[onplay],[style]"), null); + } finally { delete globalThis.DOMParser; } +}); + +test("Steam video animations validate every source and poster", async () => { + const { load } = createModuleLoader({}); + const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts")); + globalThis.DOMParser = dom.window.DOMParser; + const target = document.createElement("div"); + try { + const safe = "https://shared.fastly.steamstatic.com/animation.webm"; + renderSteamDescription(target, ``); + const video = target.querySelector("video"); + assert.ok(video); + assert.equal(video.hasAttribute("src"), false); + assert.equal(video.hasAttribute("poster"), false); + assert.equal(video.querySelectorAll("source").length, 1); + assert.equal(video.querySelector("source").src, safe); + assert.equal(target.querySelector("[onerror]"), null); + renderSteamDescription(target, '

Safe text

'); + assert.equal(target.querySelector("video,source"), null); + assert.equal(target.textContent, "Safe text"); + renderSteamDescription(target, ``); + assert.equal(target.querySelector("video").src, safe); + } finally { delete globalThis.DOMParser; } +}); + +test("Steam video animations respect reduced motion", async () => { + const { load } = createModuleLoader({}); + const { renderSteamDescription } = await import(await load("src/lib/features/library/steam-description.ts")); + globalThis.DOMParser = dom.window.DOMParser; + const previousMatchMedia = globalThis.matchMedia; + globalThis.matchMedia = () => ({ matches: true }); + try { + const target = document.createElement("div"); + renderSteamDescription(target, ''); + const video = target.querySelector("video"); + assert.ok(video); + assert.equal(video.autoplay, false); + assert.equal(video.controls, false); + } finally { globalThis.matchMedia = previousMatchMedia; delete globalThis.DOMParser; } +}); + test("library removal stops at the first failed game and keeps the library", async () => { const calls = []; globalThis.removalFixture = { calls, fail: "b", libraries: [] }; diff --git a/scripts/test_module_loader.mjs b/scripts/test_module_loader.mjs index df927e7..3de3d60 100644 --- a/scripts/test_module_loader.mjs +++ b/scripts/test_module_loader.mjs @@ -23,7 +23,7 @@ export function createModuleLoader(mocks) { const modules = new Map(); async function resolveImports(code, path) { - for (const match of [...code.matchAll(/(?:from\s*|import\s*)["']([^"']+)["']/g)]) { + for (const match of [...code.matchAll(/(?:from\s*|import\s*|import\(\s*)["']([^"']+)["']/g)]) { const specifier = match[1]; const resolved = specifier.startsWith(".") ? new URL(specifier, `file:///${path}`).pathname.slice(1) : null; const url = resolved === null diff --git a/scripts/test_steam_trailer.mjs b/scripts/test_steam_trailer.mjs new file mode 100644 index 0000000..88ead54 --- /dev/null +++ b/scripts/test_steam_trailer.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { JSDOM } from "jsdom"; +import { createModuleLoader, dataModule } from "./test_module_loader.mjs"; +const dom=new JSDOM(''); +const {load}=createModuleLoader({"src/lib/i18n":dataModule("export const t=value=>value;"),"hls.js":dataModule(`export default class Hls { + static Events={ERROR:"error"};static ErrorTypes={NETWORK_ERROR:"network",MEDIA_ERROR:"media"};static isSupported(){return true;} + constructor(){globalThis.trailerFixture.hls=this;this.restarts=[];this.recoveries=0;this.destroyed=0;} + on(_event,callback){this.callback=callback;}loadSource(){}attachMedia(){}startLoad(time){this.restarts.push(time);}recoverMediaError(){this.recoveries++;}destroy(){this.destroyed++;} +}`)}); +const {loadSteamTrailer}=await import(await load("src/lib/services/steam-trailer.ts")); +const movie={id:1,name:"Test",thumbnail:null,hls:"https://video.akamai.steamstatic.com/movie.m3u8",mp4:null,webm:null}; +const settle=()=>new Promise(resolve=>setImmediate(resolve)); +function video(){const v=dom.window.document.createElement("video");v.pause=()=>{};v.load=()=>{};return v;} + +test("stream resets retry twice at the current position, then fail once",async()=>{ + globalThis.trailerFixture={};const v=video();v.currentTime=12.5;const errors=[]; + const destroy=loadSteamTrailer(v,movie,message=>errors.push(message));await settle(); + try { + const hls=globalThis.trailerFixture.hls; + v.dispatchEvent(new dom.window.Event("error"));assert.equal(errors.length,0,"HLS owns recovery while attached"); + for(let i=0;i<2;i++)hls.callback("error",{fatal:true,type:"network",details:"Connection reset by peer"}); + assert.deepEqual(hls.restarts,[12.5,12.5]);assert.equal(errors.length,0); + hls.callback("error",{fatal:true,type:"network",details:"Connection reset by peer"}); + hls.callback("error",{fatal:true,type:"network",details:"Connection reset by peer"}); + assert.equal(errors.length,1);assert.equal(hls.destroyed,1); + } finally {destroy();delete globalThis.trailerFixture;} +}); +test("decoder recovery shares the finite recovery budget",async()=>{ + globalThis.trailerFixture={};const errors=[];const destroy=loadSteamTrailer(video(),movie,message=>errors.push(message));await settle(); + try { + const hls=globalThis.trailerFixture.hls; + hls.callback("error",{fatal:true,type:"media",details:"decoder"});assert.equal(hls.recoveries,1); + hls.callback("error",{fatal:true,type:"network",details:"reset"});assert.equal(hls.restarts.length,1); + hls.callback("error",{fatal:true,type:"media",details:"decoder"});assert.equal(errors.length,1); + } finally {destroy();delete globalThis.trailerFixture;} +}); +test("switching trailers cancels pending setup and ignores late failures",async()=>{ + globalThis.trailerFixture={};const errors=[];const v=video();const destroy=loadSteamTrailer(v,movie,message=>errors.push(message));destroy();await settle(); + assert.equal(globalThis.trailerFixture.hls,undefined);v.dispatchEvent(new dom.window.Event("error"));assert.deepEqual(errors,[]);delete globalThis.trailerFixture; +}); diff --git a/scripts/test_steam_video_loop.mjs b/scripts/test_steam_video_loop.mjs new file mode 100644 index 0000000..3b68f6c --- /dev/null +++ b/scripts/test_steam_video_loop.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { JSDOM } from "jsdom"; +import { createModuleLoader } from "./test_module_loader.mjs"; + +const dom = new JSDOM(""); +Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document }); +const frames = new Map(); +let frameId = 0; +globalThis.requestAnimationFrame = callback => { frames.set(++frameId, callback); return frameId; }; +globalThis.cancelAnimationFrame = id => frames.delete(id); +dom.window.HTMLMediaElement.prototype.pause = function () {}; +const originalContext = dom.window.HTMLCanvasElement.prototype.getContext; +dom.window.HTMLCanvasElement.prototype.getContext = () => ({ drawImage() {} }); +const { load } = createModuleLoader({}); +const { createSteamVideoSurface } = await import(await load("src/lib/features/library/steam-description-video.ts")); + +function fixture(frameCallbacks = true) { + const video = document.createElement("video"); + const callbacks = new Map(); + let id = 0; + if (frameCallbacks) { + video.requestVideoFrameCallback = callback => { callbacks.set(++id, callback); return id; }; + video.cancelVideoFrameCallback = value => callbacks.delete(value); + } + Object.defineProperties(video, { videoWidth: { value: 600 }, videoHeight: { value: 170 }, readyState: { value: 4, configurable: true }, seeking: { value: false, configurable: true }, paused: { value: false, configurable: true } }); + const surface = createSteamVideoSurface(video); + document.body.append(surface.element); + const canvas = surface.element.querySelector("canvas"); + return { video, surface, canvas, callbacks, frame() { + const queue = frameCallbacks ? callbacks : frames; + const pending = [...queue.values()]; queue.clear(); + for (const callback of pending) callback(0, {}); + }, cleanup() { surface.destroy(); surface.element.remove(); frames.clear(); } }; +} + +test("a video loop holds the last frame while the native player seeks", () => { + const f = fixture(); + try { + assert.equal(f.canvas.hidden, true, "the native poster remains visible before a decoded frame"); + f.frame(); + assert.equal(f.canvas.hidden, false); + assert.equal(f.canvas.width, 600); + assert.equal(f.canvas.height, 170); + Object.defineProperty(f.video, "seeking", { value: true }); + Object.defineProperty(f.video, "readyState", { value: 1 }); + f.frame(); + assert.equal(f.canvas.hidden, false, "the decoded surface must remain visible over the native player's empty loop frame"); + assert.equal(f.canvas.width, 600, "seeking must not clear the retained canvas"); + Object.defineProperty(f.video, "seeking", { value: false }); + Object.defineProperty(f.video, "readyState", { value: 4 }); + f.frame(); + assert.equal(f.canvas.hidden, false); + f.surface.destroy(); + assert.equal(f.callbacks.size, 0, "unmounting cancels pending frame callbacks"); + } finally { f.cleanup(); } +}); + +test("older players paint while playing and stop their fallback clock on pause", () => { + const f = fixture(false); + try { + f.video.dispatchEvent(new dom.window.Event("playing")); + f.frame(); + assert.equal(f.canvas.hidden, false); + f.video.dispatchEvent(new dom.window.Event("pause")); + assert.equal(frames.size, 0); + f.video.dispatchEvent(new dom.window.Event("playing")); + assert.equal(frames.size, 1); + f.surface.destroy(); + assert.equal(frames.size, 0); + f.video.dispatchEvent(new dom.window.Event("playing")); + assert.equal(frames.size, 0, "destroy removes playback listeners"); + } finally { f.cleanup(); } +}); + +test("players without a canvas context retain native playback", () => { + dom.window.HTMLCanvasElement.prototype.getContext = () => null; + try { + const f = fixture(); + try { + assert.equal(f.surface.element.contains(f.video), true); + assert.equal(f.canvas, null); + assert.equal(f.callbacks.size, 0); + } finally { f.cleanup(); } + } finally { dom.window.HTMLCanvasElement.prototype.getContext = originalContext; } +}); diff --git a/src-tauri/src/steam_details.rs b/src-tauri/src/steam_details.rs index 4faf20b..f7622c5 100644 --- a/src-tauri/src/steam_details.rs +++ b/src-tauri/src/steam_details.rs @@ -17,6 +17,7 @@ const CACHE_LIMIT: u32 = 128; const MAX_DETAILS_BYTES: usize = 512 * 1024; const STALE_SECONDS: i64 = 3 * 24 * 60 * 60; const MAX_SCREENSHOTS: usize = 8; +const MAX_MOVIES: usize = 8; #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -65,6 +66,18 @@ pub struct Assets { pub capsule: Option, pub background: Option, pub screenshots: Vec, + #[serde(default)] + pub movies: Vec, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Movie { + pub id: u64, + pub name: String, + pub thumbnail: Option, + pub hls: Option, + pub mp4: Option, + pub webm: Option, } #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -170,6 +183,43 @@ struct SteamData { background: Option, #[serde(default)] screenshots: Vec, + #[serde(default)] + movies: Vec, +} + +#[derive(Deserialize)] +struct SteamMovie { + id: Option, + name: Option, + thumbnail: Option, + hls_h264: Option, + #[serde(default)] + mp4: HashMap, + #[serde(default)] + webm: HashMap, +} + +fn steam_movie(movie: SteamMovie) -> Option { + let id = movie.id?; + let file = |formats: &HashMap| { + ["max", "480"] + .iter() + .find_map(|key| steam_image(formats.get(*key).cloned())) + }; + let hls = steam_image(movie.hls_h264); + let mp4 = file(&movie.mp4); + let webm = file(&movie.webm); + if hls.is_none() && mp4.is_none() && webm.is_none() { + return None; + } + Some(Movie { + id, + name: movie.name.unwrap_or_default(), + thumbnail: steam_image(movie.thumbnail), + hls, + mp4, + webm, + }) } #[derive(Deserialize)] @@ -305,6 +355,12 @@ fn decode(bytes: &[u8], app_id: u32) -> Result { header: steam_image(data.header_image), capsule: steam_image(data.capsule_image), background: steam_image(data.background), + movies: data + .movies + .into_iter() + .filter_map(steam_movie) + .take(MAX_MOVIES) + .collect(), screenshots: data .screenshots .into_iter() @@ -397,6 +453,14 @@ fn cached_localized( details.assets.header = steam_image(details.assets.header); details.assets.capsule = steam_image(details.assets.capsule); details.assets.background = steam_image(details.assets.background); + details.assets.movies.truncate(MAX_MOVIES); + details.assets.movies.retain_mut(|movie| { + movie.thumbnail = steam_image(movie.thumbnail.take()); + movie.hls = steam_image(movie.hls.take()); + movie.mp4 = steam_image(movie.mp4.take()); + movie.webm = steam_image(movie.webm.take()); + movie.hls.is_some() || movie.mp4.is_some() || movie.webm.is_some() + }); details.assets.screenshots.truncate(MAX_SCREENSHOTS); for image in &mut details.assets.screenshots { image.thumbnail = steam_image(image.thumbnail.take()); @@ -437,12 +501,16 @@ fn cached( ) } -/// Returns the details and whether they predate the description and requirements fields. +/// Returns the details and whether their cached schema needs refreshing. fn decode_cached(json: &str, app_id: u32) -> Result<(SteamDetails, bool), String> { let value: serde_json::Value = serde_json::from_str(json) .map_err(|error| format!("Invalid cached Steam details: {error}"))?; - let incomplete = - value.get("detailedDescription").is_none() || value.get("systemRequirements").is_none(); + let incomplete = value.get("detailedDescription").is_none() + || value.get("systemRequirements").is_none() + || value + .get("assets") + .and_then(|assets| assets.get("movies")) + .is_none(); let details: SteamDetails = serde_json::from_value(value) .map_err(|error| format!("Invalid cached Steam details: {error}"))?; if details.steam_app_id != app_id { @@ -1037,11 +1105,45 @@ mod tests { header: None, capsule: None, background: None, - screenshots: vec![] + screenshots: vec![], + movies: vec![], } ); } + #[test] + fn decodes_trailers_and_rejects_untrusted_streams() { + let mut value: serde_json::Value = serde_json::from_slice(&fixture(400)).unwrap(); + value["400"]["data"]["movies"] = serde_json::json!([ + {"id": 922, "name": "Portal Trailer", "thumbnail": "https://shared.fastly.steamstatic.com/movie.jpg", "hls_h264": "https://video.akamai.steamstatic.com/trailer.m3u8"}, + {"id": 923, "name": "Legacy", "mp4": {"max": "https://cdn.steamstatic.com/movie.mp4"}}, + {"id": 924, "name": "Rejected", "hls_h264": "https://steamstatic.com.evil.test/movie.m3u8"} + ]); + let details = decode(&serde_json::to_vec(&value).unwrap(), 400).unwrap(); + let serialized = serde_json::to_value(details).unwrap(); + let movies = serialized["assets"]["movies"] + .as_array() + .expect("trailers reach the gallery"); + assert_eq!(movies.len(), 2); + assert_eq!(movies[0]["id"], 922); + assert_eq!( + movies[0]["hls"], + "https://video.akamai.steamstatic.com/trailer.m3u8" + ); + assert_eq!(movies[1]["mp4"], "https://cdn.steamstatic.com/movie.mp4"); + } + + #[test] + fn cached_details_without_trailers_need_refresh() { + let details = decode(&fixture(400), 400).unwrap(); + let mut value = serde_json::to_value(details).unwrap(); + value["assets"].as_object_mut().unwrap().remove("movies"); + value["detailedDescription"] = serde_json::Value::Null; + value["systemRequirements"] = serde_json::Value::Null; + let (_, incomplete) = decode_cached(&value.to_string(), 400).unwrap(); + assert!(incomplete); + } + #[test] fn rejects_unavailable_mismatched_and_malformed_details() { assert_eq!( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b851759..a288b0f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -24,7 +24,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https:; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'none'; frame-src 'none'; form-action 'none'" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https:; media-src 'self' blob: https://steamstatic.com https://*.steamstatic.com https://steamusercontent.com https://*.steamusercontent.com https://steampowered.com https://*.steampowered.com https://steamcdn-a.akamaihd.net https://steamuserimages-a.akamaihd.net; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://steamstatic.com https://*.steamstatic.com https://steamusercontent.com https://*.steamusercontent.com https://steampowered.com https://*.steampowered.com; object-src 'none'; base-uri 'none'; frame-src 'none'; form-action 'none'" } }, "bundle": { diff --git a/src/app.css b/src/app.css index afa4cac..f0fc89a 100644 --- a/src/app.css +++ b/src/app.css @@ -177,10 +177,15 @@ dialog.legio-dialog > .legio-dialog-surface { inset: 0; border-radius: inherit; pointer-events: none; + opacity: 0; + transition: opacity 180ms ease; background-color: color-mix(in srgb, var(--legio-surface) calc(var(--legio-dialog-opacity) * 100%), transparent); -webkit-backdrop-filter: var(--legio-dialog-blur); backdrop-filter: var(--legio-dialog-blur); } +dialog.legio-dialog[data-visible="true"] > .legio-dialog-surface { + opacity: 1; +} input[type="radio"], input[type="checkbox"], input[type="range"] { accent-color: var(--legio-accent); } @layer base { diff --git a/src/lib/components/ui/Icon.svelte b/src/lib/components/ui/Icon.svelte index f75b021..ccf09a4 100644 --- a/src/lib/components/ui/Icon.svelte +++ b/src/lib/components/ui/Icon.svelte @@ -46,6 +46,8 @@ import TriangleAlert from "@lucide/svelte/icons/triangle-alert"; import Upload from "@lucide/svelte/icons/upload"; import Wrench from "@lucide/svelte/icons/wrench"; + import Volume2 from "@lucide/svelte/icons/volume-2"; + import VolumeX from "@lucide/svelte/icons/volume-x"; import X from "@lucide/svelte/icons/x"; const icons = { @@ -97,6 +99,8 @@ "thumbs-up": ThumbsUp, "funnel": Funnel, "heart": Heart, + "volume": Volume2, + "muted": VolumeX, }; export type IconName = keyof typeof icons; diff --git a/src/lib/features/library/ScreenshotGallery.svelte b/src/lib/features/library/ScreenshotGallery.svelte index 88a98ea..c65ba98 100644 --- a/src/lib/features/library/ScreenshotGallery.svelte +++ b/src/lib/features/library/ScreenshotGallery.svelte @@ -1,6 +1,8 @@
+ {#if activeMovie !== null} + {#key activeMovie.id}{/key} + {:else} + {/if}
+ {#each movies as movie, position (movie.id)} + + {/each} {#each screenshots as screenshot, position (screenshot.thumbnail ?? screenshot.full)}
-{#if open} +{#if open && activeMovie === null} select(movies.length + index)} onClose={() => (open = false)} /> {/if} diff --git a/src/lib/features/library/SteamMetadata.svelte b/src/lib/features/library/SteamMetadata.svelte index 51ebf8d..6cafffa 100644 --- a/src/lib/features/library/SteamMetadata.svelte +++ b/src/lib/features/library/SteamMetadata.svelte @@ -39,8 +39,8 @@ {#if details === null}

{t("Nessun dato Steam disponibile per questo titolo.", $language)}

{:else} - {#if screenshots.length > 0} - + {#if screenshots.length > 0 || (details.assets.movies?.length ?? 0) > 0} + {/if} {#if description !== null} diff --git a/src/lib/features/library/TrailerPlayer.svelte b/src/lib/features/library/TrailerPlayer.svelte new file mode 100644 index 0000000..a942bd7 --- /dev/null +++ b/src/lib/features/library/TrailerPlayer.svelte @@ -0,0 +1,177 @@ + + + { fullscreen = document.fullscreenElement === player; }} onpointerup={() => { scrubbing = false; }} onpointercancel={() => { scrubbing = false; }} /> +
+ +
+ + + {#if error !== null} +
+ + + +
+ {:else if buffering || starting} +
+ {:else} + + {/if} + +
+ { scrubbing = true; }} onchange={() => { scrubbing = false; }} oninput={event => seek(Number(event.currentTarget.value))} /> +
+ + + +
+ + changeVolume(Number(event.currentTarget.value))} /> +
+ {formatTime(time)} / {formatTime(duration)} + + +
+
+
+ {#if actionError}{/if} +
+ diff --git a/src/lib/features/library/steam-description-video.ts b/src/lib/features/library/steam-description-video.ts new file mode 100644 index 0000000..3f485a7 --- /dev/null +++ b/src/lib/features/library/steam-description-video.ts @@ -0,0 +1,61 @@ +export function createSteamVideoSurface(video: HTMLVideoElement): { element: HTMLElement; destroy: () => void } { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (context === null) return { element: video, destroy: () => video.pause() }; + + const element = document.createElement("span"); + element.className = "relative my-3 block w-fit max-w-full overflow-hidden rounded-lg"; + video.className = "block h-auto max-w-full"; + canvas.className = "pointer-events-none absolute inset-0 size-full"; + canvas.setAttribute("aria-hidden", "true"); + canvas.hidden = true; + element.append(video, canvas); + + const nativeFrames = typeof video.requestVideoFrameCallback === "function"; + let frame: number | undefined; + let destroyed = false; + + function paint(): void { + if (video.seeking || video.readyState < 2 || video.videoWidth === 0 || video.videoHeight === 0) return; + if (canvas.width !== video.videoWidth) canvas.width = video.videoWidth; + if (canvas.height !== video.videoHeight) canvas.height = video.videoHeight; + context!.drawImage(video, 0, 0, canvas.width, canvas.height); + // Retain the decoded frame while the native video surface resets at a loop. + canvas.hidden = false; + } + + function schedule(): void { + if (destroyed || frame !== undefined) return; + const next = () => { + frame = undefined; + if (destroyed) return; + paint(); + if (nativeFrames || !video.paused) schedule(); + }; + frame = nativeFrames ? video.requestVideoFrameCallback(next) : requestAnimationFrame(next); + } + + function cancel(): void { + if (frame === undefined) return; + if (nativeFrames) video.cancelVideoFrameCallback(frame); + else cancelAnimationFrame(frame); + frame = undefined; + } + + if (nativeFrames) schedule(); + else { + video.addEventListener("loadeddata", paint); + video.addEventListener("playing", schedule); + video.addEventListener("pause", cancel); + } + + return { element, destroy() { + if (destroyed) return; + destroyed = true; + cancel(); + video.removeEventListener("loadeddata", paint); + video.removeEventListener("playing", schedule); + video.removeEventListener("pause", cancel); + video.pause(); + } }; +} diff --git a/src/lib/features/library/steam-description.ts b/src/lib/features/library/steam-description.ts index 8611e2d..ab8905e 100644 --- a/src/lib/features/library/steam-description.ts +++ b/src/lib/features/library/steam-description.ts @@ -1,12 +1,78 @@ +import { createSteamVideoSurface } from "./steam-description-video"; + // Links keep only their text: the webview cannot open them in the system browser. const allowedTags = new Set([ "b", "blockquote", "br", "code", "div", "em", "h1", "h2", "h3", "h4", "hr", "i", "li", "ol", "p", "pre", "span", "strong", "ul", ]); -const ignoredTags = new Set(["form", "iframe", "img", "input", "math", "object", "script", "style", "svg"]); +const ignoredTags = new Set(["form", "iframe", "input", "math", "object", "script", "style", "svg"]); + +function steamDescriptionMediaUrl(source: string | null): string | null { + if (source === null) return null; + try { + const url = new URL(source); + if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.port !== "") return null; + const steamHost = ["steamstatic.com", "steamusercontent.com", "steampowered.com"] + .some(domain => url.hostname === domain || url.hostname.endsWith(`.${domain}`)); + const legacyHost = ["steamcdn-a.akamaihd.net", "steamuserimages-a.akamaihd.net"].includes(url.hostname); + return steamHost || legacyHost ? url.href : null; + } catch { + return null; + } +} + +function steamDescriptionVideo(element: Element): HTMLVideoElement | null { + const src = steamDescriptionMediaUrl(element.getAttribute("src")); + const sources = [...element.children].filter(child => child.tagName.toLowerCase() === "source") + .flatMap(child => { + const source = steamDescriptionMediaUrl(child.getAttribute("src")); + return source === null ? [] : [{ src: source, type: child.getAttribute("type") }]; + }); + if (src === null && sources.length === 0) return null; + const video = document.createElement("video"); + video.muted = true; + video.defaultMuted = true; + video.autoplay = !matchMedia("(prefers-reduced-motion: reduce)").matches; + video.loop = true; + video.playsInline = true; + video.controls = false; + video.preload = "metadata"; + video.className = "my-3 block h-auto max-w-full rounded-lg"; + const poster = steamDescriptionMediaUrl(element.getAttribute("poster")); + if (poster !== null) video.poster = poster; + for (const dimension of ["width", "height"] as const) { + const value = Number(element.getAttribute(dimension)); + if (Number.isInteger(value) && value > 0 && value <= 16384) video[dimension] = value; + } + if (src !== null) video.src = src; + for (const item of sources) { + const source = document.createElement("source"); + source.src = item.src; + if (item.type !== null && /^video\/(webm|mp4)(?:\s*;|$)/i.test(item.type)) source.type = item.type; + video.appendChild(source); + } + return video; +} + +function suspendGif(image: HTMLImageElement): void { + if (!image.complete || image.naturalWidth <= 0 || image.getAttribute("src") === null) return; + if (!image.hasAttribute("width")) image.width = image.naturalWidth; + if (!image.hasAttribute("height")) image.height = image.naturalHeight; + image.dataset.steamGifSuspended = "true"; + image.removeAttribute("src"); +} -export function renderSteamDescription(target: HTMLElement, source: string): { update: (value: string) => void } { +export function renderSteamDescription(target: HTMLElement, source: string): { update: (value: string) => void; destroy: () => void } { + let media: (() => void)[] = []; + let gifObserver: IntersectionObserver | undefined; + function dispose(): void { + gifObserver?.disconnect(); + gifObserver = undefined; + for (const destroy of media) destroy(); + media = []; + } function render(value: string): void { + dispose(); const parsed = new DOMParser().parseFromString(value, "text/html"); const output = document.createDocumentFragment(); @@ -18,6 +84,57 @@ export function renderSteamDescription(target: HTMLElement, source: string): { u if (node.nodeType !== Node.ELEMENT_NODE) return; const element = node as Element; const tag = element.tagName.toLowerCase(); + if (tag === "video") { + const video = steamDescriptionVideo(element); + if (video !== null) { + const surface = createSteamVideoSurface(video); + media.push(surface.destroy); + parent.appendChild(surface.element); + } + return; + } + if (tag === "img") { + const src = steamDescriptionMediaUrl(element.getAttribute("src")); + if (src === null) return; + const image = document.createElement("img"); + image.alt = element.getAttribute("alt") ?? ""; + image.setAttribute("loading", "lazy"); + image.setAttribute("decoding", "async"); + image.setAttribute("referrerpolicy", "no-referrer"); + image.className = "my-3 block h-auto max-w-full rounded-lg"; + const width = Number(element.getAttribute("width")); + const height = Number(element.getAttribute("height")); + if (Number.isInteger(width) && width > 0 && width <= 16384) image.width = width; + if (Number.isInteger(height) && height > 0 && height <= 16384) image.height = height; + image.src = src; + const isGif = new URL(src).pathname.toLowerCase().endsWith(".gif"); + if (isGif && typeof IntersectionObserver !== "undefined") { + image.dataset.steamGifSrc = src; + image.dataset.steamGifNear = "true"; + gifObserver ??= new IntersectionObserver(entries => { + for (const entry of entries) { + const target = entry.target as HTMLImageElement; + target.dataset.steamGifNear = String(entry.isIntersecting); + if (entry.isIntersecting) { + if (target.dataset.steamGifSuspended === "true") { + delete target.dataset.steamGifSuspended; + target.src = target.dataset.steamGifSrc ?? ""; + } + } else { + suspendGif(target); + } + } + }, { rootMargin: "300px 0px" }); + const suspend = () => { + if (image.dataset.steamGifNear === "false") suspendGif(image); + }; + image.addEventListener("load", suspend); + media.push(() => image.removeEventListener("load", suspend)); + gifObserver.observe(image); + } + parent.appendChild(image); + return; + } if (ignoredTags.has(tag)) return; const safe = allowedTags.has(tag) ? document.createElement(tag) : parent; if (safe !== parent) parent.appendChild(safe); @@ -29,7 +146,7 @@ export function renderSteamDescription(target: HTMLElement, source: string): { u } render(source); - return { update: render }; + return { update: render, destroy: dispose }; } export function steamSummaryLine(source: string | null): string | null { diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 0d60181..57e54ed 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -1153,5 +1153,24 @@ "Mod importata da un archivio locale: non è su Thunderstore.": "Mod imported from a local archive: it is not on Thunderstore.", "Aggiungere questa sorgente?": "Add this source?", "Un collegamento chiede di aggiungere una sorgente di download. Aggiungila solo se ti fidi di chi la pubblica: può proporre qualsiasi file da scaricare.": "A link asks to add a download source. Only add it if you trust its publisher: it can offer any file for download.", - "Aggiungi sorgente": "Add source" + "Aggiungi sorgente": "Add source", + "Trailer": "Trailer", + "Mostra trailer: {0}": "Show trailer: {0}", + "Impossibile riprodurre il trailer.": "Could not play the trailer.", + "Impossibile riprodurre il trailer: {0}": "Could not play the trailer: {0}", + "Controlli del trailer": "Trailer controls", + "Avanzamento del trailer": "Trailer progress", + "Riproduci trailer": "Play trailer", + "Metti in pausa": "Pause", + "Disattiva audio": "Mute", + "Attiva audio": "Unmute", + "Volume del trailer": "Trailer volume", + "Schermo intero": "Fullscreen", + "Esci da schermo intero": "Exit fullscreen", + "Il trailer non è disponibile. Controlla la connessione e riprova.": "The trailer is unavailable. Check your connection and try again.", + "Impossibile avviare il trailer. Riprova.": "Could not start the trailer. Try again.", + "Impossibile attivare lo schermo intero.": "Could not enter fullscreen.", + "Caricamento del trailer...": "Loading trailer...", + "Indietro di 10 secondi": "Back 10 seconds", + "Avanti di 10 secondi": "Forward 10 seconds" } diff --git a/src/lib/services/steam-metadata.ts b/src/lib/services/steam-metadata.ts index 62232e2..a73cf90 100644 --- a/src/lib/services/steam-metadata.ts +++ b/src/lib/services/steam-metadata.ts @@ -3,6 +3,15 @@ import { language } from "../i18n"; import { invoke } from "./invoke"; import type { ArtworkWork } from "./artwork-work"; +export interface SteamMovie { + id: number; + name: string; + thumbnail: string | null; + hls: string | null; + mp4: string | null; + webm: string | null; +} + export interface SteamDetails { steamAppId: number; name: string; @@ -20,6 +29,7 @@ export interface SteamDetails { capsule: string | null; background: string | null; screenshots: { thumbnail: string | null; full: string | null }[]; + movies?: SteamMovie[]; }; } diff --git a/src/lib/services/steam-trailer.ts b/src/lib/services/steam-trailer.ts new file mode 100644 index 0000000..c6fe77c --- /dev/null +++ b/src/lib/services/steam-trailer.ts @@ -0,0 +1,59 @@ +import type { SteamMovie } from "./steam-metadata"; +import { toMessage } from "../utils/errors"; + +export function loadSteamTrailer(video: HTMLVideoElement, movie: SteamMovie, onError: (message: string) => void): () => void { + let disposed = false; + let failed = false; + let stream: { destroy: () => void } | undefined; + function fail(message: string): void { + if (disposed || failed) return; + failed = true; + stream?.destroy(); + stream = undefined; + onError(message); + } + const nativeError = () => { + if (stream === undefined) fail(video.error?.message || "Media playback failed."); + }; + video.addEventListener("error", nativeError); + async function load(): Promise { + if (movie.hls !== null) { + const { default: Hls } = await import("hls.js"); + if (disposed) return; + if (Hls.isSupported()) { + const hls = new Hls({ enableWorker: false, maxBufferLength: 20, maxMaxBufferLength: 30 }); + stream = hls; + let recoveries = 0; + hls.on(Hls.Events.ERROR, (_event, data) => { + if (!data.fatal || disposed || failed) return; + if (recoveries < 2 && data.type === Hls.ErrorTypes.NETWORK_ERROR) { + recoveries++; + hls.startLoad(Number.isFinite(video.currentTime) ? video.currentTime : 0); + } else if (recoveries < 2 && data.type === Hls.ErrorTypes.MEDIA_ERROR) { + recoveries++; + hls.recoverMediaError(); + } else fail(data.details); + }); + hls.loadSource(movie.hls); + hls.attachMedia(video); + return; + } + if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = movie.hls; + return; + } + } + const file = movie.mp4 ?? movie.webm; + if (file !== null) video.src = file; + else fail("HLS playback is not supported by this browser."); + } + void load().catch(error => fail(toMessage(error))); + return () => { + disposed = true; + video.removeEventListener("error", nativeError); + stream?.destroy(); + video.pause(); + video.removeAttribute("src"); + video.load(); + }; +} diff --git a/vite.config.ts b/vite.config.ts index 635c9f5..55c4e0c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,18 @@ import { defineConfig } from "vite"; export default defineConfig({ - plugins: [tailwindcss(), svelte()], + plugins: [{ + name: "load-svelte-before-style", + apply: "serve", + configureServer(server) { + server.middlewares.use((request, _response, next) => { + const url = (request as { url?: string }).url ?? ""; + if (!/\.svelte\?svelte&type=style&lang\.css$/.test(url)) return next(); + // The browser can request a cached style after the dev server restarts. + void server.transformRequest(url.split("?")[0]).then(() => next(), next); + }); + }, + }, tailwindcss(), svelte()], clearScreen: false, server: { port: 1420,