Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/locales/bg_BG/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Всички дискове",
"all-saves": "Всички записи",
"all-states": "Всички бързи записи",
"back-to-gallery": "Към галерията",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/cs_CZ/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Všechny disky",
"all-saves": "Všechny uložené pozice",
"all-states": "Všechny stavy",
"back-to-gallery": "Zpět do galerie",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/de_DE/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Alle Discs",
"all-saves": "Alle Spielstände",
"all-states": "Alle Zustände",
"back-to-gallery": "Zurück zur Plattformübersicht",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/en_GB/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "All discs",
"all-saves": "All saves",
"all-states": "All states",
"back-to-gallery": "Back to gallery",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/en_US/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "All discs",
"all-saves": "All saves",
"all-states": "All states",
"back-to-gallery": "Back to gallery",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/es_ES/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Todos los discos",
"all-saves": "Todas las partidas",
"all-states": "Todos los estados",
"back-to-gallery": "Volver a galería",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/fr_FR/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Tous les disques",
"all-saves": "Toutes les sauvegardes",
"all-states": "Tous les états",
"back-to-gallery": "Retour à la galerie",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/hu_HU/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Összes lemez",
"all-saves": "Összes mentés",
"all-states": "Összes állás",
"back-to-gallery": "Vissza a galériába",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/it_IT/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Tutti i dischi",
"all-saves": "Tutti i salvataggi",
"all-states": "Tutti gli stati",
"back-to-gallery": "Ritorna alla galleria",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/ja_JP/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "すべてのディスク",
"all-saves": "すべてのセーブ",
"all-states": "すべてのステートセーブ",
"back-to-gallery": "ギャラリーへ戻る",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/ko_KR/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "모든 디스크",
"all-saves": "모든 세이브",
"all-states": "모든 상태",
"back-to-gallery": "갤러리로 가기",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/pl_PL/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Wszystkie płyty",
"all-saves": "Wszystkie zapisy",
"all-states": "Wszystkie stany",
"back-to-gallery": "Powrót do galerii",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/pt_BR/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Todos os discos",
"all-saves": "Todos os saves",
"all-states": "Todos os states",
"back-to-gallery": "Voltar à galeria",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/ro_RO/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Toate discurile",
"all-saves": "Toate salvările",
"all-states": "Toate stările",
"back-to-gallery": "Înapoi la galerie",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/ru_RU/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Все диски",
"all-saves": "Все сохранения",
"all-states": "Все состояния",
"back-to-gallery": "Вернуться в галерею",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/tr_TR/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "Tüm diskler",
"all-saves": "Tüm kayıtlar",
"all-states": "Tüm durum kayıtları",
"back-to-gallery": "Galeriye dön",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/zh_CN/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "所有光盘",
"all-saves": "所有存档",
"all-states": "所有状态",
"back-to-gallery": "返回游戏库",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/zh_TW/play.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"all-discs": "所有光碟",
"all-saves": "所有存檔",
"all-states": "所有即時存檔",
"back-to-gallery": "返回遊戲庫",
Expand Down
83 changes: 73 additions & 10 deletions frontend/src/v2/utils/playerDisc.test.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,113 @@
import { describe, expect, it } from "vitest";
import { resolveStoredDisc } from "./playerDisc";
import {
ALL_DISCS,
bootDiscId,
defaultDisc,
resolveStoredDisc,
} from "./playerDisc";

// Only `id` is read off each file; minimal stubs stand in for RomFileSchema.
const files = (...ids: number[]) => ids.map((id) => ({ id }));
// Only `id` and `file_name` are read off each file; minimal stubs stand in for
// RomFileSchema. Ids default to a `.chd` name, which is not a playlist.
const files = (...ids: number[]) =>
ids.map((id) => ({ id, file_name: `Game (Disc ${id}).chd` }));
const withM3u = (...ids: number[]) => [
...files(...ids),
{ id: 99, file_name: "Game.m3u" },
];

