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, `Gameplay
`);
+ 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}
+
+
+
{error}
+
+
+ {: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)}
+
{movie.name}
+
+
+
+
+ {#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,