From ca2e657eaca9f3d6d5713e2e29b2057a29f9cea6 Mon Sep 17 00:00:00 2001 From: Uruk Date: Fri, 22 May 2026 02:05:10 +0200 Subject: [PATCH] feat(playback): add pure remote-command mapper --- utils/playback/remoteCommands.test.ts | 86 +++++++++++++++++++++++++++ utils/playback/remoteCommands.ts | 82 +++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 utils/playback/remoteCommands.test.ts create mode 100644 utils/playback/remoteCommands.ts diff --git a/utils/playback/remoteCommands.test.ts b/utils/playback/remoteCommands.test.ts new file mode 100644 index 000000000..420bc143d --- /dev/null +++ b/utils/playback/remoteCommands.test.ts @@ -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(); + }); +}); diff --git a/utils/playback/remoteCommands.ts b/utils/playback/remoteCommands.ts new file mode 100644 index 000000000..1c0447d26 --- /dev/null +++ b/utils/playback/remoteCommands.ts @@ -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): 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, +): RemoteAction | null => { + const args = (data.Arguments ?? {}) as Record; + 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; + if (message.MessageType === "Playstate") return mapPlaystate(data); + if (message.MessageType === "GeneralCommand") return mapGeneralCommand(data); + return null; +};