mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 12:08:37 +01:00
feat(playback): add pure remote-command mapper
This commit is contained in:
86
utils/playback/remoteCommands.test.ts
Normal file
86
utils/playback/remoteCommands.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mapRemoteCommand } from "./remoteCommands";
|
||||
|
||||
describe("mapRemoteCommand — Playstate", () => {
|
||||
test("maps Pause", () => {
|
||||
expect(
|
||||
mapRemoteCommand({
|
||||
MessageType: "Playstate",
|
||||
Data: { Command: "Pause" },
|
||||
}),
|
||||
).toEqual({ kind: "pause" });
|
||||
});
|
||||
|
||||
test("maps Stop, PlayPause, Unpause, NextTrack, PreviousTrack", () => {
|
||||
const m = (c: string) =>
|
||||
mapRemoteCommand({ MessageType: "Playstate", Data: { Command: c } });
|
||||
expect(m("Stop")).toEqual({ kind: "stop" });
|
||||
expect(m("PlayPause")).toEqual({ kind: "playPause" });
|
||||
expect(m("Unpause")).toEqual({ kind: "unpause" });
|
||||
expect(m("NextTrack")).toEqual({ kind: "next" });
|
||||
expect(m("PreviousTrack")).toEqual({ kind: "previous" });
|
||||
});
|
||||
|
||||
test("maps Seek, converting ticks to milliseconds", () => {
|
||||
expect(
|
||||
mapRemoteCommand({
|
||||
MessageType: "Playstate",
|
||||
Data: { Command: "Seek", SeekPositionTicks: 600_000_000 },
|
||||
}),
|
||||
).toEqual({ kind: "seek", positionMs: 60_000 });
|
||||
});
|
||||
|
||||
test("returns null for Seek with no position", () => {
|
||||
expect(
|
||||
mapRemoteCommand({ MessageType: "Playstate", Data: { Command: "Seek" } }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for an unknown command", () => {
|
||||
expect(
|
||||
mapRemoteCommand({ MessageType: "Playstate", Data: { Command: "Wat" } }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRemoteCommand — GeneralCommand", () => {
|
||||
test("maps SetVolume, converting 0-100 to 0-1", () => {
|
||||
expect(
|
||||
mapRemoteCommand({
|
||||
MessageType: "GeneralCommand",
|
||||
Data: { Name: "SetVolume", Arguments: { Volume: "40" } },
|
||||
}),
|
||||
).toEqual({ kind: "setVolume", level: 0.4 });
|
||||
});
|
||||
|
||||
test("clamps SetVolume to 0-1", () => {
|
||||
const r = mapRemoteCommand({
|
||||
MessageType: "GeneralCommand",
|
||||
Data: { Name: "SetVolume", Arguments: { Volume: "250" } },
|
||||
});
|
||||
expect(r).toEqual({ kind: "setVolume", level: 1 });
|
||||
});
|
||||
|
||||
test("maps ToggleMute / Mute / Unmute to toggleMute", () => {
|
||||
const m = (n: string) =>
|
||||
mapRemoteCommand({ MessageType: "GeneralCommand", Data: { Name: n } });
|
||||
expect(m("ToggleMute")).toEqual({ kind: "toggleMute" });
|
||||
expect(m("Mute")).toEqual({ kind: "toggleMute" });
|
||||
expect(m("Unmute")).toEqual({ kind: "toggleMute" });
|
||||
});
|
||||
|
||||
test("maps DisplayMessage from Arguments.Text", () => {
|
||||
expect(
|
||||
mapRemoteCommand({
|
||||
MessageType: "GeneralCommand",
|
||||
Data: { Name: "DisplayMessage", Arguments: { Text: "Hello" } },
|
||||
}),
|
||||
).toEqual({ kind: "displayMessage", text: "Hello" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRemoteCommand — other", () => {
|
||||
test("returns null for unrelated message types", () => {
|
||||
expect(mapRemoteCommand({ MessageType: "KeepAlive", Data: {} })).toBeNull();
|
||||
});
|
||||
});
|
||||
82
utils/playback/remoteCommands.ts
Normal file
82
utils/playback/remoteCommands.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Pure mapping from a Jellyfin remote-control WebSocket message to a typed
|
||||
* action. Dependency-free so it is unit-testable under `bun test`.
|
||||
*/
|
||||
|
||||
/** A WebSocket message envelope (subset). */
|
||||
export interface RemoteWsMessage {
|
||||
MessageType: string;
|
||||
Data?: unknown;
|
||||
}
|
||||
|
||||
export type RemoteAction =
|
||||
| { kind: "playPause" }
|
||||
| { kind: "pause" }
|
||||
| { kind: "unpause" }
|
||||
| { kind: "stop" }
|
||||
| { kind: "seek"; positionMs: number }
|
||||
| { kind: "next" }
|
||||
| { kind: "previous" }
|
||||
| { kind: "setVolume"; level: number }
|
||||
| { kind: "toggleMute" }
|
||||
| { kind: "displayMessage"; text: string };
|
||||
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
|
||||
const mapPlaystate = (data: Record<string, unknown>): RemoteAction | null => {
|
||||
switch (data.Command) {
|
||||
case "PlayPause":
|
||||
return { kind: "playPause" };
|
||||
case "Pause":
|
||||
return { kind: "pause" };
|
||||
case "Unpause":
|
||||
return { kind: "unpause" };
|
||||
case "Stop":
|
||||
return { kind: "stop" };
|
||||
case "NextTrack":
|
||||
return { kind: "next" };
|
||||
case "PreviousTrack":
|
||||
return { kind: "previous" };
|
||||
case "Seek": {
|
||||
const ticks = data.SeekPositionTicks;
|
||||
if (typeof ticks !== "number") return null;
|
||||
return { kind: "seek", positionMs: Math.floor(ticks / 10000) };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const mapGeneralCommand = (
|
||||
data: Record<string, unknown>,
|
||||
): RemoteAction | null => {
|
||||
const args = (data.Arguments ?? {}) as Record<string, unknown>;
|
||||
switch (data.Name) {
|
||||
case "SetVolume": {
|
||||
const volume = Number(args.Volume);
|
||||
if (!Number.isFinite(volume)) return null;
|
||||
return { kind: "setVolume", level: clamp01(volume / 100) };
|
||||
}
|
||||
case "Mute":
|
||||
case "Unmute":
|
||||
case "ToggleMute":
|
||||
return { kind: "toggleMute" };
|
||||
case "DisplayMessage": {
|
||||
const text = args.Text ?? args.Header;
|
||||
if (!text) return null;
|
||||
return { kind: "displayMessage", text: String(text) };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Map a remote-control WS message to a typed action, or null if unhandled. */
|
||||
export const mapRemoteCommand = (
|
||||
message: RemoteWsMessage,
|
||||
): RemoteAction | null => {
|
||||
const data = (message.Data ?? {}) as Record<string, unknown>;
|
||||
if (message.MessageType === "Playstate") return mapPlaystate(data);
|
||||
if (message.MessageType === "GeneralCommand") return mapGeneralCommand(data);
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user