diff --git a/frontend/src/locales/bg_BG/play.json b/frontend/src/locales/bg_BG/play.json index 5930885258..5dcf56f7cd 100644 --- a/frontend/src/locales/bg_BG/play.json +++ b/frontend/src/locales/bg_BG/play.json @@ -1,4 +1,5 @@ { + "all-discs": "Всички дискове", "all-saves": "Всички записи", "all-states": "Всички бързи записи", "back-to-gallery": "Към галерията", diff --git a/frontend/src/locales/cs_CZ/play.json b/frontend/src/locales/cs_CZ/play.json index 6b5f99f112..4fb6642c8b 100644 --- a/frontend/src/locales/cs_CZ/play.json +++ b/frontend/src/locales/cs_CZ/play.json @@ -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", diff --git a/frontend/src/locales/de_DE/play.json b/frontend/src/locales/de_DE/play.json index c3424681d6..37a8d1a107 100644 --- a/frontend/src/locales/de_DE/play.json +++ b/frontend/src/locales/de_DE/play.json @@ -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", diff --git a/frontend/src/locales/en_GB/play.json b/frontend/src/locales/en_GB/play.json index f10b27d60e..5d9cdaff55 100644 --- a/frontend/src/locales/en_GB/play.json +++ b/frontend/src/locales/en_GB/play.json @@ -1,4 +1,5 @@ { + "all-discs": "All discs", "all-saves": "All saves", "all-states": "All states", "back-to-gallery": "Back to gallery", diff --git a/frontend/src/locales/en_US/play.json b/frontend/src/locales/en_US/play.json index f10b27d60e..5d9cdaff55 100644 --- a/frontend/src/locales/en_US/play.json +++ b/frontend/src/locales/en_US/play.json @@ -1,4 +1,5 @@ { + "all-discs": "All discs", "all-saves": "All saves", "all-states": "All states", "back-to-gallery": "Back to gallery", diff --git a/frontend/src/locales/es_ES/play.json b/frontend/src/locales/es_ES/play.json index 2aa55a2896..2dfdd4abc2 100644 --- a/frontend/src/locales/es_ES/play.json +++ b/frontend/src/locales/es_ES/play.json @@ -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", diff --git a/frontend/src/locales/fr_FR/play.json b/frontend/src/locales/fr_FR/play.json index 56ad187cee..5cdd51cfe8 100644 --- a/frontend/src/locales/fr_FR/play.json +++ b/frontend/src/locales/fr_FR/play.json @@ -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", diff --git a/frontend/src/locales/hu_HU/play.json b/frontend/src/locales/hu_HU/play.json index 222d3d45cd..31d1a91b4f 100644 --- a/frontend/src/locales/hu_HU/play.json +++ b/frontend/src/locales/hu_HU/play.json @@ -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", diff --git a/frontend/src/locales/it_IT/play.json b/frontend/src/locales/it_IT/play.json index 45db62c5ff..26a12d4c52 100644 --- a/frontend/src/locales/it_IT/play.json +++ b/frontend/src/locales/it_IT/play.json @@ -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", diff --git a/frontend/src/locales/ja_JP/play.json b/frontend/src/locales/ja_JP/play.json index 8c1f29e9cd..e2d278d383 100644 --- a/frontend/src/locales/ja_JP/play.json +++ b/frontend/src/locales/ja_JP/play.json @@ -1,4 +1,5 @@ { + "all-discs": "すべてのディスク", "all-saves": "すべてのセーブ", "all-states": "すべてのステートセーブ", "back-to-gallery": "ギャラリーへ戻る", diff --git a/frontend/src/locales/ko_KR/play.json b/frontend/src/locales/ko_KR/play.json index 740dc25907..093e6e6572 100644 --- a/frontend/src/locales/ko_KR/play.json +++ b/frontend/src/locales/ko_KR/play.json @@ -1,4 +1,5 @@ { + "all-discs": "모든 디스크", "all-saves": "모든 세이브", "all-states": "모든 상태", "back-to-gallery": "갤러리로 가기", diff --git a/frontend/src/locales/pl_PL/play.json b/frontend/src/locales/pl_PL/play.json index 2ce82a0305..31d6697650 100644 --- a/frontend/src/locales/pl_PL/play.json +++ b/frontend/src/locales/pl_PL/play.json @@ -1,4 +1,5 @@ { + "all-discs": "Wszystkie płyty", "all-saves": "Wszystkie zapisy", "all-states": "Wszystkie stany", "back-to-gallery": "Powrót do galerii", diff --git a/frontend/src/locales/pt_BR/play.json b/frontend/src/locales/pt_BR/play.json index 7d4bc4dfd7..4242e40190 100644 --- a/frontend/src/locales/pt_BR/play.json +++ b/frontend/src/locales/pt_BR/play.json @@ -1,4 +1,5 @@ { + "all-discs": "Todos os discos", "all-saves": "Todos os saves", "all-states": "Todos os states", "back-to-gallery": "Voltar à galeria", diff --git a/frontend/src/locales/ro_RO/play.json b/frontend/src/locales/ro_RO/play.json index f0aa884f06..41031328d0 100644 --- a/frontend/src/locales/ro_RO/play.json +++ b/frontend/src/locales/ro_RO/play.json @@ -1,4 +1,5 @@ { + "all-discs": "Toate discurile", "all-saves": "Toate salvările", "all-states": "Toate stările", "back-to-gallery": "Înapoi la galerie", diff --git a/frontend/src/locales/ru_RU/play.json b/frontend/src/locales/ru_RU/play.json index 1acbc84920..0188215efe 100644 --- a/frontend/src/locales/ru_RU/play.json +++ b/frontend/src/locales/ru_RU/play.json @@ -1,4 +1,5 @@ { + "all-discs": "Все диски", "all-saves": "Все сохранения", "all-states": "Все состояния", "back-to-gallery": "Вернуться в галерею", diff --git a/frontend/src/locales/tr_TR/play.json b/frontend/src/locales/tr_TR/play.json index d77ece5aff..8fc86c14a1 100644 --- a/frontend/src/locales/tr_TR/play.json +++ b/frontend/src/locales/tr_TR/play.json @@ -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", diff --git a/frontend/src/locales/zh_CN/play.json b/frontend/src/locales/zh_CN/play.json index cd09a6a542..64df377f9c 100644 --- a/frontend/src/locales/zh_CN/play.json +++ b/frontend/src/locales/zh_CN/play.json @@ -1,4 +1,5 @@ { + "all-discs": "所有光盘", "all-saves": "所有存档", "all-states": "所有状态", "back-to-gallery": "返回游戏库", diff --git a/frontend/src/locales/zh_TW/play.json b/frontend/src/locales/zh_TW/play.json index 1ec1a8ae3f..5867f207e8 100644 --- a/frontend/src/locales/zh_TW/play.json +++ b/frontend/src/locales/zh_TW/play.json @@ -1,4 +1,5 @@ { + "all-discs": "所有光碟", "all-saves": "所有存檔", "all-states": "所有即時存檔", "back-to-gallery": "返回遊戲庫", diff --git a/frontend/src/v2/utils/playerDisc.test.ts b/frontend/src/v2/utils/playerDisc.test.ts index 591261a35b..13f12bc4c1 100644 --- a/frontend/src/v2/utils/playerDisc.test.ts +++ b/frontend/src/v2/utils/playerDisc.test.ts @@ -1,13 +1,24 @@ 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, }); }); @@ -15,36 +26,88 @@ describe("resolveStoredDisc", () => { 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(); + }); }); diff --git a/frontend/src/v2/utils/playerDisc.ts b/frontend/src/v2/utils/playerDisc.ts index 05ca4e5918..7d89cf3a6d 100644 --- a/frontend/src/v2/utils/playerDisc.ts +++ b/frontend/src/v2/utils/playerDisc.ts @@ -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"); } diff --git a/frontend/src/v2/views/Player/EmulatorJS.vue b/frontend/src/v2/views/Player/EmulatorJS.vue index 2b0d37ba6d..0e4310df82 100644 --- a/frontend/src/v2/views/Player/EmulatorJS.vue +++ b/frontend/src/v2/views/Player/EmulatorJS.vue @@ -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 @@ -121,7 +126,7 @@ const heroRom = computed( ); const isSavesTabSelected = ref(true); const selectedState = ref(null); -const selectedDisc = ref(null); +const selectedDisc = ref(null); const selectedCore = ref(null); const selectedFirmware = ref(null); const supportedCores = ref([]); @@ -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 + @@ -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 @@ -714,15 +729,9 @@ const selectedAsset = computed(() => 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" /> (() => :save="selectedSave" :bios="selectedFirmware" :core="selectedCore" - :disc="selectedDisc" + :disc="bootDiscId(selectedDisc)" />