describe("resolveStoredDisc", () => {
it("keeps a stored id that still belongs to the rom", () => {
expect(resolveStoredDisc("2", files(1, 2, 3))).toEqual({
discId: 2,
disc: 2,
stale: false,
});
});

it("falls back to the first file and marks stale when the id is gone", () => {
// Classic post-rescan case: files were reimported with new ids.
expect(resolveStoredDisc("2", files(10, 11, 12))).toEqual({
discId: 10,
disc: 10,
stale: true,
});
});

it("falls back to the first file without staleness when nothing was stored", () => {
expect(resolveStoredDisc(null, files(5, 6))).toEqual({
discId: 5,
disc: 5,
stale: false,
});
});

it("treats a non-numeric stored value as stale garbage to forget", () => {
expect(resolveStoredDisc("not-a-number", files(1))).toEqual({
discId: 1,
disc: 1,
stale: true,
});
});

it("returns a null disc id (and no staleness) for a rom with no files", () => {
it("returns a null disc (and no staleness) for a rom with no files", () => {
expect(resolveStoredDisc(null, [])).toEqual({
discId: null,
disc: null,
stale: false,
});
});

it("still clears a stored id when the rom now has no files", () => {
expect(resolveStoredDisc("2", [])).toEqual({
discId: null,
disc: null,
stale: true,
});
});

it("keeps a stored all-discs choice while the rom is multi-file", () => {
expect(resolveStoredDisc(ALL_DISCS, files(1, 2))).toEqual({
disc: ALL_DISCS,
stale: false,
});
});

it("drops a stored all-discs choice once the rom is single-file", () => {
expect(resolveStoredDisc(ALL_DISCS, files(7))).toEqual({
disc: 7,
stale: true,
});
});
});

describe("defaultDisc", () => {
it("boots every file together when the set ships its own playlist", () => {
expect(defaultDisc(withM3u(1, 2))).toBe(ALL_DISCS);
});

it("matches the playlist extension case-insensitively", () => {
expect(
defaultDisc([
{ id: 1, file_name: "Game (Disc 1).chd" },
{ id: 2, file_name: "Game.M3U" },
]),
).toBe(ALL_DISCS);
});

it("boots the first file when a multi-file set has no playlist", () => {
expect(defaultDisc(files(3, 4))).toBe(3);
});

it("boots the only file of a single-file rom carrying a playlist", () => {
expect(defaultDisc([{ id: 1, file_name: "Game.m3u" }])).toBe(1);
});

it("returns null for a rom with no files", () => {
expect(defaultDisc([])).toBeNull();
});
});

describe("bootDiscId", () => {
it("downloads a single file when one disc is selected", () => {
expect(bootDiscId(4)).toBe(4);
});

it("downloads the rom whole for all-discs and for no selection", () => {
expect(bootDiscId(ALL_DISCS)).toBeNull();
expect(bootDiscId(null)).toBeNull();
});
});
53 changes: 47 additions & 6 deletions frontend/src/v2/utils/playerDisc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,63 @@
// with that stale id makes the download endpoint 404, which EmulatorJS surfaces
// as a generic "Network Error" (issue #3938). Validate the saved id against the
// rom's current files before using it, and report when it must be forgotten.
//
// A multi-file rom can also be booted whole (issue #3985): asking the download
// endpoint for no file in particular returns every file zipped together with a
// generated .m3u playlist, which EmulatorJS boots with its in-game disc
// switcher. That choice is stored as `ALL_DISCS`.
export const ALL_DISCS = "all";

// A file id to boot on its own, ALL_DISCS to boot every file together, or null
// when the rom has no files to boot at all.
export type DiscSelection = number | typeof ALL_DISCS | null;

interface DiscFile {
id: number;
file_name: string;
}

export interface ResolvedDisc {
// The file id to boot, or null when the rom has no files.
discId: number | null;
// True when a value was stored but no longer matches a current file, so the
disc: DiscSelection;
// True when a value was stored but no longer matches the rom's files, so the
// caller should drop the stale localStorage entry.
stale: boolean;
}

export function resolveStoredDisc(
storedDisc: string | null,
files: readonly { id: number }[],
files: readonly DiscFile[],
): ResolvedDisc {
if (storedDisc === ALL_DISCS) {
// Nothing left to boot together once the rom is down to a single file.
return files.length > 1
? { disc: ALL_DISCS, stale: false }
: { disc: defaultDisc(files), stale: true };
}

const storedDiscId = storedDisc ? parseInt(storedDisc) : null;
if (storedDiscId !== null && files.some((f) => f.id === storedDiscId)) {
return { discId: storedDiscId, stale: false };
return { disc: storedDiscId, stale: false };
}
// NaN (non-numeric storage) counts as stored garbage worth forgetting.
return { discId: files[0]?.id ?? null, stale: storedDiscId !== null };
return { disc: defaultDisc(files), stale: storedDiscId !== null };
}

// Sets that ship their own .m3u are curated multi-disc releases, so boot them
// whole and let the user swap discs from the emulator menu. Every other set
// keeps booting a single file: pulling the whole set can mean hundreds of extra
// MB over the wire, and EmulatorJS only prefers the playlist over a bare .chd /
// .iso for cue-based sets anyway.
export function defaultDisc(files: readonly DiscFile[]): DiscSelection {
if (files.length > 1 && files.some(isM3uFile)) return ALL_DISCS;
return files[0]?.id ?? null;
}

// The file id to download, or null to download the rom whole.
export function bootDiscId(disc: DiscSelection): number | null {
return typeof disc === "number" ? disc : null;
}

function isM3uFile(file: DiscFile): boolean {
return file.file_name.toLowerCase().endsWith(".m3u");
}
33 changes: 21 additions & 12 deletions frontend/src/v2/views/Player/EmulatorJS.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ import {
resolveBezelUrl,
resolveStoredBezelVisible,
} from "@/v2/utils/playerBezel";
import { resolveStoredDisc } from "@/v2/utils/playerDisc";
import {
ALL_DISCS,
bootDiscId,
resolveStoredDisc,
type DiscSelection,
} from "@/v2/utils/playerDisc";
import { installIOSFullscreenShim } from "@/views/Player/EmulatorJS/utils";

// Reuse v1's heavy emulator integration — do NOT rewrite this. Lazy so the
Expand Down Expand Up @@ -121,7 +126,7 @@ const heroRom = computed<DetailedRom | SimpleRom | null>(
);
const isSavesTabSelected = ref(true);
const selectedState = ref<StateSchema | null>(null);
const selectedDisc = ref<number | null>(null);
const selectedDisc = ref<DiscSelection>(null);
const selectedCore = ref<string | null>(null);
const selectedFirmware = ref<FirmwareSchema | null>(null);
const supportedCores = ref<string[]>([]);
Expand Down Expand Up @@ -191,6 +196,16 @@ const compatibleStates = computed(
) ?? [],
);

// Booting every file at once hands EmulatorJS the whole set plus an .m3u
// playlist, which unlocks its in-game disc switcher for multi-disc games.
const discItems = computed<{ title: string; value: DiscSelection }[]>(() => [
{ title: t("play.all-discs"), value: ALL_DISCS },
...(rom.value?.files ?? []).map((f) => ({
title: f.file_name,
value: f.id,
})),
]);

const setBgArt = useBackgroundArt();

// The hero cover is the shared GameCover (same component as gallery +
Expand Down Expand Up @@ -421,11 +436,11 @@ onMounted(async () => {
// Validate the saved disc against the rom's current files: a rescan can
// leave a stale id behind that would 404 the download (issue #3938).
const storedDisc = localStorage.getItem(`player:${rom.value.id}:disc`);
const { discId, stale } = resolveStoredDisc(storedDisc, rom.value.files);
const { disc, stale } = resolveStoredDisc(storedDisc, rom.value.files);
if (stale) {
localStorage.removeItem(`player:${rom.value.id}:disc`);
}
selectedDisc.value = discId;
selectedDisc.value = disc;

// Prefer the core saved for this game, then the platform default, validating
// each candidate so a stale entry falls through instead of masking the next
Expand Down Expand Up @@ -714,15 +729,9 @@ const selectedAsset = computed<SaveSchema | StateSchema | null>(() =>
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-disc"
clearable
hide-details
:label="t('rom.file')"
:items="
(rom?.files ?? []).map((f) => ({
title: f.file_name,
value: f.id,
}))
"
:items="discItems"
/>
<RSelect
v-if="supportedCores.length > 1"
Expand Down Expand Up @@ -788,7 +797,7 @@ const selectedAsset = computed<SaveSchema | StateSchema | null>(() =>
:save="selectedSave"
:bios="selectedFirmware"
:core="selectedCore"
:disc="selectedDisc"
:disc="bootDiscId(selectedDisc)"
/>
<!-- Bezel overlay drawn around the game canvas. Purely decorative and
click-through, so pointer events reach the emulator underneath. In
Expand Down
Loading
Loading