Skip to content

Commit d94c5b6

Browse files
committed
feat(streaming): add streaming player frontend
Add a Pinia streaming store (config fetch, session lifecycle, broker controls), a v1 player view at rom/:rom/stream with save state and volume controls, and a cast button on PlayBtn for platforms with a configured streaming container. The route registers a v2 named view via v2For so the v2 fallback screen shows until a v2 player lands.
1 parent a6e8cd7 commit d94c5b6

5 files changed

Lines changed: 1139 additions & 0 deletions

File tree

frontend/src/components/common/Game/PlayBtn.vue

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ROUTES } from "@/plugins/router";
1111
import storeConfig from "@/stores/config";
1212
import storeHeartbeat from "@/stores/heartbeat";
1313
import storeRoms, { type SimpleRom } from "@/stores/roms";
14+
import { useStreamingStore } from "@/stores/streaming";
1415
import type { Events } from "@/types/emitter";
1516
import { isEJSEmulationSupported, isRuffleEmulationSupported } from "@/utils";
1617
@@ -23,6 +24,10 @@ const router = useRouter();
2324
const { config } = storeToRefs(configStore);
2425
const { value: heartbeat } = storeToRefs(heartbeatStore);
2526
const emitter = inject<Emitter<Events>>("emitter");
27+
const streamingStore = useStreamingStore();
28+
const streamingContainer = computed(() =>
29+
streamingStore.containerForPlatform(props.rom.platform_slug),
30+
);
2631
2732
const isAprilFools = computed(() => {
2833
const today = new Date();
@@ -98,4 +103,16 @@ async function goToPlayer(rom: SimpleRom) {
98103
<v-icon>mdi-play</v-icon>
99104
</v-btn>
100105
</template>
106+
<!-- Streaming play button - shows for any platform with a container configured -->
107+
<v-btn
108+
v-if="streamingContainer && !rom.missing_from_fs"
109+
v-bind="attrs"
110+
:aria-label="`Play ${rom.name} via ${streamingContainer.label}`"
111+
:to="{
112+
name: ROUTES.STREAM,
113+
params: { rom: rom.id },
114+
}"
115+
>
116+
<v-icon>mdi-cast</v-icon>
117+
</v-btn>
101118
</template>

frontend/src/layouts/Main.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ import TryV2Banner from "@/components/common/TryV2Banner.vue";
2929
import storeCollections from "@/stores/collections";
3030
import storeNavigation from "@/stores/navigation";
3131
import storePlatforms from "@/stores/platforms";
32+
import { useStreamingStore } from "@/stores/streaming";
3233
import type { Events } from "@/types/emitter";
3334
3435
const navigationStore = storeNavigation();
3536
const platformsStore = storePlatforms();
3637
const collectionsStore = storeCollections();
38+
const streamingStore = useStreamingStore();
3739
3840
const emitter = inject<Emitter<Events>>("emitter");
3941
emitter?.on("refreshDrawer", async () => {
@@ -66,6 +68,7 @@ onBeforeMount(async () => {
6668
if (showVirtualCollections) {
6769
collectionsStore.fetchVirtualCollections(virtualCollectionTypeRef.value);
6870
}
71+
streamingStore.fetchConfig();
6972
7073
navigationStore.reset();
7174
});

frontend/src/plugins/router.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const ROUTES = {
3232
ROM: "rom",
3333
EMULATORJS: "emulatorjs",
3434
RUFFLE: "ruffle",
35+
STREAM: "stream",
3536
SCAN: "scan",
3637
UPLOAD: "upload",
3738
ACTIVITY: "activity",
@@ -268,6 +269,14 @@ const routes = [
268269
v2: v2For(ROUTES.APRIL_FOOLS),
269270
},
270271
},
272+
{
273+
path: "rom/:rom/stream",
274+
name: ROUTES.STREAM,
275+
components: {
276+
default: () => import("@/views/Player/Stream/Player.vue"),
277+
v2: v2For(ROUTES.STREAM),
278+
},
279+
},
271280
// Settings group — every settings route shares the same v2
272281
// sub-layout (sidebar + content panel). Library Tools (Scan /
273282
// Upload / Patcher) live here too so they share the settings

frontend/src/stores/streaming.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { isAxiosError } from "axios";
2+
import { defineStore } from "pinia";
3+
import { ref, computed } from "vue";
4+
import api from "@/services/api";
5+
6+
// ── Types ─────────────────────────────────────────────────────────────────────
7+
8+
export interface StreamingContainer {
9+
platform: string; // e.g. "ps2"
10+
host: string; // browser-facing URL, e.g. "http://192.168.1.50:3000"
11+
label: string; // e.g. "PCSX2"
12+
}
13+
14+
export interface StreamingConfig {
15+
enabled: boolean;
16+
containers: StreamingContainer[];
17+
}
18+
19+
export interface ActiveSession {
20+
platform: string;
21+
host: string;
22+
label: string;
23+
rom_name: string;
24+
claimed_at: string;
25+
}
26+
27+
// ── Store ─────────────────────────────────────────────────────────────────────
28+
29+
export const useStreamingStore = defineStore("streaming", () => {
30+
const config = ref<StreamingConfig>({ enabled: false, containers: [] });
31+
const activeSession = ref<ActiveSession | null>(null);
32+
const loading = ref(false);
33+
const error = ref<string | null>(null);
34+
35+
const isEnabled = computed(() => config.value.enabled);
36+
37+
// ── Actions ────────────────────────────────────────────────────────────────
38+
39+
/**
40+
* Returns the streaming container for a given platform slug, or null if
41+
* streaming is disabled or no container is configured for that platform.
42+
* Case-insensitive so "PS2" and "ps2" both match.
43+
*/
44+
function containerForPlatform(
45+
slug: string | null | undefined,
46+
): StreamingContainer | null {
47+
if (!slug || !config.value.enabled) return null;
48+
const lower = slug.toLowerCase();
49+
return (
50+
config.value.containers.find((c) => c.platform.toLowerCase() === lower) ??
51+
null
52+
);
53+
}
54+
55+
/**
56+
* Returns per-platform save-state capabilities for the streaming player UI.
57+
*
58+
* maxSlots - number of user-accessible save slots (slot selector range)
59+
* hasAutosave - whether a dedicated "load autosave" action is available
60+
*
61+
* Dolphin (ngc, wii, wiiu): slots 1-7 user-accessible; slot 8 reserved for auto-save.
62+
* PCSX2 (ps2), xemu (xbox): 9 slots + slot 10 autosave.
63+
* Eden (switch) and unknown platforms: no save state UI - a platform gets
64+
* slots only once its broker's slot semantics are known.
65+
*/
66+
function platformCapabilities(slug: string | null | undefined): {
67+
maxSlots: number;
68+
hasAutosave: boolean;
69+
autosaveSlot: number;
70+
} {
71+
const lower = (slug ?? "").toLowerCase();
72+
if (lower === "ngc" || lower === "wii" || lower === "wiiu") {
73+
return { maxSlots: 7, hasAutosave: true, autosaveSlot: 8 };
74+
}
75+
if (lower === "switch") {
76+
return { maxSlots: 0, hasAutosave: false, autosaveSlot: 0 };
77+
}
78+
if (lower === "ps2" || lower === "xbox") {
79+
return { maxSlots: 9, hasAutosave: true, autosaveSlot: 10 };
80+
}
81+
return { maxSlots: 0, hasAutosave: false, autosaveSlot: 0 };
82+
}
83+
84+
/**
85+
* Fetch streaming config from the backend once on app load.
86+
* Non-fatal - if it fails, streaming stays disabled and no buttons appear.
87+
*/
88+
async function fetchConfig(): Promise<void> {
89+
loading.value = true;
90+
error.value = null;
91+
try {
92+
const { data } = await api.get<StreamingConfig>("/streaming/config", {
93+
headers: { "Cache-Control": "no-cache" },
94+
});
95+
config.value = {
96+
enabled: data.enabled ?? false,
97+
containers: data.containers ?? [],
98+
};
99+
} catch (err) {
100+
error.value = String(err);
101+
console.warn("[streaming] Could not fetch config:", err);
102+
} finally {
103+
loading.value = false;
104+
}
105+
}
106+
107+
/**
108+
* Claim a streaming session for a ROM. The backend derives the platform,
109+
* filesystem path, and display name from the ROM id - the client never
110+
* sends a path.
111+
* Returns the session data (including the container host URL) on success.
112+
* Throws an error with a `status` property on failure:
113+
* 409 session in use - error has who/what is playing
114+
* 404 - ROM or platform container not configured
115+
* 503 - broker unreachable
116+
*/
117+
async function claimSession(romId: number): Promise<ActiveSession> {
118+
try {
119+
const { data } = await api.post<ActiveSession>("/streaming/sessions", {
120+
rom_id: romId,
121+
});
122+
activeSession.value = data;
123+
return data;
124+
} catch (e) {
125+
const response = isAxiosError(e) ? e.response : undefined;
126+
const detail = response?.data?.detail;
127+
const err = Object.assign(
128+
new Error(detail?.message ?? `HTTP ${response?.status}`),
129+
{ status: response?.status, detail },
130+
);
131+
throw err;
132+
}
133+
}
134+
135+
/**
136+
* Release the active session when the user leaves the player page.
137+
* Best-effort - never throws.
138+
*/
139+
async function releaseSession(platform: string): Promise<void> {
140+
if (!platform) return;
141+
activeSession.value = null;
142+
try {
143+
await api.delete(`/streaming/sessions/${platform}`);
144+
} catch (err) {
145+
console.warn("[streaming] Could not release session:", err);
146+
}
147+
}
148+
149+
/**
150+
* Save game state then release the session.
151+
* wait=true (default): blocks until broker confirms save+kill - use for explicit button press.
152+
* wait=false: broker fires save+kill in background, returns immediately - use for navigation away.
153+
* Best-effort - never throws.
154+
*/
155+
async function saveAndExit(
156+
platform: string,
157+
slot = 0,
158+
wait = true,
159+
): Promise<boolean> {
160+
if (!platform) return false;
161+
activeSession.value = null;
162+
try {
163+
const { data } = await api.post(
164+
`/streaming/sessions/${platform}/save-and-exit`,
165+
{ slot, wait },
166+
);
167+
return data.saved ?? false;
168+
} catch (err) {
169+
console.warn("[streaming] Could not save-and-exit:", err);
170+
return false;
171+
}
172+
}
173+
174+
/**
175+
* Set emulator volume (0-100). Best-effort - never throws.
176+
*/
177+
async function setVolume(platform: string, level: number): Promise<void> {
178+
if (!platform) return;
179+
try {
180+
await api.post(`/streaming/sessions/${platform}/volume`, {
181+
level: Math.round(level),
182+
});
183+
} catch (err) {
184+
console.warn("[streaming] Could not set volume:", err);
185+
}
186+
}
187+
188+
/**
189+
* Toggle or explicitly set mute. Pass true/false to set, omit for toggle.
190+
* Best-effort - never throws.
191+
*/
192+
async function setMute(platform: string, mute?: boolean): Promise<void> {
193+
if (!platform) return;
194+
try {
195+
await api.post(
196+
`/streaming/sessions/${platform}/mute`,
197+
mute !== undefined ? { mute } : {},
198+
);
199+
} catch (err) {
200+
console.warn("[streaming] Could not set mute:", err);
201+
}
202+
}
203+
204+
/**
205+
* Save game state to a slot (1-9) without stopping the emulator.
206+
* The broker fires the save in the background and returns immediately.
207+
* Best-effort - never throws.
208+
*/
209+
async function saveState(platform: string, slot = 1): Promise<boolean> {
210+
if (!platform) return false;
211+
try {
212+
const { data } = await api.post(
213+
`/streaming/sessions/${platform}/save-state`,
214+
{ slot },
215+
);
216+
return data.status === "saving";
217+
} catch (err) {
218+
console.warn("[streaming] Could not save state:", err);
219+
return false;
220+
}
221+
}
222+
223+
/**
224+
* Load game state from a slot (1-10). Slot 10 is the autosave slot on xemu and rpcs3.
225+
* Best-effort - never throws.
226+
*/
227+
async function loadState(platform: string, slot = 1): Promise<boolean> {
228+
if (!platform) return false;
229+
try {
230+
const { data } = await api.post(
231+
`/streaming/sessions/${platform}/load-state`,
232+
{ slot },
233+
);
234+
return data.loaded ?? false;
235+
} catch (err) {
236+
console.warn("[streaming] Could not load state:", err);
237+
return false;
238+
}
239+
}
240+
241+
return {
242+
config,
243+
activeSession,
244+
loading,
245+
error,
246+
isEnabled,
247+
containerForPlatform,
248+
platformCapabilities,
249+
fetchConfig,
250+
claimSession,
251+
releaseSession,
252+
saveAndExit,
253+
setVolume,
254+
setMute,
255+
saveState,
256+
loadState,
257+
};
258+
});

0 commit comments

Comments
 (0)