From 94212996d20b8067f658c91f7df3159a49ad3636 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sat, 15 Nov 2025 17:47:59 +0100 Subject: [PATCH 1/2] Map `Key` to `Control` --- gleam.toml | 1 + manifest.toml | 2 ++ src/mpv/control.gleam | 46 +++++++++++++++++++++++++++++++++++++ src/mpv/key.gleam | 1 - src/mpv/mpv.gleam | 44 +++++++++++++++++++---------------- test/mpv/control_test.gleam | 24 +++++++++++++++++++ 6 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 src/mpv/control.gleam create mode 100644 test/mpv/control_test.gleam diff --git a/gleam.toml b/gleam.toml index fcaabef..70b914e 100644 --- a/gleam.toml +++ b/gleam.toml @@ -17,6 +17,7 @@ gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_otp = ">= 1.2.0 and < 2.0.0" gleam_erlang = ">= 1.3.0 and < 2.0.0" simplifile = ">= 2.3.1 and < 3.0.0" +gleam_json = ">= 3.1.0 and < 4.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index c3d61b4..a94ff87 100644 --- a/manifest.toml +++ b/manifest.toml @@ -4,6 +4,7 @@ packages = [ { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, @@ -12,6 +13,7 @@ packages = [ [requirements] gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } +gleam_json = { version = ">= 3.1.0 and < 4.0.0" } gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/src/mpv/control.gleam b/src/mpv/control.gleam new file mode 100644 index 0000000..b505ec5 --- /dev/null +++ b/src/mpv/control.gleam @@ -0,0 +1,46 @@ +import gleam/json +import gleam/result +import mpv/key.{type Key, Char} +import tcp/reason.{type Reason} +import tcp/tcp.{type Socket} + +pub type Control { + TogglePlayPause + Exit +} + +pub type ControlError { + ControlError(details: String) +} + +pub fn from_key(key: Key) -> Result(Control, Nil) { + case key { + Char(char) -> char_control(char) + _ -> Error(Nil) + } +} + +fn char_control(char: String) -> Result(Control, Nil) { + case char { + " " -> Ok(TogglePlayPause) + "q" -> Ok(Exit) + _ -> Error(Nil) + } +} + +pub fn toggle_play_pause(socket: Socket) -> Result(Nil, ControlError) { + let command = + json.object([#("command", json.array(["cycle", "pause"], of: json.string))]) + + case send_command(socket, command) { + Error(r) -> Error(ControlError(reason.to_string(r))) + Ok(_) -> Ok(Nil) + } +} + +fn send_command(socket: Socket, command: json.Json) -> Result(String, Reason) { + result.try(tcp.send(socket, json.to_string(command) <> "\n"), fn(_) { + let timeout_ms = 10_000 + tcp.receive(socket, timeout_ms) + }) +} diff --git a/src/mpv/key.gleam b/src/mpv/key.gleam index 1f40752..9ef4a1b 100644 --- a/src/mpv/key.gleam +++ b/src/mpv/key.gleam @@ -43,7 +43,6 @@ pub fn start_raw_shell() { internal_key.shell_start_interactive(#(no_shell, raw)) } -// TODO map key to something like Control, to not leak `Continue` etc. pub fn read_input_until_key(l: List(String)) -> Key { let l = internal_key.read_input() |> list.wrap |> list.append(l, _) diff --git a/src/mpv/mpv.gleam b/src/mpv/mpv.gleam index cb78784..b4eba13 100644 --- a/src/mpv/mpv.gleam +++ b/src/mpv/mpv.gleam @@ -1,15 +1,13 @@ import gleam/erlang/process.{type Subject} import gleam/otp/actor +import gleam/result import gleam/string -import mpv/key.{type Key, Char} -import tcp/reason.{type Reason} +import mpv/control.{type Control} +import mpv/key +import tcp/reason import tcp/tcp.{type Socket} -pub type Message { - KeyPress(Key) -} - type State(socket, exit) { State(socket: Socket, exit: Subject(Nil)) } @@ -39,28 +37,34 @@ pub fn new(exit: Subject(Nil)) -> Result(Nil, String) { } } -pub fn toggle_play_pause(socket: Socket) -> Result(Nil, Reason) { - tcp.send(socket, "{\"command\":[\"cycle\",\"pause\"]}\n") -} - fn handle_message( state: State(socket, exit), - message: Message, -) -> actor.Next(State(socket, exit), Message) { - case message { - KeyPress(Char("q")) -> { + control: Control, +) -> actor.Next(State(socket, exit), Control) { + case control { + control.TogglePlayPause -> { + echo "toggling play/pause" + let _ = + result.map_error(control.toggle_play_pause(state.socket), fn(err) { + echo "Could not toggle play/pause: " <> err.details + }) + actor.continue(state) + } + control.Exit -> { process.send(state.exit, Nil) actor.stop() } - KeyPress(key) -> { - echo "key: " <> string.inspect(key) - actor.continue(state) - } } } -fn read_input(subject: Subject(Message)) -> Nil { - key.read_input_until_key([]) |> KeyPress |> process.send(subject, _) +fn read_input(subject: Subject(Control)) -> Nil { + case + key.read_input_until_key([]) + |> control.from_key + { + Error(_) -> Nil + Ok(control) -> process.send(subject, control) + } read_input(subject) } diff --git a/test/mpv/control_test.gleam b/test/mpv/control_test.gleam new file mode 100644 index 0000000..8a6de30 --- /dev/null +++ b/test/mpv/control_test.gleam @@ -0,0 +1,24 @@ +import gleam/list +import gleeunit + +import mpv/control.{type Control} +import mpv/key.{type Key, Char} + +pub fn main() -> Nil { + gleeunit.main() +} + +type TestCase { + TestCase(key: Key, expected: Result(Control, Nil)) +} + +pub fn control_from_key_test() { + let test_cases = [ + TestCase(Char(" "), Ok(control.TogglePlayPause)), + TestCase(Char("q"), Ok(control.Exit)), + ] + + list.each(test_cases, fn(tc) { + assert tc.expected == control.from_key(tc.key) + }) +} -- 2.51.0 From 417b5a2559230c48e92ae7b5a97095d6952e17e4 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sun, 16 Nov 2025 16:23:16 +0100 Subject: [PATCH 2/2] Add ability to get `playback-time` --- src/mpv/control.gleam | 27 +++++++++++++++++++++++++++ src/mpv/internal/control.gleam | 25 +++++++++++++++++++++++++ src/mpv/mpv.gleam | 8 ++++++++ test/mpv/control_test.gleam | 9 +++++++++ 4 files changed, 69 insertions(+) create mode 100644 src/mpv/internal/control.gleam diff --git a/src/mpv/control.gleam b/src/mpv/control.gleam index b505ec5..ad5b38d 100644 --- a/src/mpv/control.gleam +++ b/src/mpv/control.gleam @@ -1,5 +1,8 @@ import gleam/json import gleam/result +import gleam/string + +import mpv/internal/control as internal_control import mpv/key.{type Key, Char} import tcp/reason.{type Reason} import tcp/tcp.{type Socket} @@ -38,6 +41,30 @@ pub fn toggle_play_pause(socket: Socket) -> Result(Nil, ControlError) { } } +// https://mpv.io/manual/master/#command-interface-playback-time +pub type PlaybackTime { + PlaybackTime(data: Float) +} + +pub fn get_playback_time(socket: Socket) -> Result(PlaybackTime, ControlError) { + let command = + json.object([ + #( + "command", + json.array(["get_property_string", "playback-time"], of: json.string), + ), + ]) + + case send_command(socket, command) { + Error(r) -> Error(ControlError(reason.to_string(r))) + Ok(json_string) -> + case internal_control.parse_playback_time(json_string) { + Error(e) -> Error(ControlError(string.inspect(e))) + Ok(data) -> Ok(PlaybackTime(data)) + } + } +} + fn send_command(socket: Socket, command: json.Json) -> Result(String, Reason) { result.try(tcp.send(socket, json.to_string(command) <> "\n"), fn(_) { let timeout_ms = 10_000 diff --git a/src/mpv/internal/control.gleam b/src/mpv/internal/control.gleam new file mode 100644 index 0000000..597a695 --- /dev/null +++ b/src/mpv/internal/control.gleam @@ -0,0 +1,25 @@ +import gleam/dynamic/decode +import gleam/float +import gleam/json +import gleam/string + +pub fn parse_playback_time( + json_string: String, +) -> Result(Float, json.DecodeError) { + let decoder = { + let float_dececoder = fn(data_string) { + case float.parse(data_string) { + Error(_) -> decode.failure(0.0, "data") + Ok(float_value) -> decode.success(float_value) + } + } + use data <- decode.field( + "data", + decode.then(decode.string, float_dececoder), + ) + + decode.success(data) + } + + json.parse(from: string.trim(json_string), using: decoder) +} diff --git a/src/mpv/mpv.gleam b/src/mpv/mpv.gleam index b4eba13..e433703 100644 --- a/src/mpv/mpv.gleam +++ b/src/mpv/mpv.gleam @@ -1,4 +1,5 @@ import gleam/erlang/process.{type Subject} +import gleam/float import gleam/otp/actor import gleam/result import gleam/string @@ -44,10 +45,17 @@ fn handle_message( case control { control.TogglePlayPause -> { echo "toggling play/pause" + let _ = result.map_error(control.toggle_play_pause(state.socket), fn(err) { echo "Could not toggle play/pause: " <> err.details }) + + let _ = + result.map(control.get_playback_time(state.socket), fn(playback) { + echo "playback: " <> float.to_string(playback.data) + }) + actor.continue(state) } control.Exit -> { diff --git a/test/mpv/control_test.gleam b/test/mpv/control_test.gleam index 8a6de30..66cb559 100644 --- a/test/mpv/control_test.gleam +++ b/test/mpv/control_test.gleam @@ -2,6 +2,7 @@ import gleam/list import gleeunit import mpv/control.{type Control} +import mpv/internal/control as control_internal import mpv/key.{type Key, Char} pub fn main() -> Nil { @@ -22,3 +23,11 @@ pub fn control_from_key_test() { assert tc.expected == control.from_key(tc.key) }) } + +pub fn parse_playback_time_test() { + let json_string = + "{\"data\":\"123.456789\",\"request_id\":0,\"error\":\"success\"}\n" + + let assert Ok(data) = control_internal.parse_playback_time(json_string) + assert data == 123.456789 +} -- 2.51.0