Changes
33 changed files (+1142/-6302)
-
-
@@ -43,13 +43,6 @@ pub fn build(b: *std.Build) !void {const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const freelog = b.option(bool, "freelog", "Enable logging memory releases") orelse false; const extension = RoonExtension{ .id = b.option([]const u8, "extension-id", "Roon extension ID") orelse app_name, .name = b.option([]const u8, "extension-name", "Roon extension name") orelse "Plac for GTK", .version = b.option([]const u8, "extension-version", "Roon extension version") orelse "0.0.0-dev", }; const compile_gschema = b.option(bool, "compile-gschema", "Compile gschema XML file for local run") orelse false; const libsood = libsood: {
-
@@ -77,36 +70,6 @@ step.dependOn(libroon.step);step.dependOn(libroon.install_pretty_headers); } const zig_lib = zig_lib: { const mod = b.createModule(.{ .root_source_file = b.path("src/core/main.zig"), .link_libc = true, .target = target, .optimize = optimize, }); mod.addImport("sood", b.dependency("sood", .{}).module("sood")); mod.addImport("moo", b.dependency("libmoo", .{}).module("moo")); mod.addImport("websocket", b.dependency("websocket", .{}).module("websocket")); const options = b.addOptions(); options.addOption(bool, "freelog", freelog); options.addOption(RoonExtension, "extension", extension); mod.addOptions("config", options); const obj = b.addObject(.{ .name = "plac", .root_module = mod, }); obj.linkSystemLibrary2("glib-2.0", .{ .preferred_link_mode = .dynamic }); obj.installHeader(b.path("src/core/plac.h"), "plac.h"); obj.installHeader(b.path("src/core/plac.vapi"), "plac.vapi"); break :zig_lib obj; }; // System libraries to link. const system_libraries = [_][]const u8{ "gtk4",
-
@@ -143,11 +106,10 @@ "libsoup-3.0",}); compile.addPackage("posix"); compile.addPackages(&.{ "plac", "libsood", "roon" }); compile.addPackages(&.{ "libsood", "roon" }); compile.addGResourceXML(b.path("data/gresource.xml")); compile.addVapi(b.path("src/core/plac.vapi")); compile.addVapi(b.path("src/libsood.vapi")); compile.addVapi(libroon.lib.getEmittedIncludeTree().path(b, "roon.vapi"));
-
@@ -209,7 +171,6 @@ for (system_libraries) |lib| {exe.linkSystemLibrary(lib); } exe.root_module.addObject(zig_lib); exe.root_module.linkLibrary(libsood); exe.root_module.linkLibrary(libroon.lib);
-
@@ -320,24 +281,9 @@break :moo_test run; }; // `zig build zig-test` const zig_test = zig_test: { const t = b.addTest(.{ .root_module = zig_lib.root_module, }); const run = b.addRunArtifact(t); const step = b.step("zig-test", "Test core module written in Zig"); step.dependOn(&run.step); break :zig_test run; }; // `zig build test` { const step = b.step("test", "Run unit tests"); step.dependOn(&zig_test.step); step.dependOn(&moo_test.step); } }
-
-
src/Plac.vala (deleted)
-
@@ -1,204 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 namespace Plac { public class AsyncConnection : GLib.Object { private Connection conn; private GLib.Thread<void>? thread = null; private bool is_closed = false; public size_t retry_max = 3; public AsyncConnection(Discovery.Server server) { this.conn = new Connection(server, null); } public AsyncConnection.with_token(Discovery.Server server, string token) { this.conn = new Connection(server, token); } public signal void connection_started(); public signal void out_of_memory_error(); public signal void connection_error(ConnectionErrorEvent event); public signal void connected(ConnectedEvent event); public signal void zones_changed(Transport.ZoneListEvent event); public void activate() { if (thread != null) { return; } is_closed = false; thread = new GLib.Thread<void>("connection-loop", () => { GLib.Idle.add(() => { connection_started(); return false; }); size_t retry_count = 0; while (true) { if (is_closed) { return; } var event = conn.get_event(); if (event == null) { deactivate(); GLib.Idle.add(() => { out_of_memory_error(); return false; }); return; } switch (event.kind) { case ERROR: { var error_event = event.get_connection_error_event(); if (error_event.code == CLOSED_BY_SERVER && retry_count < retry_max) { retry_count += 1; GLib.log( "Plac", LEVEL_INFO, "Connection closed by server, retrying (#%u)", (uint) retry_count ); break; } deactivate(); GLib.Idle.add(() => { connection_error(error_event); return false; }); break; } case CONNECTED: { retry_count = 0; conn.subscribe_zones(); GLib.Idle.add(() => { connected(event.get_connected_event()); return false; }); break; } case ZONE_LIST: { GLib.Idle.add(() => { zones_changed(event.get_zone_list_event()); return false; }); break; } } } }); } public void deactivate() { if (thread == null) { return; } // Prevent unnecessary read immediately. is_closed = true; // Schedule thread disposal. Calling `thread.join` immediately results in // `join` from the same thread = deadlock. GLib.Idle.add(() => { thread.join(); return false; }); } public async void control(Transport.Zone zone, uint16 action) { GLib.SourceFunc callback = control.callback; new GLib.Thread<void>("control", () => { conn.control(zone, action); GLib.Idle.add((owned) callback); }); yield; } public async Transport.SeekResultCode seek(Transport.Zone zone, int64 at_seconds) { GLib.SourceFunc callback = seek.callback; Transport.SeekResultCode code = UNKNOWN_ERROR; new GLib.Thread<void>("seek", () => { code = conn.seek(zone, at_seconds); GLib.Idle.add((owned) callback); }); yield; return code; } public async Transport.VolumeControlResultCode increase_volume(Transport.Output output) { GLib.SourceFunc callback = increase_volume.callback; Transport.VolumeControlResultCode code = UNKNOWN_ERROR; new GLib.Thread<void>("increase_volume", () => { code = conn.increase_volume(output); GLib.Idle.add((owned) callback); }); yield; return code; } public async Transport.VolumeControlResultCode decrease_volume(Transport.Output output) { GLib.SourceFunc callback = decrease_volume.callback; Transport.VolumeControlResultCode code = UNKNOWN_ERROR; new GLib.Thread<void>("decrease_volume", () => { code = conn.decrease_volume(output); GLib.Idle.add((owned) callback); }); yield; return code; } public async Transport.VolumeControlResultCode change_volume(Transport.Output output, double value) { GLib.SourceFunc callback = change_volume.callback; Transport.VolumeControlResultCode code = UNKNOWN_ERROR; new GLib.Thread<void>("change_volume", () => { code = conn.change_volume(output, value); GLib.Idle.add((owned) callback); }); yield; return code; } public async Browse.Result? browse(Browse.Hierarchy hierarchy, Transport.Zone? zone, Browse.Item? item, bool back) { GLib.SourceFunc callback = browse.callback; Browse.Result? result = null; new GLib.Thread<void>("browse", () => { result = conn.browse(hierarchy, zone, item, back); GLib.Idle.add((owned) callback); }); yield; return (owned) result; } public string? get_image_url(string image_key, Image.GetOptions options) { return conn.get_image_url(image_key, options); } } }
-
-
src/Plac/Connection.vala (new)
-
@@ -0,0 +1,125 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 namespace Plac { namespace V2 { public errordomain RequestError { NETWORK_ERROR, UNEXPECTED_RESPONSE, } // TODO: Handle connection close and reconnect public class Connection : Object { private size_t request_id = 1; public Soup.WebsocketConnection conn { get; construct; } public Server server { get; construct; } public string token { get; construct; } public Connection(Soup.WebsocketConnection conn, Server server, string token) { Object(conn: conn, server: server, token: token); } construct { // Roon forces application-layer ping/pong instead of WebSocket's // ping/pong frame. conn.message.connect((c, type, bytes) => { var message = (string) bytes.get_data(); Moo.Metadata req_meta; Moo.Headers req_headers; try { req_meta = new Moo.Metadata.from_string(message); req_headers = new Moo.Headers.from_string(message, req_meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } if (req_meta.service != "com.roonlabs.ping:1/ping") { return; } var res_meta = new Moo.Metadata("COMPLETE", "Success"); var res_headers = new Moo.Headers(); res_headers.write("Request-Id", @"$(req_headers.request_id)"); conn.send_binary(@"$res_meta$res_headers".data); }); } public async Response json_request(string service, string body) throws RequestError { SourceFunc callback = json_request.callback; var req_id = request_id; request_id += 1; Response? resp = null; GLib.Error? error = null; var message_handler_id = conn.message.connect((c, type, bytes) => { var message = (string) bytes.get_data(); Moo.Metadata res_meta; Moo.Headers res_headers; try { res_meta = new Moo.Metadata.from_string(message); res_headers = new Moo.Headers.from_string(message, res_meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } if (res_meta.verb == "REQUEST" || res_headers.request_id != req_id) { return; } resp = new Response(res_meta, res_headers, message); callback(); }); var error_handler_id = conn.error.connect((c, error) => { GLib.log("Plac", LEVEL_ERROR, "Failed to receive MOO message: %s", error.message); callback(); }); var req_meta = new Moo.Metadata("REQUEST", service); var req_headers = new Moo.Headers(); req_headers.write("Request-Id", @"$req_id"); req_headers.write("Content-Type", "application/json"); req_headers.write("Content-Length", @"$(body.length)"); conn.send_binary(@"$req_meta$req_headers$body".data); yield; conn.disconnect(message_handler_id); conn.disconnect(error_handler_id); if (error != null) { throw new RequestError.NETWORK_ERROR(error.message); } if (resp == null) { throw new RequestError.UNEXPECTED_RESPONSE("Got unexpected response from Roon server, failed to parse."); } return resp; } } } }
-
-
src/Plac/Label.vala (new)
-
@@ -0,0 +1,101 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 namespace Plac { namespace V2 { namespace Label { public abstract class Node : Object { public string text { get; construct; } } public class Text : Node { public Text(string text) { Object(text: text); } } public class Link : Node { public string id { get; construct; } public Link(string id, string text) { Object(id: id, text: text); } } public class Parsed : Object { public GLib.Array<Node>nodes { get; construct; } public string label { owned get { var builder = new GLib.StringBuilder(); foreach (var node in nodes.data) { builder.append(node.text); } return builder.free_and_steal(); } } public Parsed.from_string(string input) { Object(nodes: parse(input)); } private static GLib.Array<Node>parse(string input) { var nodes = new GLib.Array<Node>(); int i = 0; while (i < input.length) { var link_start = input.index_of("[[", i); if (link_start < 0) { var node = new Text(input.slice(i, input.length)); nodes.append_val(node); break; } var link_separator = input.index_of_char('|', link_start); if (link_separator < 0) { var node = new Text(input.slice(i, input.length)); nodes.append_val(node); break; } var link_end = input.index_of("]]", link_separator); if (link_end < 0) { var node = new Text(input.slice(i, input.length)); nodes.append_val(node); break; } if (link_start > i) { var text = new Text(input.slice(i, link_start)); nodes.append_val(text); } var id = input.slice(link_start + 2, link_separator); var label = input.slice(link_separator + 1, link_end); var link = new Link(id, label); nodes.append_val(link); i = link_end + 2; } return nodes; } } } } }
-
-
-
@@ -22,6 +22,14 @@ SERVER_MISMATCH,NETWORK_ERROR, } public errordomain ConnectError { SERVER_NOT_FOUND, INVALID_REQUEST, INVALID_RESPONSE, SERVER_MISMATCH, NETWORK_ERROR, } public class Server : Object { /** * IP address of the server.
-
@@ -52,48 +60,19 @@public static async Server from_address( GLib.InetSocketAddress address, uint16 http_port, string id, GLib.Cancellable? cancellable = null ) throws ResolveServerError { var session_singleton = new Session(); var session = session_singleton.session; var url = GLib.Uri.build( NONE, "ws:", null, address.address.to_string(), http_port, "/api", null, null ); var msg = new Soup.Message.from_uri("GET", url); Soup.WebsocketConnection conn; try { // Roon API does not specify WebSocket subprotocols. conn = yield session.websocket_connect_async( msg, null, null, GLib.Priority.DEFAULT, cancellable ); conn = yield ws_connect(address, http_port, cancellable); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_DEBUG, "Handshake error"); throw new ResolveServerError.NETWORK_ERROR(error.message); } var message = yield get_message(conn); Moo.Metadata meta; Moo.Headers headers; Roon.Registry.Info.Response info; try { meta = new Moo.Metadata.from_string(message); headers = new Moo.Headers.from_string(message, meta); if (headers.request_id != 1) { throw new ResolveServerError.SERVER_MISMATCH("Request-Id does not match."); } if (headers.content_type != "application/json") { throw new ResolveServerError.SERVER_MISMATCH("Not a JSON body."); } var body = new Moo.JsonBody.from_string(message, headers); info = new Roon.Registry.Info.Response.from_json(body.data); } catch (GLib.Error error) { throw new ResolveServerError.SERVER_MISMATCH(error.message); } if (info == null) { throw new ResolveServerError.SERVER_MISMATCH("Got invalid info response"); info = yield get_registry_info(conn); } catch (ConnectError error) { throw new ResolveServerError.SERVER_NOT_FOUND(error.message); } if (info.core_id != id) {
-
@@ -103,37 +82,164 @@return new Server(address, id, info.display_version, info.display_name, http_port); } private static async string get_message(Soup.WebsocketConnection conn) throws ResolveServerError { SourceFunc callback = get_message.callback; GLib.Bytes? bytes = null; private static async Soup.WebsocketConnection ws_connect( GLib.InetSocketAddress address, uint16 port, GLib.Cancellable? cancellable = null ) throws GLib.Error { var session_singleton = new Session(); var session = session_singleton.session; var url = GLib.Uri.build( NONE, "ws:", null, address.address.to_string(), port, "/api", null, null ); var msg = new Soup.Message.from_uri("GET", url); // Roon API does not specify WebSocket subprotocols. Soup.WebsocketConnection conn = yield session.websocket_connect_async( msg, null, null, GLib.Priority.DEFAULT, cancellable ); return conn; } conn.message.connect((c, type, b) => { bytes = b; private static async Response send_request( Soup.WebsocketConnection conn, string service, uint64 request_id, string? json_body = null ) throws ConnectError { SourceFunc callback = send_request.callback; Response? resp = null; var message_handler_id = conn.message.connect((c, type, bytes) => { var message = (string) bytes.get_data(); Moo.Metadata res_meta; Moo.Headers res_headers; try { res_meta = new Moo.Metadata.from_string(message); res_headers = new Moo.Headers.from_string(message, res_meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } if (res_headers.request_id != request_id) { GLib.log("Plac", LEVEL_DEBUG, "Ignoring response with unexpected Request-Id"); return; } resp = new Response(res_meta, res_headers, message); callback(); }); conn.error.connect((c, error) => { var error_handler_id = conn.error.connect((c, error) => { GLib.log("Plac", LEVEL_ERROR, "Failed to receive MOO message: %s", error.message); callback(); }); conn.send_binary(build_request().data); var req_meta = new Moo.Metadata("REQUEST", service); var req_headers = new Moo.Headers(); req_headers.write("Request-Id", @"$request_id"); if (json_body != null) { req_headers.write("Content-Type", "application/json"); req_headers.write("Content-Length", @"$(json_body.length)"); conn.send_binary(@"$req_meta$req_headers$json_body".data); } else { conn.send_binary(@"$req_meta$req_headers".data); } yield; if (bytes == null) { throw new ResolveServerError.SERVER_NOT_FOUND("Server not found."); conn.disconnect(message_handler_id); conn.disconnect(error_handler_id); if (resp == null) { throw new ConnectError.SERVER_NOT_FOUND("Server not found."); } return resp; } private static async Roon.Registry.Info.Response get_registry_info( Soup.WebsocketConnection conn, uint64 request_id = 1 ) throws ConnectError { var resp = yield send_request(conn, "com.roonlabs.registry:1/info", request_id); if (resp.meta.service != "Success") { throw new ConnectError.INVALID_RESPONSE("Server error."); } if (resp.headers.content_type != "application/json") { throw new ConnectError.INVALID_RESPONSE("Not a JSON body."); } Roon.Registry.Info.Response info; try { var body = new Moo.JsonBody.from_string(resp.message, resp.headers); info = new Roon.Registry.Info.Response.from_json(body.data); } catch (GLib.Error error) { throw new ConnectError.INVALID_RESPONSE(error.message); } return (string) GLib.Bytes.unref_to_data(bytes); if (info == null) { throw new ConnectError.INVALID_RESPONSE("Unable to parse info response: Out of memory."); } return info; } private static string build_request() { var meta = new Moo.Metadata("REQUEST", "com.roonlabs.registry:1/info"); public async Connection connect_async(Roon.Registry.Register.Request req) throws ConnectError { GLib.log("Plac", LEVEL_DEBUG, @"Connecting to $(this.address.address):$(this.http_port)"); Soup.WebsocketConnection conn; try { conn = yield ws_connect(address, http_port); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_DEBUG, "Handshake error"); throw new ConnectError.NETWORK_ERROR(error.message); } var info = yield get_registry_info(conn, 1); var headers = new Moo.Headers(); headers.write("Request-Id", "1"); if (info.core_id != id) { throw new ConnectError.SERVER_MISMATCH("Got unexpected server ID."); } return @"$meta$headers"; GLib.log("Plac", LEVEL_DEBUG, "Confirmed the Roon server is running. Registering extension."); var req_json = req.to_json(); if (req_json == null) { throw new ConnectError.INVALID_REQUEST("Unable to serialize register request."); } var register_resp = yield send_request(conn, "com.roonlabs.registry:1/register", 2, req_json); if (register_resp.meta.service != "Registered") { throw new ConnectError.INVALID_RESPONSE(@"$(register_resp.meta.service)"); } Roon.Registry.Register.Response result; try { var body = new Moo.JsonBody.from_string(register_resp.message, register_resp.headers); result = new Roon.Registry.Register.Response.from_json(body.data); } catch (GLib.Error error) { throw new ConnectError.INVALID_RESPONSE(error.message); } if (result == null) { throw new ConnectError.INVALID_RESPONSE("Unable to parse info response: Out of memory."); } GLib.log("Plac", LEVEL_DEBUG, @"Registered Roon extension at $(this.address.address):$(this.http_port)"); return new Connection(conn, this, result.token); } } }
-
-
src/Plac/ZonesModel.vala (new)
-
@@ -0,0 +1,209 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 namespace Plac { namespace V2 { public errordomain ZonesModelError { SUBSCRIPTION_FAILED, } public class ZoneModel : Object { public Roon.Transport.Zone zone { get; set construct; } public ZoneModel(Roon.Transport.Zone zone) { Object(zone: zone); } public static bool is_same_zone(ZoneModel a, ZoneModel b) { return a.zone.zone_id == b.zone.zone_id; } } public class ZonesModel : Object { public signal void added(Roon.Transport.Zone zone); public signal void changed(Roon.Transport.Zone zone); public signal void removed(Roon.Transport.Zone zone); public signal void seek(Roon.Transport.SeekChange change); public signal void error(GLib.Error error); public Connection conn { get; set construct; } public GLib.ListStore zones { get; construct; } private uint zone_position = 0; public ZoneModel? zone { owned get { return (ZoneModel?) zones.get_item(zone_position); } set { uint position; if (zones.find_with_equal_func(value, (GLib.EqualFunc<ZoneModel>) ZoneModel.is_same_zone, out position)) { zone_position = position; } } } public ZonesModel(Connection conn) { Object(conn: conn, zones: new GLib.ListStore(typeof (ZoneModel))); } construct { this.notify["conn"].connect(() => { this.listen_changes(); }); this.listen_changes(); } private void listen_changes() { this.zones.remove_all(); this.listen_changes_async.begin((obj, res) => { try { this.listen_changes_async.end(res); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_CRITICAL, "Failed to subscribe to zone changes: %s", error.message); } }); } private async void listen_changes_async() throws GLib.Error { GLib.log("Plac", LEVEL_DEBUG, "Subscribing to zone changes..."); var req = new Roon.Transport.SubscribeZoneChanges.Request("1"); var res = yield conn.json_request("com.roonlabs.transport:2/subscribe_zones", req.to_json()); if (res.meta.service != "Subscribed") { throw new ZonesModelError.SUBSCRIPTION_FAILED(@"Expected Subscribed, got \"$(res.meta.service)\""); } var body = new Moo.JsonBody.from_string(res.message, res.headers); var result = new Roon.Transport.SubscribeZoneChanges.Response.from_json(body.data); if (result == null) { throw new ZonesModelError.SUBSCRIPTION_FAILED("Got invalid subscription response."); } foreach (var zone in result.zones) { var model = new ZoneModel(zone); zones.append(model); if (this.zone == null) { this.zone = model; } } GLib.log("Plac", LEVEL_DEBUG, "Subscribed successfully, found %u initial.", zones.n_items); conn.conn.message.connect((_conn, _type, bytes) => { var message = (string) bytes.get_data(); Moo.Metadata meta; Moo.Headers headers; try { meta = new Moo.Metadata.from_string(message); headers = new Moo.Headers.from_string(message, meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } if (meta.service != "Changed" || headers.request_id != res.headers.request_id) { return; } Moo.JsonBody event_body; try { event_body = new Moo.JsonBody.from_string(message, headers); } catch (Moo.JsonBodyParseError error) { GLib.log("Plac", LEVEL_WARNING, "Got zone change event, but payload is unparsable."); return; } var event = new Roon.Transport.SubscribeZoneChanges.Event.from_json(event_body.data); if (event == null) { GLib.log("Plac", LEVEL_WARNING, "Got unexpected zone change event."); return; } foreach (var id in event.removed_zone_ids) { for (uint i = 0; i < zones.n_items; i++) { var model = (ZoneModel) zones.get_item(i); if (model.zone.zone_id == id) { GLib.log("Plac", LEVEL_DEBUG, "Zone removed: %s", model.zone.display_name); zones.remove(i); this.removed(model.zone); break; } } } foreach (var added in event.added_zones) { var new_model = new ZoneModel(added); // Roon behaves pretty badly in state updates: they send // an "added" event initially. Subscribe method's response // already contains those zones. uint position; if (zones.find_with_equal_func(new_model, (GLib.EqualFunc<ZoneModel>) ZoneModel.is_same_zone, out position)) { var model = (ZoneModel) zones.get_item(position); model.zone = added; this.changed(added); } else { GLib.log("Plac", LEVEL_DEBUG, "New zone added: %s", added.display_name); zones.append(new_model); this.added(added); } } foreach (var changed in event.changed_zones) { var new_model = new ZoneModel(changed); // We might have missed an "added" event, so make sure // we safely handle change events to unknown zones. uint position; if (zones.find_with_equal_func(new_model, (GLib.EqualFunc<ZoneModel>) ZoneModel.is_same_zone, out position)) { var model = (ZoneModel) zones.get_item(position); model.zone = changed; this.changed(changed); } else { GLib.log("Plac", LEVEL_DEBUG, "New zone added by change event: %s", changed.display_name); zones.append(new_model); this.added(changed); } } foreach (var seek_change in event.seek_changes) { for (uint i = 0; i < zones.n_items; i++) { var model = (ZoneModel) zones.get_item(i); if (model.zone.zone_id == seek_change.zone_id && model.zone.now_playing != null) { if (seek_change.has_seek_position) { model.zone.now_playing.has_seek_info = true; model.zone.now_playing.seek_position = seek_change.seek_position; } else { model.zone.now_playing.has_seek_info = false; } this.seek(seek_change); break; } } } }); } } } }
-
-
src/Roon.vala (new)
-
@@ -0,0 +1,85 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 namespace Roon { namespace Image { public enum ScalingMethod { AUTO, FIT, FILL, STRETCH, } public enum ContentType { AUTO, JPEG, PNG, } public class Request : Object { public string image_key { get; construct; } public size_t width { get; set construct; } public size_t height { get; set construct; } public ScalingMethod scaling { get; set; default = AUTO; } public ContentType content_type { get; set; default = AUTO; } public Request(string image_key, size_t width, size_t height) { Object(image_key: image_key, width: width, height: height); } public GLib.Uri to_uri(GLib.InetAddress addr, uint16 port) { var query = @"width=$width&height=$height"; switch (scaling) { case FIT: query += "&scale=fit"; break; case FILL: query += "&scale=fill"; break; case STRETCH: query += "&scale=stretch"; break; default: break; } switch (content_type) { case JPEG: query += "&format=image/jpeg"; break; case PNG: query += "&format=image/png"; break; default: break; } return GLib.Uri.build( NONE, "http", null, addr.to_string(), (int) port, @"/api/image/$image_key", query, null ); } } } }
-
-
-
@@ -54,29 +54,10 @@ height_request = (int) value;} } private Plac.AsyncConnection? _conn = null; public Plac.AsyncConnection? conn { get { return _conn; } set { if (_conn != value) { _conn = value; render(); } } } public Plac.V2.Connection? conn { get; set construct; } public string? image_key { get; set construct; } private string? _image_key = null; public string? image_key { get { return _image_key; } set { if (_image_key != value) { _image_key = value; render(); } } } public Plac.Image.ScalingMethod scaling { get; set; default = FIT; } public Roon.Image.ScalingMethod scaling { get; set; default = FIT; } private Gtk.Picture picture = new Gtk.Picture(); private Adw.Spinner spinner = new Adw.Spinner();
-
@@ -115,13 +96,27 @@ error_icon.width_request = _width;error_icon.height_request = _height; error_icon.pixel_size = _width / 2; error_icon.visible = false; this.notify["conn"].connect(() => { render(); }); this.notify["image-key"].connect(() => { render(); }); render(); } public void render() { picture.paintable = null; if (_conn == null || _image_key == null) { if (image_key == null) { return; } if (conn == null) { GLib.log("Plac", LEVEL_DEBUG, "Skipping artwork download because connection is unset."); return; }
-
@@ -129,27 +124,10 @@ picture.visible = false;spinner.visible = true; error_icon.visible = false; var opts = new Plac.Image.GetOptions(); opts.set_size(scaling, _width, _height); opts.set_content_type(JPEG); GLib.Uri url; try { var raw = _conn.get_image_url(_image_key, opts); if (raw == null) { GLib.log("Plac", LEVEL_WARNING, "Failed to build image download URL for %s", _image_key); error_icon.visible = true; spinner.visible = false; return; } url = GLib.Uri.parse(raw, NONE); } catch (GLib.UriError error) { GLib.log("Plac", LEVEL_WARNING, "Failed to build image download URL for %s: %s", _image_key, error.message); error_icon.visible = true; spinner.visible = false; return; } var image = new Roon.Image.Request(image_key, width, height); image.scaling = scaling; image.content_type = PNG; var url = image.to_uri(conn.server.address.address, conn.server.http_port); var msg = new Soup.Message.from_uri("GET", url);
-
-
-
@@ -15,6 +15,11 @@ //// SPDX-License-Identifier: Apache-2.0 namespace PlacGtkAdwaita { private errordomain BrowseWidgetLoadPageError { BROWSE_ERROR, LOAD_ERROR, } [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/browse.ui")] class Browse : Gtk.Box { public signal void loading_start();
-
@@ -43,39 +48,46 @@ private unowned Adw.ToastOverlay toasts;private GLib.ListStore items_store = new GLib.ListStore(typeof (BrowseItem.Item)); private Plac.AsyncConnection? conn = null; private Plac.Transport.Zone? _zone = null; public Plac.Transport.Zone? zone { get { return _zone; } set { _zone = value; load(); } } private Plac.Browse.Hierarchy? _hierarchy = null; public Plac.Browse.Hierarchy hierarchy { get { return _hierarchy; } construct set { if (_hierarchy != value) { _hierarchy = value; item = null; load(); } } } private Plac.V2.Connection? conn = null; private BrowseItem.Item? item = null; private bool is_loading = false; private Settings settings = new Settings(); public Roon.Browse.Hierarchy hierarchy { get; set; default = BROWSE; } public Plac.V2.ZonesModel? zones_model { get; set; default = null; } public Roon.Transport.Zone? zone { get { if (zones_model == null || zones_model.zone == null) { return null; } return zones_model.zone.zone; } } public Browse() { Object(); } construct { this.notify["hierarchy"].connect(() => { this.item = null; load_page(); }); this.notify["zones-model"].connect(() => { if (zones_model != null) { zones_model.notify["zone"].connect(() => { this.item = null; load_page(); }); load_page(); } }); items.single_click_activate = true; items.model = new Gtk.NoSelection(items_store);
-
@@ -91,7 +103,7 @@ return;} this.item = item; load(false, false); load_page(false, false); }); back.clicked.connect(() => {
-
@@ -101,9 +113,10 @@settings.settings.bind(Settings.SHOW_BROWSE_ITEM_SEPARATORS, items, "show-separators", GET); } public void start(Plac.AsyncConnection? conn) { public void start(Plac.V2.Connection? conn, Plac.V2.ZonesModel zones_model) { this.conn = conn; this.load(); this.zones_model = zones_model; load_page(); } private void start_loading() {
-
@@ -122,69 +135,148 @@ }private void pop(bool scroll_to_top = true) { this.item = null; this.load(true, scroll_to_top); this.load_page(true, scroll_to_top); } private void load(bool pop = false, bool scroll_to_top = true) { if (conn == null || _hierarchy == null || is_loading) { private void load_page(bool pop = false, bool scroll_to_top = true) { if (is_loading) { return; } if (conn == null) { GLib.log("Plac", LEVEL_WARNING, "Connection is null"); return; } var item = this.item == null ? null : this.item.item; start_loading(); conn.browse.begin(_hierarchy, _zone, item, pop, (obj, res) => { var result = conn.browse.end(res); if (result.code != OK) { GLib.log("Plac", LEVEL_WARNING, "Browse request failed: %s", result.code.to_string()); var toast = new Adw.Toast.format("Browse operation failed: %s", result.code.to_string()); load_page_async.begin(pop, scroll_to_top, (obj, res) => { try { load_page_async.end(res); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Failed to open a page: %s", error.message); var toast = new Adw.Toast.format("Failed to open a page: %s", error.message); toast.priority = HIGH; toasts.add_toast(toast); end_loading(); return; } if (result.action != LIST) { GLib.log("Plac", LEVEL_WARNING, "Unexpected result action: %s", result.action.to_string()); var toast = new Adw.Toast.format( "Browse operation failed: unexpected item type %s", result.action.to_string() ); toast.priority = HIGH; toasts.add_toast(toast); end_loading(); return; } end_loading(); }); } var action = result.get_list_action(); private async void load_page_async(bool pop, bool scroll_to_top) throws GLib.Error { var browse_req = new Roon.Browse.Browse.Request(hierarchy); if (item != null) { browse_req.item_key = item.item.item_key; } if (zone != null) { browse_req.zone_or_output_id = zone.zone_id; } if (!pop && item == null) { browse_req.pop_all = true; } if (pop) { browse_req.pop_levels = 1; } var browse_res = yield browse_async(browse_req); if (browse_res.message != null) { var toast = new Adw.Toast(browse_res.message); toast.priority = NORMAL; toasts.add_toast(toast); } switch (browse_res.action) { case MESSAGE: case NONE: return; case REPLACE_ITEM: case REMOVE_ITEM: var action_name = browse_res.action.to_string(); GLib.log( "Plac", LEVEL_CRITICAL, @"Roon server returned $(action_name) action, but Plac does not support the action." ); return; case LIST: break; } if (browse_res.list == null) { throw new BrowseWidgetLoadPageError.BROWSE_ERROR( "Roon server returned LIST action, but list property is empty." ); } var load_req = new Roon.Browse.Load.Request(hierarchy); load_req.count = uint16.MAX; load_req.level = browse_res.list.level; var load_res = yield load_async(load_req); if (settings.label_parsing_enabled) { var parsed = new Plac.V2.Label.Parsed.from_string(load_res.list.title); title.label = parsed.label; } else { title.label = load_res.list.title; } if (load_res.list.subtitle != null) { if (settings.label_parsing_enabled) { title.label = new Plac.Browse.Label(action.title).plain_text; } else { title.label = action.title; } if (action.subtitle != null) { if (settings.label_parsing_enabled) { subtitle.label = new Plac.Browse.Label(action.subtitle).plain_text; } else { subtitle.label = action.subtitle; } subtitle.visible = true; var parsed = new Plac.V2.Label.Parsed.from_string(load_res.list.subtitle); subtitle.label = parsed.label; } else { subtitle.visible = false; subtitle.label = load_res.list.subtitle; } subtitle.visible = true; } else { subtitle.visible = false; } items_store.remove_all(); if (scroll_to_top) { scroller.vadjustment.value = scroller.vadjustment.lower; } foreach (var new_item in action.items) { items_store.append(new BrowseItem.Item(new_item, conn)); } items_store.remove_all(); if (scroll_to_top) { scroller.vadjustment.value = scroller.vadjustment.lower; } foreach (var new_item in load_res.items) { items_store.append(new BrowseItem.Item(new_item, conn)); } back.visible = load_res.list.level > 0; } private async Roon.Browse.Browse.Response browse_async(Roon.Browse.Browse.Request request) throws GLib.Error { var res = yield conn.json_request("com.roonlabs.browse:1/browse", request.to_json()); back.visible = action.level > 0; end_loading(); }); if (res.meta.service != "Success") { throw new BrowseWidgetLoadPageError.BROWSE_ERROR(@"Expected Success response, got $(res.meta.service)."); } var body = new Moo.JsonBody.from_string(res.message, res.headers); var result = new Roon.Browse.Browse.Response.from_json(body.data); if (result == null) { throw new BrowseWidgetLoadPageError.BROWSE_ERROR("Failed to parse browse response."); } return result; } private async Roon.Browse.Load.Response load_async(Roon.Browse.Load.Request request) throws GLib.Error { var res = yield conn.json_request("com.roonlabs.browse:1/load", request.to_json()); if (res.meta.service != "Success") { throw new BrowseWidgetLoadPageError.BROWSE_ERROR(@"Expected Success response, got $(res.meta.service)."); } var body = new Moo.JsonBody.from_string(res.message, res.headers); var result = new Roon.Browse.Load.Response.from_json(body.data); if (result == null) { throw new BrowseWidgetLoadPageError.BROWSE_ERROR("Failed to parse load response."); } return result; } } }
-
-
-
@@ -16,10 +16,10 @@ // SPDX-License-Identifier: Apache-2.0namespace PlacGtkAdwaita { class BrowseHierarchyRow : Gtk.ListBoxRow { public Plac.Browse.Hierarchy hierarchy { get; construct set; } public Roon.Browse.Hierarchy hierarchy { get; construct set; } public string label { get; construct set; } public BrowseHierarchyRow(Plac.Browse.Hierarchy hierarchy, string label) { public BrowseHierarchyRow(Roon.Browse.Hierarchy hierarchy, string label) { Object(hierarchy: hierarchy, label: label); }
-
-
-
@@ -17,15 +17,15 @@namespace PlacGtkAdwaita { namespace BrowseItem { class Item : Object { public Plac.Browse.Item item { get; construct; } public Plac.AsyncConnection? conn { get; construct; } public Roon.Browse.Item item { get; construct; } public Plac.V2.Connection? conn { get; construct; } public Item(Plac.Browse.Item item, Plac.AsyncConnection? conn = null) { public Item(Roon.Browse.Item item, Plac.V2.Connection? conn = null) { Object(item: item, conn: conn); } } // GListView requires a widget to calculate row sizes. // GListView requires a widget to calculate row sizes. private class RowContainer : Gtk.Box { private Gtk.Widget? child = null;
-
-
-
@@ -30,28 +30,29 @@[GtkChild] private unowned Gtk.Image icon; public Plac.Browse.Item item { get; construct; } public Roon.Browse.Item item { get; construct; } private Settings settings = new Settings(); public Plac.AsyncConnection? conn { get { return artwork.conn; } set construct { artwork.conn = value; } } public Plac.V2.Connection? conn { get; set construct; } public Navigation(Plac.Browse.Item item, Plac.AsyncConnection? conn = null) { public Navigation(Roon.Browse.Item item, Plac.V2.Connection? conn = null) { Object(item: item, conn: conn); } construct { this.bind_property("conn", artwork, "conn", SYNC_CREATE); if (settings.label_parsing_enabled) { title.label = new Plac.Browse.Label(item.title).plain_text; var parsed = new Plac.V2.Label.Parsed.from_string(item.title); title.label = parsed.label; } else { title.label = item.title; } if (item.subtitle != null) { if (settings.label_parsing_enabled) { subtitle.label = new Plac.Browse.Label(item.subtitle).plain_text; var parsed = new Plac.V2.Label.Parsed.from_string(item.subtitle); subtitle.label = parsed.label; } else { subtitle.label = item.subtitle; }
-
@@ -59,7 +60,6 @@ subtitle.visible = true;} if (item.image_key != null) { artwork.conn = conn; artwork.image_key = item.image_key; artwork.visible = true; }
-
-
-
@@ -17,13 +17,13 @@namespace PlacGtkAdwaita { namespace BrowseItem { class Row : Gtk.ListBoxRow { public Plac.Browse.Item item { get; construct; } public Roon.Browse.Item item { get; construct; } public Plac.AsyncConnection? conn { public Plac.V2.Connection? conn { get; construct; } public Row(Plac.Browse.Item item, Plac.AsyncConnection? conn = null) { public Row(Roon.Browse.Item item, Plac.V2.Connection? conn = null) { Object(item: item, conn: conn); }
-
-
-
@@ -15,9 +15,14 @@ //// SPDX-License-Identifier: Apache-2.0 namespace PlacGtkAdwaita { private errordomain PlaybackToolbarError { CONTROL_FAILED, SEEK_FAILED, } [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/playback-toolbar.ui")] class PlaybackToolbar : Gtk.Box { public signal void zone_selected(Plac.Transport.Zone? zone); public signal void zone_selected(Roon.Transport.Zone? zone); [GtkChild] private unowned Gtk.Box controls;
-
@@ -61,128 +66,65 @@[GtkChild] private unowned Artwork artwork; private GLib.ListStore zones = new GLib.ListStore(typeof (ZoneWrapper)); private bool is_seeking = false; private int64? next_seek = null; private ZoneWrapper? _selected = null; private ZoneWrapper? selected { get { return _selected; } set { _selected = value; zone_selected(value.zone); next_seek = null; } } private Settings settings = new Settings(); private Plac.V2.ZoneModel? watching_zone = null; private Plac.Transport.Zone? zone { get { if (selected != null) { return selected.zone; private Plac.V2.ZoneModel? selected_zone { owned get { if (zones_model == null) { return null; } return null; return zones_model.zone; } } private string? zone_id { get { if (zone != null) { return zone.id; set { if (zones_model == null) { return; } return null; zones_model.zone = value; zone_selected(value.zone); next_seek = null; } } public Plac.AsyncConnection? conn { get { return artwork.conn; } set { if (artwork.conn != null) { artwork.conn.zones_changed.disconnect(on_zone_change); } public Plac.V2.Connection? conn { get; set construct; } public Plac.V2.ZonesModel? zones_model { get; set; default = null; } artwork.conn = value; value.zones_changed.connect(on_zone_change); } public PlaybackToolbar() { Object(); } private void on_zone_change(Plac.Transport.ZoneListEvent event) { bool should_set_zone = this.selected == null; construct { this.bind_property("conn", artwork, "conn", SYNC_CREATE); foreach (string id in event.removed) { for (int i = 0; i < zones.n_items; i++) { var item = (ZoneWrapper) zones.get_item(i); if (item != null && item.zone.id == id) { zones.remove(i); if (zone_id == id) { should_set_zone = true; } break; } } GLib.log("Plac", LEVEL_DEBUG, "Zone id=%s removed", id); } this.notify["zones-model"].connect(() => { if (zones_model != null) { zone_list.model = zones_model.zones; foreach (Plac.Transport.Zone zone in event.added) { upsert_zone(zone); } zones_model.notify["zone"].connect(() => { this.render(); foreach (Plac.Transport.Zone zone in event.changed) { upsert_zone(zone); } if (watching_zone != null) { watching_zone.notify["zone"].disconnect(this.render); watching_zone = null; } foreach (Plac.Transport.SeekChange change in event.seek_changed) { for (int i = 0; i < zones.n_items; i++) { var item = (ZoneWrapper?) zones.get_item(i); if (item != null && item.zone.id == change.zone_id) { var zone = item.zone; if (zone.now_playing != null) { zone.now_playing.seek_position = change.seek_position; zone.now_playing.has_seek_position = true; if (zones_model.zone != null) { zones_model.zone.notify["zone"].connect(this.render); } } } } }); if (should_set_zone) { var first = zones.get_item(0); if (first != null) { this.selected = (ZoneWrapper) first; } else { this.selected = null; zones_model.seek.connect(this.update_seek); } } this.render(); } private void upsert_zone(Plac.Transport.Zone zone) { var payload = new ZoneWrapper(zone); GLib.EqualFunc<ZoneWrapper>eq = ZoneWrapper.is_equal; uint found_position = 0; var found = zones.find_with_equal_func(payload, eq, out found_position); if (!found) { zones.append(payload); return; } this.render(); }); var item = (ZoneWrapper) zones.get_item(found_position); item.zone = zone; } public PlaybackToolbar() { Object(); } construct { var zone_list_factory = new Gtk.SignalListItemFactory(); zone_list_factory.setup.connect((item) => {
-
@@ -193,62 +135,84 @@zone_list_factory.bind.connect((item) => { var list_item = (Gtk.ListItem) item; var label = (Gtk.Label) list_item.child; var wrapper = (ZoneWrapper) list_item.item; label.label = wrapper.zone.name; var model = (Plac.V2.ZoneModel) list_item.item; label.label = model.zone.display_name; }); zone_list.model = zones; zone_list.factory = zone_list_factory; zone_list.notify["selected"].connect(() => { this.selected = (ZoneWrapper) zones.get_item(zone_list.selected); this.render(); var model = (Plac.V2.ZoneModel) zones_model.zones.get_item(zone_list.selected); if (model != null) { this.selected_zone = model; this.render(); } }); play.clicked.connect(() => { if (conn == null || zone == null) { if (conn == null || selected_zone == null) { return; } this.disable_actions(); conn.control.begin(zone, Plac.Transport.ACTION_PLAY, (obj, res) => { conn.control.end(res); this.control_async.begin(conn, selected_zone.zone, PLAY, (_obj, res) => { try { this.control_async.end(res); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_CRITICAL, "Play control failed: %s", error.message); } this.enable_actions(); }); }); pause.clicked.connect(() => { if (conn == null || zone == null) { if (conn == null || selected_zone == null) { return; } this.disable_actions(); conn.control.begin(zone, Plac.Transport.ACTION_PAUSE, (obj, res) => { conn.control.end(res); this.control_async.begin(conn, selected_zone.zone, PAUSE, (_obj, res) => { try { this.control_async.end(res); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_CRITICAL, "Pause control failed: %s", error.message); } this.enable_actions(); }); }); prev.clicked.connect(() => { if (conn == null || zone == null) { if (conn == null || selected_zone == null) { return; } this.disable_actions(); conn.control.begin(zone, Plac.Transport.ACTION_PREV, (obj, res) => { conn.control.end(res); this.control_async.begin(conn, selected_zone.zone, PREVIOUS, (_obj, res) => { try { this.control_async.end(res); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_CRITICAL, "Back-to-previous control failed: %s", error.message); } this.enable_actions(); }); }); next.clicked.connect(() => { if (conn == null || zone == null) { if (conn == null || selected_zone == null) { return; } this.disable_actions(); conn.control.begin(zone, Plac.Transport.ACTION_NEXT, (obj, res) => { conn.control.end(res); this.control_async.begin(conn, selected_zone.zone, NEXT, (_obj, res) => { try { this.control_async.end(res); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_CRITICAL, "Go-to-next control failed: %s", error.message); } this.enable_actions(); }); });
-
@@ -270,7 +234,6 @@ seek.set_value(next_value);schedule_seek((int64) next_value); }); seek.set_increments(1, 10); settings.settings.bind(Settings.SHOW_SEEK_BY_10SECS, seek_backwards_10, "visible", GET);
-
@@ -280,7 +243,7 @@ this.render();} private void dequeue_seek() { if (conn == null || next_seek == null || zone == null) { if (conn == null || next_seek == null || selected_zone == null) { is_seeking = false; return; }
-
@@ -288,11 +251,11 @@var position = next_seek; next_seek = null; conn.seek.begin(zone, position, (obj, res) => { var result = conn.seek.end(res); if (result != OK) { GLib.log("Plac", LEVEL_WARNING, "Failed to seek: %s", result.to_string()); seek_async.begin(conn, selected_zone.zone, position, (_obj, res) => { try { seek_async.end(res); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Failed to seek: %s", error.message); } dequeue_seek();
-
@@ -322,23 +285,35 @@ // of a duration longer than max(uint32)return "%02u:%02u:%02u".printf((uint) h, (uint) m, (uint) s); } private void update_seek(Roon.Transport.SeekChange change) { if (selected_zone == null || selected_zone.zone.zone_id != change.zone_id) { return; } if (seek.sensitive) { seek.set_value(change.seek_position); seek.tooltip_text = format_seconds(change.seek_position); } } private void render() { var zone = this.zone; if (zone == null) { if (selected_zone == null) { play.visible = true; pause.visible = false; return; } play.sensitive = (zone.allowed_action & Plac.Transport.ACTION_PLAY) != 0; pause.sensitive = (zone.allowed_action & Plac.Transport.ACTION_PAUSE) != 0; prev.sensitive = (zone.allowed_action & Plac.Transport.ACTION_PREV) != 0; next.sensitive = (zone.allowed_action & Plac.Transport.ACTION_NEXT) != 0; var zone = selected_zone.zone; play.sensitive = zone.is_play_allowed; pause.sensitive = zone.is_pause_allowed; prev.sensitive = zone.is_previous_allowed; next.sensitive = zone.is_next_allowed; if (zone.now_playing != null) { playing_line1.label = zone.now_playing.two_line_line1; playing_line2.label = zone.now_playing.two_line_line2 != null ? zone.now_playing.two_line_line2 playing_line1.label = zone.now_playing.two_line_1; playing_line2.label = zone.now_playing.two_line_2 != null ? zone.now_playing.two_line_2 : ""; artwork.image_key = zone.now_playing.image_key; } else {
-
@@ -349,8 +324,7 @@ }if ( zone.now_playing != null && zone.now_playing.has_length && zone.now_playing.has_seek_position zone.now_playing.has_seek_info ) { seek.sensitive = true; seek.set_range(0, zone.now_playing.length);
-
@@ -363,7 +337,7 @@ seek.set_value(0);seek.tooltip_text = "Not playing"; } switch (zone.playback) { switch (zone.state) { case LOADING: case STOPPED: case PAUSED:
-
@@ -417,19 +391,18 @@ zone_outputs.remove(row);} } private void update_output(Plac.Transport.Output output) { private void update_output(Roon.Transport.Output output) { int i = 0; while (true) { var row = (ZoneOutputRow?) zone_outputs.get_row_at_index(i++); if (row == null) { var new_row = new ZoneOutputRow(output); new_row.conn = conn; this.bind_property("conn", new_row, "conn", DEFAULT); this.bind_property("conn", new_row, "conn", SYNC_CREATE); zone_outputs.append(new_row); return; } if (row.output_id != output.id) { if (row.output_id != output.output_id) { continue; }
-
@@ -450,19 +423,29 @@ zone_list_popover.sensitive = true;controls.sensitive = true; seek.sensitive = true; } } // GLib and its ecosystem heavily relies on GObject. // Wrapping a class to avoid GLib from infecting to other parts. class ZoneWrapper : Object { public Plac.Transport.Zone zone { get; set construct; } private async void control_async( Plac.V2.Connection conn, Roon.Transport.Zone zone, Roon.Transport.ControlType control ) throws GLib.Error { var req = new Roon.Transport.Control.Request(zone.zone_id, control); var res = yield conn.json_request("com.roonlabs.transport:2/control", req.to_json()); public ZoneWrapper(Plac.Transport.Zone zone) { Object(zone: zone); if (res.meta.service != "Success") { throw new PlaybackToolbarError.CONTROL_FAILED(@"Expected Success response, got $(res.meta.service)."); } } public static bool is_equal(ZoneWrapper a, ZoneWrapper b) { return a.zone.id == b.zone.id; private async void seek_async( Plac.V2.Connection conn, Roon.Transport.Zone zone, int64 seconds ) throws GLib.Error { var req = new Roon.Transport.Seek.Request(zone.zone_id); req.how = ABSOLUTE; req.seconds = seconds; var res = yield conn.json_request("com.roonlabs.transport:2/seek", req.to_json()); if (res.meta.service != "Success") { throw new PlaybackToolbarError.SEEK_FAILED(@"Expected Success response, got $(res.meta.service)."); } } } }
-
-
-
@@ -15,6 +15,16 @@ //// SPDX-License-Identifier: Apache-2.0 namespace PlacGtkAdwaita { private errordomain ZoneOutputRowError { CHANGE_VOLUME_FAILED, STEP_VOLUME_FAILED, } private enum ZoneOutputRowVolumeStepDirection { INCREASE, DECREASE, } [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/zone-output-row.ui")] class ZoneOutputRow : Gtk.ListBoxRow { [GtkChild]
-
@@ -44,34 +54,40 @@public bool stale = false; public string output_id { get { return _output.id; } get { return _output.output_id; } } public Plac.AsyncConnection? conn { get; set construct; } public Plac.V2.Connection? conn { get; set construct; } private Plac.Transport.Output _output; public Plac.Transport.Output output { private Roon.Transport.Output _output; public Roon.Transport.Output output { get { return _output; } set construct { _output = value; display_name.label = value.display_name; if (value.is_incremental_volume) { if (value.volume == null) { incremental_control.visible = false; value_control.visible = false; return; } if (value.volume.type == "incremental") { incremental_control.visible = true; value_control.visible = false; } else { incremental_control.visible = false; value_control.visible = true; if (volume_slider.adjustment.lower != output.volume.min) { volume_slider.adjustment.lower = output.volume.min; if (volume_slider.adjustment.lower != value.volume.min) { volume_slider.adjustment.lower = value.volume.min; } if (volume_slider.adjustment.upper != output.volume.max) { volume_slider.adjustment.upper = output.volume.max; if (volume_slider.adjustment.upper != value.volume.max) { volume_slider.adjustment.upper = value.volume.max; } if (!is_moving_volume && volume_slider.adjustment.value != output.volume.value) { volume_slider.adjustment.value = output.volume.value; if (!is_moving_volume && volume_slider.adjustment.value != value.volume.value) { volume_slider.adjustment.value = value.volume.value; } if (volume_slider.adjustment.step_increment != output.volume.step) { volume_slider.adjustment.step_increment = output.volume.step; if (volume_slider.adjustment.step_increment != value.volume.step) { volume_slider.adjustment.step_increment = value.volume.step; } } }
-
@@ -80,32 +96,31 @@private bool is_incrementing_volume = false; // true = increase, false = decrease private Gee.ArrayQueue<bool>volume_increment_queue = new Gee.ArrayQueue<bool>(); private Gee.ArrayQueue<ZoneOutputRowVolumeStepDirection>volume_increment_queue = new Gee.ArrayQueue<ZoneOutputRowVolumeStepDirection>(); private bool is_moving_volume = false; private double? next_volume_move_to = null; public ZoneOutputRow(Plac.Transport.Output output) { public ZoneOutputRow(Roon.Transport.Output output) { Object(output: output); } construct { volume_up_incr.clicked.connect(() => { queue_incremental_volume_change(true); queue_incremental_volume_change(INCREASE); }); volume_up.clicked.connect(() => { var current = next_volume_move_to != null ? next_volume_move_to : output.volume.value; schedule_volume_change(current + output.volume.step); queue_incremental_volume_change(INCREASE); }); volume_down_incr.clicked.connect(() => { queue_incremental_volume_change(false); queue_incremental_volume_change(DECREASE); }); volume_down.clicked.connect(() => { var current = next_volume_move_to != null ? next_volume_move_to : output.volume.value; schedule_volume_change(current - output.volume.step); queue_incremental_volume_change(DECREASE); }); volume_slider.change_value.connect((scroll, value) => {
-
@@ -114,8 +129,8 @@ return false;}); } private void queue_incremental_volume_change(bool is_increase) { volume_increment_queue.offer(is_increase); private void queue_incremental_volume_change(ZoneOutputRowVolumeStepDirection direction) { volume_increment_queue.offer(direction); if (!is_incrementing_volume) { is_incrementing_volume = true;
-
@@ -131,25 +146,16 @@ }var next = volume_increment_queue.poll(); if (next) { conn.increase_volume.begin(output, (obj, res) => { var code = conn.increase_volume.end(res); if (code != OK) { GLib.log("Plac", LEVEL_WARNING, "Increase volume failed: %s", code.to_string()); } dequeue_incremental_volume_change(); }); } else { conn.decrease_volume.begin(output, (obj, res) => { var code = conn.decrease_volume.end(res); if (code != OK) { GLib.log("Plac", LEVEL_WARNING, "Decrease volume failed: %s", code.to_string()); } step_volume_async.begin(conn, next, (_obj, res) => { try { step_volume_async.end(res); } catch (GLib.Error error) { var label = next == INCREASE ? "Increase" : "Decrease"; GLib.log("Plac", LEVEL_WARNING, "%s volume failed: %s", label, error.message); } dequeue_incremental_volume_change(); }); } dequeue_incremental_volume_change(); }); } private void schedule_volume_change(double value) {
-
@@ -170,14 +176,41 @@var value = next_volume_move_to; next_volume_move_to = null; conn.change_volume.begin(output, value, (obj, res) => { var code = conn.change_volume.end(res); if (code != OK) { GLib.log("Plac", LEVEL_WARNING, "Change volume failed: %s", code.to_string()); change_volume_async.begin(conn, value, (_obj, res) => { try { change_volume_async.end(res); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Volume change failed: %s", error.message); } take_volume_change(); }); } private async void change_volume_async(Plac.V2.Connection conn, double value) throws GLib.Error { var req = new Roon.Transport.ChangeVolume.Request(output.output_id); req.how = ABSOLUTE; req.value = value; var res = yield conn.json_request("com.roonlabs.transport:2/change_volume", req.to_json()); if (res.meta.service != "Success") { throw new ZoneOutputRowError.CHANGE_VOLUME_FAILED(@"Expected Success response, got $(res.meta.service)."); } } private async void step_volume_async( Plac.V2.Connection conn, ZoneOutputRowVolumeStepDirection direction ) throws GLib.Error { double step = output.volume == null || output.volume.type == "incremental" ? 1.0 : output.volume.step; var req = new Roon.Transport.ChangeVolume.Request(output.output_id); req.how = RELATIVE; req.value = (direction == INCREASE ? 1.0 : -1.0) * step; var res = yield conn.json_request("com.roonlabs.transport:2/change_volume", req.to_json()); if (res.meta.service != "Success") { throw new ZoneOutputRowError.STEP_VOLUME_FAILED(@"Expected Success response, got $(res.meta.service)."); } } } }
-
-
-
@@ -39,19 +39,19 @@ private unowned Gtk.ListBox browse_hierarchy;private Settings settings = new Settings(); private Plac.Discovery.Server? server = null; private Plac.AsyncConnection? conn = null; private Plac.V2.Server? server = null; private Plac.V2.Connection? conn = null; private Plac.V2.ZonesModel? zones = null; private string server_id; public MainWindow(Gtk.Application app, Plac.Discovery.Server server) { public MainWindow(Gtk.Application app, Plac.V2.Server server) { (typeof (Artwork)).ensure(); (typeof (ServerConnecting)).ensure(); (typeof (PlaybackToolbar)).ensure(); Object(application: app); this.server = server; this.conn = new Plac.AsyncConnection(server); this.server_id = server.id; }
-
@@ -73,10 +73,6 @@ );error_banner.button_clicked.connect(() => { try_listen(); }); playback_toolbar.zone_selected.connect((zone) => { browse.zone = zone; }); var explore_row = new BrowseHierarchyRow(BROWSE, "Explore");
-
@@ -124,7 +120,7 @@this.present(); } private void try_listen() { private void try_listen(string? token = null) { error_banner.revealed = false; if (conn != null) {
-
@@ -139,14 +135,13 @@ error_banner.revealed = true;return; } listen_events(); listen_events(token); }); } } private void try_listen_with_addr(string ip_addr, uint16 port) { if (conn != null) { listen_events(); return; }
-
@@ -155,61 +150,61 @@ var patched_ip_addr = colon < 0 ? ip_addr : ip_addr.slice(0, colon);var address = new GLib.InetSocketAddress.from_string(patched_ip_addr, port); Plac.V2.Server.from_address.begin(address, port, server_id, null, (obj, res) => { try { var server = Plac.V2.Server.from_address.end(res); this.server = new Plac.Discovery.Server( server.id, server.name, server.version, server.address.to_string(), server.http_port ); conn = new Plac.AsyncConnection.with_token(this.server, settings.connected_server_token); this.server = Plac.V2.Server.from_address.end(res); } catch (Plac.V2.ResolveServerError error) { GLib.log("Plac", LEVEL_DEBUG, "Failed to resolve: %s", error.message); GLib.log("Plac", LEVEL_INFO, "Failed to restore connection, scanning server"); try_listen(); try_listen(settings.connected_server_token); return; } listen_events(); listen_events(settings.connected_server_token); }); } private void listen_events() { private void listen_events(string? token = null) { this.title = "Plac - %s".printf(server.name); conn.connection_started.connect(() => { root_stack.visible_child_name = "loading"; playback_toolbar.visible = false; }); root_stack.visible_child_name = "loading"; playback_toolbar.visible = false; conn.connected.connect((event) => { root_stack.visible_child_name = "main"; playback_toolbar.visible = true; var req = new Roon.Registry.Register.Request(); // TODO: Configure these via build parameters req.extension_id = "jp.pocka.plac.gtk-adwaita"; req.display_name = "Plac for GTK"; req.display_version = "0.0.0-dev"; req.publisher = "Shota FUJI"; req.email = "pockawoooh@gmail.com"; if (token != null && token != "") { req.token = token; } req.append_required_services("com.roonlabs.transport:2"); req.append_required_services("com.roonlabs.browse:1"); req.append_provided_services("com.roonlabs.ping:1"); settings.connected_server_id = server_id; settings.connected_server_token = event.token; settings.connected_server_addr = server.ip_addr; settings.connected_server_port = server.http_port; server.connect_async.begin(req, (obj, res) => { try { var conn = server.connect_async.end(res); this.conn = conn; zones = new Plac.V2.ZonesModel(conn); browse.start(conn); }); root_stack.visible_child_name = "main"; playback_toolbar.visible = true; conn.connection_error.connect((event) => { GLib.log("Plac", LEVEL_CRITICAL, "Failed to connect: %s", event.code.to_string()); error_banner.title = "Connection error: %s".printf(event.code.to_string()); error_banner.revealed = true; }); settings.connected_server_id = server.id; settings.connected_server_token = conn.token; settings.connected_server_addr = server.address.address.to_string(); settings.connected_server_port = server.http_port; conn.out_of_memory_error.connect(() => { GLib.log("Plac", LEVEL_CRITICAL, "Failed to connect: out of memory"); error_banner.title = "Connection error (out of memory)"; error_banner.revealed = true; playback_toolbar.conn = conn; playback_toolbar.zones_model = zones; browse.start(conn, zones); } catch (Plac.V2.ConnectError error) { GLib.log("Plac", LEVEL_CRITICAL, "Failed to connect: %s", error.message); error_banner.title = "Connection error: %s".printf(error.message); error_banner.revealed = true; } }); playback_toolbar.conn = conn; conn.activate(); } private async void resolve_server() throws GLib.Error {
-
@@ -221,16 +216,8 @@ scanner.start();scanner.found.connect((found) => { if (server_id == found.id) { var s = new Plac.Discovery.Server( found.id, found.name, found.version, found.address.to_string(), found.http_port ); server = s; conn = new Plac.AsyncConnection.with_token(s, settings.connected_server_token); Idle.add((owned) callback); server = found; callback(); } });
-
-
-
@@ -55,16 +55,7 @@row.open.connect(() => { scanner.stop(); var window = new MainWindow( application, new Plac.Discovery.Server( row.server.id, row.server.name, row.server.version, row.server.address.to_string(), row.server.http_port ) ); var window = new MainWindow(application, row.server); window.start(); this.close(); });
-
-
src/core/Arc.zig (deleted)
-
@@ -1,39 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 //! Atomic Reference Counting based on example code on `std.atomic.Value`. //! https://ziglang.org/documentation/0.14.0/std/#std.atomic.Value const std = @import("std"); count: std.atomic.Value(usize) = std.atomic.Value(usize).init(0), const Arc = @This(); /// Increment reference count by one. pub fn ref(arc: *Arc) void { _ = arc.count.fetchAdd(1, .monotonic); } /// Decrement reference count by one, returns `true` if no reference is alive. pub fn unref(arc: *Arc) bool { if (arc.count.fetchSub(1, .release) == 1) { _ = arc.count.load(.acquire); return true; } else { return false; } }
-
-
src/core/browse.zig (deleted)
-
@@ -1,867 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const Arc = @import("./Arc.zig"); const freelog = @import("./log.zig").freelog; const BrowseService = @import("./services/BrowseService.zig"); pub const Hierarchy = enum(c_int) { browse = 0, playlists = 1, settings = 2, internet_radio = 3, albums = 4, artists = 5, genres = 6, composers = 7, search = 8, }; pub const ItemHint = enum(c_int) { unknown = 0, action = 1, action_list = 2, list = 3, header = 4, }; pub const InputPrompt = extern struct { const cname = "plac_browse_input_prompt"; const allocator = std.heap.c_allocator; internal: *Internal, prompt: [*:0]const u8, action: [*:0]const u8, default_value: ?[*:0]const u8, is_password: bool, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const BrowseService.Item.InputPrompt) std.mem.Allocator.Error!*InputPrompt { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const prompt = try allocator.dupeZ(u8, src.prompt); errdefer allocator.free(prompt); const action = try allocator.dupeZ(u8, src.action); errdefer allocator.free(action); const default_value = if (src.value) |value| try allocator.dupeZ(u8, value) else null; errdefer if (default_value) |slice| allocator.free(slice); const self = try allocator.create(InputPrompt); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .prompt = prompt.ptr, .action = action.ptr, .default_value = if (default_value) |slice| slice.ptr else null, .is_password = src.is_password, }; return self; } pub fn retain(ptr: ?*InputPrompt) callconv(.C) *InputPrompt { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*InputPrompt) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); allocator.free(std.mem.span(self.prompt)); allocator.free(std.mem.span(self.action)); if (self.default_value) |default_value| { allocator.free(std.mem.span(default_value)); } allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const Item = extern struct { const cname = "plac_browse_item"; const allocator = std.heap.c_allocator; internal: *Internal, title: [*:0]const u8, subtitle: ?[*:0]const u8, image_key: ?[*:0]const u8, item_key: ?[*:0]const u8, hint: ItemHint, prompt: ?*InputPrompt, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const BrowseService.Item) std.mem.Allocator.Error!*Item { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const title = try allocator.dupeZ(u8, src.title); errdefer allocator.free(title); const subtitle = if (src.subtitle) |subtitle| try allocator.dupeZ(u8, subtitle) else null; errdefer if (subtitle) |slice| allocator.free(slice); const image_key = if (src.image_key) |key| try allocator.dupeZ(u8, key) else null; errdefer if (image_key) |slice| allocator.free(slice); const item_key = if (src.item_key) |key| try allocator.dupeZ(u8, key) else null; errdefer if (item_key) |slice| allocator.free(slice); const prompt = if (src.input_prompt) |*input_prompt| prompt: { const p = try InputPrompt.make(input_prompt); break :prompt p.retain(); } else null; errdefer if (prompt) |p| p.release(); const self = try allocator.create(Item); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .title = title, .subtitle = if (subtitle) |slice| slice.ptr else null, .image_key = if (image_key) |slice| slice.ptr else null, .item_key = if (item_key) |slice| slice.ptr else null, .hint = switch (src.hint) { .unknown => .unknown, .action => .action, .action_list => .action_list, .header => .header, .list => .list, }, .prompt = prompt, }; return self; } pub fn retain(ptr: ?*Item) callconv(.C) *Item { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*Item) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); if (self.prompt) |prompt| prompt.release(); if (self.item_key) |item_key| allocator.free(std.mem.span(item_key)); if (self.image_key) |image_key| allocator.free(std.mem.span(image_key)); if (self.subtitle) |subtitle| allocator.free(std.mem.span(subtitle)); allocator.free(std.mem.span(self.title)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ReplaceItemAction = extern struct { const cname = "plac_browse_replace_item_action"; const allocator = std.heap.c_allocator; internal: *Internal, item: *Item, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(item_src: *const BrowseService.Item) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const item = try Item.make(item_src); _ = item.retain(); errdefer item.release(); const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .item = item, }; return self; } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); self.item.release(); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ListAction = extern struct { const cname = "plac_browse_list_action"; const allocator = std.heap.c_allocator; internal: *Internal, title: [*:0]const u8, subtitle: ?[*:0]const u8, image_key: ?[*:0]const u8, level: u64, items_ptr: [*]*Item, items_len: usize, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const BrowseService.Load.Response) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const title = try allocator.dupeZ(u8, src.list.title); errdefer allocator.free(title); const subtitle = if (src.list.subtitle) |st| try allocator.dupeZ(u8, st) else null; errdefer if (subtitle) |st| allocator.free(st); const image_key = if (src.list.image_key) |ik| try allocator.dupeZ(u8, ik) else null; errdefer if (image_key) |ik| allocator.free(ik); const items = try allocator.alloc(*Item, src.items.len); errdefer allocator.free(items); var items_i: usize = 0; errdefer for (0..items_i) |i| { items[i].release(); }; for (src.items) |*item| { items[items_i] = try Item.make(item); _ = items[items_i].retain(); items_i += 1; } const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .title = title.ptr, .subtitle = if (subtitle) |st| st.ptr else null, .image_key = if (image_key) |ik| ik.ptr else null, .level = src.list.level, .items_ptr = items.ptr, .items_len = items.len, }; return self; } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); const items = self.items_ptr[0..self.items_len]; for (items) |item| { item.release(); } allocator.free(items); if (self.image_key) |image_key| allocator.free(std.mem.span(image_key)); if (self.subtitle) |subtitle| allocator.free(std.mem.span(subtitle)); allocator.free(std.mem.span(self.title)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ErrorMessageAction = extern struct { const cname = "plac_browse_error_message_action"; const allocator = std.heap.c_allocator; internal: *Internal, message: [*:0]const u8, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(message_src: []const u8) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const message = try allocator.dupeZ(u8, message_src); errdefer allocator.free(message); const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .message = message.ptr, }; return self; } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); allocator.free(std.mem.span(self.message)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const MessageAction = extern struct { const cname = "plac_browse_message_action"; const allocator = std.heap.c_allocator; internal: *Internal, message: [*:0]const u8, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(message_src: []const u8) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const message = try allocator.dupeZ(u8, message_src); errdefer allocator.free(message); const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .message = message.ptr, }; return self; } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); allocator.free(std.mem.span(self.message)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ResultCode = enum(c_int) { ok = 0, unknown_error = 1, missing_property = 2, out_of_memory = 3, failed_to_send = 4, closed = 5, timeout = 6, }; pub const ResultAction = enum(c_int) { none = 0, replace_item = 1, remove_item = 2, list = 3, error_message = 4, message = 5, }; pub const Result = extern struct { const cname = "plac_browse_result"; const allocator = std.heap.c_allocator; internal: *Internal, code: ResultCode, action: ResultAction = .none, pub const Payload = union(ResultAction) { none: void, replace_item: *ReplaceItemAction, remove_item: void, list: *ListAction, error_message: *ErrorMessageAction, message: *MessageAction, }; pub const Internal = struct { arc: Arc = .{}, payload: Payload = .none, }; fn makeError(code: ResultCode) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .code = code, }; return self; } pub fn makeRetainedError(code: ResultCode) std.mem.Allocator.Error!*@This() { const result = try makeError(code); return result.retain(); } fn make(payload: Payload) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); switch (payload) { .replace_item => |action| _ = action.retain(), .list => |action| _ = action.retain(), .error_message => |action| _ = action.retain(), .message => |action| _ = action.retain(), else => {}, } errdefer switch (payload) { .replace_item => |action| action.release(), .list => |action| action.release(), .error_message => |action| action.release(), .message => |action| action.release(), else => {}, }; internal.* = .{ .payload = payload, }; const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .code = .ok, .action = switch (payload) { .none => .none, .replace_item => .replace_item, .remove_item => .remove_item, .list => .list, .error_message => .error_message, .message => .message, }, }; return self; } pub fn makeRetained(payload: Payload) std.mem.Allocator.Error!*@This() { const result = try make(payload); return result.retain(); } fn exportActionGetter(comptime Action: type, tag: ResultAction, name: []const u8) void { const Getter = struct { fn get(ptr: ?*Result) callconv(.C) *Action { const self = ptr orelse @panic( std.fmt.comptimePrint( "Received null pointer on {s}_{s}", .{ cname, name }, ), ); if (@intFromEnum(self.internal.payload) == @intFromEnum(tag)) { return @field(self.internal.payload, @tagName(tag)).retain(); } else { std.log.err("{s}_{s} called on {s}", .{ cname, name, @tagName(self.internal.payload), }); @panic(std.fmt.comptimePrint("Union tag mismatch on {s}_{s}", .{ cname, name })); } } }; @export(&Getter.get, .{ .name = std.fmt.comptimePrint("{s}_{s}", .{ cname, name }) }); } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); switch (self.internal.payload) { .replace_item => |action| action.release(), .list => |action| action.release(), .error_message => |action| action.release(), .message => |action| action.release(), else => {}, } allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); exportActionGetter(ReplaceItemAction, .replace_item, "get_replace_item_action"); exportActionGetter(ListAction, .list, "get_list_action"); exportActionGetter(ErrorMessageAction, .error_message, "get_error_message_action"); exportActionGetter(MessageAction, .message, "get_message_action"); } }; const LabelParser = struct { const State = enum { non_link, id, label_text, }; pub fn parse(allocator: std.mem.Allocator, src: []const u8) std.mem.Allocator.Error![:0]const u8 { const dst = try allocator.allocSentinel(u8, src.len, 0); errdefer allocator.free(dst); @memset(dst, 0); var src_i: usize = 0; var dst_i: usize = 0; state: switch (State.non_link) { .non_link => { if (src_i >= src.len) { break :state; } if (std.mem.startsWith(u8, src[src_i..], "[[")) { src_i += 2; continue :state .id; } dst[dst_i] = src[src_i]; src_i += 1; dst_i += 1; continue :state .non_link; }, .id => { if (src_i >= src.len) { break :state; } defer src_i += 1; if (src[src_i] == '|') { continue :state .label_text; } continue :state .id; }, .label_text => { if (src_i >= src.len) { break :state; } if (std.mem.startsWith(u8, src[src_i..], "]]")) { src_i += 2; continue :state .non_link; } dst[dst_i] = src[src_i]; src_i += 1; dst_i += 1; continue :state .label_text; }, } return dst; } }; test LabelParser { { const x = try LabelParser.parse(std.testing.allocator, "Foo [[123|Bar]] Baz"); defer std.testing.allocator.free(x); try std.testing.expectEqualStrings("Foo Bar Baz", std.mem.span(x.ptr)); } { const x = try LabelParser.parse(std.testing.allocator, "[[123|Bar]]"); defer std.testing.allocator.free(x); try std.testing.expectEqualStrings("Bar", std.mem.span(x.ptr)); } { const x = try LabelParser.parse(std.testing.allocator, "[123|Bar]"); defer std.testing.allocator.free(x); try std.testing.expectEqualStrings("[123|Bar]", std.mem.span(x.ptr)); } { const x = try LabelParser.parse(std.testing.allocator, "[[]]"); defer std.testing.allocator.free(x); try std.testing.expectEqualStrings("", std.mem.span(x.ptr)); } { const x = try LabelParser.parse(std.testing.allocator, "[[|]]Foo"); defer std.testing.allocator.free(x); try std.testing.expectEqualStrings("Foo", std.mem.span(x.ptr)); } { const x = try LabelParser.parse(std.testing.allocator, "Foo Bar"); defer std.testing.allocator.free(x); try std.testing.expectEqualStrings("Foo Bar", std.mem.span(x.ptr)); } { const global = struct { fn testOne(_: void, input: []const u8) anyerror!void { const x = try LabelParser.parse(std.testing.allocator, input); defer std.testing.allocator.free(x); } }; try std.testing.fuzz(void{}, global.testOne, .{}); } } const Label = extern struct { const cname = "plac_browse_label"; const allocator = std.heap.c_allocator; internal: *Internal, plain_text: [*:0]const u8, const Internal = struct { arc: Arc = .{}, plain_text: [:0]const u8, const ParseState = enum { non_link, id, label_text, }; pub fn init(src: []const u8) std.mem.Allocator.Error!@This() { return .{ .plain_text = try LabelParser.parse(allocator, src) }; } pub fn deinit(self: *const Internal) void { allocator.free(self.plain_text); } }; fn make(src: []const u8) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = try Internal.init(src); errdefer internal.deinit(); const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .plain_text = internal.plain_text, }; return self; } fn makeRetained(src: [*:0]const u8) callconv(.C) ?*@This() { const result = make(std.mem.span(src)) catch { return null; }; return result.retain(); } fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); self.internal.deinit(); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&makeRetained, .{ .name = std.fmt.comptimePrint("{s}_from_string", .{cname}) }); @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub fn export_capi() void { InputPrompt.export_capi(); Item.export_capi(); ReplaceItemAction.export_capi(); ListAction.export_capi(); ErrorMessageAction.export_capi(); MessageAction.export_capi(); Result.export_capi(); Label.export_capi(); }
-
-
src/core/connection.zig (deleted)
-
@@ -1,1577 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); const websocket = @import("websocket"); const Arc = @import("./Arc.zig"); const browse = @import("./browse.zig"); const discovery = @import("./discovery.zig"); const extension = @import("./extension.zig").extension; const image = @import("./image.zig"); const freelog = @import("./log.zig").freelog; const BrowseService = @import("./services/BrowseService.zig"); const ImageService = @import("./services/ImageService.zig"); const PingService = @import("./services/PingService.zig"); const registry = @import("./services/registry.zig"); const TransportService = @import("./services/TransportService.zig"); const transport = @import("./transport.zig"); pub const ConnectionError = enum(c_int) { unknown = 0, closed_by_server = 1, out_of_memory = 2, unexpected_response = 3, network_unavailable = 4, network_error = 5, }; pub const ConnectionErrorEvent = extern struct { const cname = "plac_connection_connection_error_event"; const allocator = std.heap.c_allocator; internal: *Internal, code: ConnectionError, const Internal = struct { arc: Arc = .{}, }; pub fn make(code: ConnectionError) std.mem.Allocator.Error!*ConnectionErrorEvent { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const self = try allocator.create(ConnectionErrorEvent); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .code = code, }; return self; } pub fn retain(ptr: ?*ConnectionErrorEvent) callconv(.C) *ConnectionErrorEvent { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*ConnectionErrorEvent) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ConnectedEvent = extern struct { const cname = "plac_connection_connected_event"; const allocator = std.heap.c_allocator; internal: *Internal, token: [*:0]const u8, pub const Internal = struct { arc: Arc = .{}, }; /// This function takes ownership of `token`. pub fn make(token: [:0]const u8) std.mem.Allocator.Error!*ConnectedEvent { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const self = try allocator.create(ConnectedEvent); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .token = token.ptr, }; return self; } pub fn retain(ptr: ?*ConnectedEvent) callconv(.C) *ConnectedEvent { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*ConnectedEvent) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); allocator.free(std.mem.span(self.token)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const Event = extern struct { const cname = "plac_connection_event"; const allocator = std.heap.c_allocator; pub const Kind = enum(c_int) { connection_error = 0, connected = 1, zone_list = 10, }; internal: *Internal, kind: Kind, pub const Internal = struct { const Payload = union(Kind) { connection_error: *ConnectionErrorEvent, connected: *ConnectedEvent, zone_list: *transport.ZoneListEvent, }; payload: Payload, arc: Arc = .{}, }; pub fn makeConnectionError(err: anyerror) std.mem.Allocator.Error!*Event { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); const connection_error = try ConnectionErrorEvent.make(switch (err) { error.OutOfMemory => .out_of_memory, error.NetworkUnavailable => .network_unavailable, error.SocketError, error.SocketPermissionDenied => .network_error, error.RequestIdMismatch => .unexpected_response, error.ClosedByServer, error.ReadClosedConnection => .closed_by_server, else => .unknown, }); errdefer connection_error.release(); internal.* = .{ .payload = .{ .connection_error = connection_error.retain() }, }; const self = try allocator.create(Event); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .kind = .connection_error, }; return self; } /// This function takes ownership of `token`. pub fn makeConnected(token: [:0]const u8) std.mem.Allocator.Error!*Event { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); const connected = try ConnectedEvent.make(token); errdefer connected.release(); internal.* = .{ .payload = .{ .connected = connected.retain() }, }; const self = try allocator.create(Event); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .kind = .connected, }; return self; } pub fn makeZoneListFromInitial( res: *const TransportService.SubscribeZoneChanges.InitialResponse, ) std.mem.Allocator.Error!*Event { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); const zone_list = try transport.ZoneListEvent.makeFromInitial(res); errdefer zone_list.release(); internal.* = .{ .payload = .{ .zone_list = zone_list.retain() }, }; const self = try allocator.create(Event); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .kind = .zone_list, }; return self; } pub fn makeZoneListFromChanges( res: *const TransportService.SubscribeZoneChanges.Response, ) std.mem.Allocator.Error!*Event { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); const zone_list = try transport.ZoneListEvent.makeFromChanges(res); errdefer zone_list.release(); internal.* = .{ .payload = .{ .zone_list = zone_list.retain() }, }; const self = try allocator.create(Event); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .kind = .zone_list, }; return self; } pub fn retain(ptr: ?*Event) callconv(.C) *Event { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*Event) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); switch (self.internal.payload) { .connection_error => |ev| ev.release(), .connected => |ev| ev.release(), .zone_list => |ev| ev.release(), } allocator.destroy(self.internal); allocator.destroy(self); } } fn exportEventCastFunction(comptime TargetEvent: type, comptime kind: Kind) void { const Caster = struct { pub const fn_name = std.fmt.comptimePrint("{s}_get_{s}_event", .{ cname, @tagName(kind) }); pub fn cast(ptr: ?*Event) callconv(.C) *TargetEvent { const self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}", .{fn_name}), ); if (self.internal.payload == kind) { return @field(self.internal.payload, @tagName(kind)).retain(); } std.log.err("{s} called on {s}", .{ fn_name, @tagName(self.internal.payload) }); unreachable; } }; @export(&Caster.cast, .{ .name = Caster.fn_name }); } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); Event.exportEventCastFunction(ConnectionErrorEvent, .connection_error); Event.exportEventCastFunction(ConnectedEvent, .connected); Event.exportEventCastFunction(transport.ZoneListEvent, .zone_list); } }; fn Listener(comptime T: type, timeout_ms: usize) type { return struct { wrote: std.Thread.ResetEvent = .{}, data: ?T = null, pub fn listen(self: *@This()) error{Timeout}!T { try self.wrote.timedWait(timeout_ms * std.time.ns_per_ms); return self.data orelse @panic("Set Listener.wrote before writing data"); } pub fn write(self: *@This(), data: T) void { self.data = data; self.wrote.set(); } pub const Store = struct { const Map = std.AutoHashMap(u64, Listener(T, timeout_ms)); mutex: std.Thread.Mutex = .{}, map: Map, pub fn init(allocator: std.mem.Allocator) Store { return .{ .map = Map.init(allocator) }; } pub fn deinit(self: *Store) void { self.map.deinit(); } pub fn add(self: *Store, req_id: u64) !*Listener(T, timeout_ms) { self.mutex.lock(); defer self.mutex.unlock(); const entry = try self.map.getOrPut(req_id); entry.value_ptr.* = .{}; return entry.value_ptr; } /// Only call from the thread that calls "add". pub fn remove(self: *Store, req_id: u64) void { self.mutex.lock(); defer self.mutex.unlock(); _ = self.map.remove(req_id); } /// Caller needs to call "unlock" after reading or writing to the /// returned value. pub fn get(self: *Store, req_id: u64) ?*Listener(T, timeout_ms) { self.mutex.lock(); const entry = self.map.getPtr(req_id) orelse { self.mutex.unlock(); return null; }; return entry; } pub fn unlock(self: *Store) void { self.mutex.unlock(); } }; }; } fn JsonResponseListener(comptime T: type, timeout_ms: usize) type { return Listener(moo.JsonBody(T), timeout_ms); } pub const Connection = extern struct { const cname = "plac_connection"; const allocator = std.heap.c_allocator; const BrowseListener = JsonResponseListener(BrowseService.Browse.Response, 5_000); const LoadListener = JsonResponseListener(BrowseService.Load.Response, 5_000); const ControlListener = Listener(transport.ControlResultCode, 3_000); const SeekListener = Listener(transport.SeekResultCode, 2_000); const ChangeVolumeListener = Listener(transport.VolumeControlResultCode, 2_000); const EventsQueue = std.DoublyLinkedList(*Event); internal: *Internal, pub const Internal = struct { tsa: std.heap.ThreadSafeAllocator = .{ .child_allocator = allocator }, server: *discovery.Server, ws: ?websocket.Client = null, request_id: u64 = 0, request_id_lock: std.Thread.Mutex = .{}, subscription_id: u64 = 0, host: []const u8, zone_subscription_request_id: ?u64 = null, arc: Arc = .{}, saved_token: ?[]const u8 = null, browse_listeners: BrowseListener.Store, load_listeners: LoadListener.Store, control_events: ControlListener.Store, seek_events: SeekListener.Store, change_volume_events: ChangeVolumeListener.Store, read_loop: ?std.Thread = null, /// An event inside this queue is retained. events_queue: EventsQueue = .{}, events_queue_lock: std.Thread.Mutex = .{}, events_queue_cond: std.Thread.Condition = .{}, fn init(server: *discovery.Server, token: ?[]const u8) !Internal { var addr = std.ArrayList(u8).init(allocator); defer addr.deinit(); try addr.writer().print("{}", .{server.internal.address}); const addr_string = try addr.toOwnedSlice(); defer allocator.free(addr_string); // Zig std always prints "<addr>:<port>" for IPv4 and IPv6 const port_start = std.mem.lastIndexOfScalar(u8, addr_string, ':') orelse { unreachable; }; const host = try allocator.dupe(u8, addr_string[0..port_start]); errdefer allocator.free(host); const saved_token = if (token) |t| try allocator.dupe(u8, t) else null; errdefer if (saved_token) |t| { allocator.free(t); }; return .{ .server = server.retain(), .host = host, .saved_token = saved_token, .browse_listeners = undefined, .load_listeners = undefined, .control_events = undefined, .seek_events = undefined, .change_volume_events = undefined, }; } fn initListeners(self: *Internal) void { self.browse_listeners = BrowseListener.Store.init(allocator); self.load_listeners = LoadListener.Store.init(allocator); self.control_events = ControlListener.Store.init(allocator); self.seek_events = SeekListener.Store.init(allocator); self.change_volume_events = ChangeVolumeListener.Store.init(allocator); } fn deinitListeners(self: *Internal) void { self.browse_listeners.deinit(); self.load_listeners.deinit(); self.control_events.deinit(); self.seek_events.deinit(); self.change_volume_events.deinit(); } fn deinit(self: *Internal) void { self.deinitListeners(); if (self.read_loop) |thread| { thread.detach(); self.read_loop = null; } if (self.ws) |*ws| { ws.deinit(); } { defer self.events_queue_cond.broadcast(); self.events_queue_lock.lock(); defer self.events_queue_lock.unlock(); while (self.events_queue.popFirst()) |node| { node.data.release(); allocator.destroy(node); } } if (self.saved_token) |saved_token| { allocator.free(saved_token); } allocator.free(self.host); self.server.release(); } }; fn getRequestId(self: *Connection) u64 { self.internal.request_id_lock.lock(); defer self.internal.request_id_lock.unlock(); const current = self.internal.request_id; self.internal.request_id += 1; return current; } pub fn make(server_ptr: ?*discovery.Server, token: ?[*:0]const u8) callconv(.C) ?*Connection { const server = server_ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); const self = allocator.create(Connection) catch return null; const internal = allocator.create(Internal) catch { allocator.destroy(self); return null; }; internal.* = Internal.init(server, if (token) |t| std.mem.span(t) else null) catch |err| { std.log.err("Unable to establish connection to Roon Server: {s}", .{@errorName(err)}); allocator.destroy(internal); allocator.destroy(self); return null; }; self.* = .{ .internal = internal }; internal.arc.ref(); return self; } pub fn retain(ptr: ?*Connection) callconv(.C) *Connection { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*Connection) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); self.internal.deinit(); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn disconnect(ptr: ?*Connection) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.read_loop) |thread| { thread.detach(); self.internal.read_loop = null; } if (self.internal.ws) |*ws| { std.log.debug("Closing WebSocket connection...", .{}); // websocket.zig cannot properly close connection, when using unmanaged read. // As a workaround, we use manual termination snippet from: // https://github.com/karlseguin/websocket.zig/issues/46 std.posix.shutdown(ws.stream.stream.handle, .both) catch |err| { std.log.warn("WebSocket shutdown failed, ignoring: {s}", .{@errorName(err)}); }; std.posix.close(ws.stream.stream.handle); ws.deinit(); self.internal.ws = null; } self.internal.deinitListeners(); } pub fn getEvent(ptr: ?*Connection) callconv(.C) ?*Event { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.events_queue_lock.lock(); defer self.internal.events_queue_lock.unlock(); while (true) { const node = self.internal.events_queue.popFirst() orelse { if (self.internal.ws == null) { const new_token = self.connect(self.internal.saved_token) catch |err| { std.log.err("Unable to connect: {s}", .{@errorName(err)}); const event = Event.makeConnectionError(err) catch |make_err| { std.log.err("Unable to compose {s} event: {s}", .{ @tagName(Event.Kind.connection_error), @errorName(make_err), }); return null; }; return event.retain(); }; const event = Event.makeConnected(new_token) catch |err| { std.log.err("Unable to compose {s} event: {s}", .{ @tagName(Event.Kind.connected), @errorName(err), }); return null; }; return event.retain(); } if (self.internal.read_loop == null) { // After "deinit()". Hitting this branch means concurrent call to "getEvent", // which is illegal. return null; } self.internal.events_queue_cond.wait(&self.internal.events_queue_lock); continue; }; defer allocator.destroy(node); return node.data; } } const ReadError = error{ ClosedByServer, ReadClosedConnection, RequestIdMismatch, }; /// Returns a token. Caller owns the returned memory. fn connect(self: *Connection, token: ?[]const u8) ![:0]const u8 { std.log.debug( "Establishing WebSocket connection to {}...", .{self.internal.server.internal.address}, ); self.internal.initListeners(); errdefer self.internal.deinitListeners(); var ws = try websocket.Client.init(allocator, .{ .host = self.internal.host, .port = self.internal.server.internal.address.getPort(), .max_size = std.math.maxInt(u32), }); errdefer ws.deinit(); std.log.debug("Performing WebSocket handshake...", .{}); try ws.handshake("/api", .{ .timeout_ms = 1_000 }); var request_id: u64 = 0; { { std.log.debug("Checking server status...", .{}); const req = try registry.RegistryService.Info.request(allocator, request_id); defer allocator.free(req); try ws.writeBin(req); } _, const header_ctx, const msg = try readMessage(&ws); defer ws.done(msg); const header: moo.NoBodyHeaders, _ = try moo.NoBodyHeaders.parse(msg.data, header_ctx); if (header.request_id != request_id) { return ReadError.RequestIdMismatch; } const res = try registry.RegistryService.Info.response( allocator, header_ctx, msg.data, ); defer res.deinit(); request_id += 1; } const new_token = new_token: { { std.log.debug("Registering extension...", .{}); const req = try registry.RegistryService.Register.request( allocator, request_id, &extension, token, ); defer allocator.free(req); try ws.writeBin(req); } const meta, const header_ctx, const msg = try readMessage(&ws); defer ws.done(msg); const header: moo.NoBodyHeaders, _ = try moo.NoBodyHeaders.parse(msg.data, header_ctx); if (header.request_id != request_id) { return ReadError.RequestIdMismatch; } const res = try registry.RegistryService.Register.response( allocator, &meta, header_ctx, msg.data, ); defer res.deinit(); request_id += 1; break :new_token try allocator.dupeZ(u8, res.value.token); }; self.internal.request_id = request_id; self.internal.ws = ws; self.internal.read_loop = try std.Thread.spawn(.{}, readLoop, .{ self, &self.internal.ws.? }); return new_token; } fn readLoop(self: *Connection, ws: *websocket.Client) void { var should_use_pool = true; var pool: std.Thread.Pool = undefined; pool.init(.{ .allocator = allocator, }) catch |err| { std.log.warn( "Failed to create thread pool for events, handling event in read message thread: {s}", .{@errorName(err)}, ); should_use_pool = false; }; defer if (should_use_pool) pool.deinit(); if (should_use_pool) { std.log.debug("Using {d} threads for event handling", .{pool.threads.len}); } while (true) { _, _, const msg = readMessage(ws) catch |err| { if (err == error.ReadClosedConnection) { std.log.debug("WebSocket connection is closed", .{}); self.disconnect(); } else { std.log.err("Failed to read a message: {s}", .{@errorName(err)}); } const event = Event.makeConnectionError(err) catch |make_err| { std.log.err("Unable to compose {s} event: {s}", .{ @tagName(Event.Kind.connection_error), @errorName(make_err), }); return; }; self.queueEvent(event) catch { std.log.err("Out of memory at pushing event to queue", .{}); return; }; return; }; defer ws.done(msg); const data = allocator.dupe(u8, msg.data) catch { std.log.err("Out of memory at copying WebSocket message", .{}); continue; }; if (should_use_pool) { pool.spawn(handleEvent, .{ self, data }) catch |err| { allocator.free(data); std.log.err("Failed to spawn message handler thread: {s}", .{@errorName(err)}); continue; }; } else { self.handleEvent(data); } } } fn queueEvent(self: *Connection, event: *Event) std.mem.Allocator.Error!void { defer self.internal.events_queue_cond.signal(); self.internal.events_queue_lock.lock(); defer self.internal.events_queue_lock.unlock(); const node = try allocator.create(EventsQueue.Node); node.* = .{ .data = event.retain(), }; self.internal.events_queue.append(node); } /// This function takes ownership of `msg`. fn handleEvent(self: *Connection, msg: []const u8) void { defer allocator.free(msg); const meta, const header_ctx = moo.Metadata.parse(msg) catch |err| { std.log.err("Failed to parse message (possible memory corruption): {s}", .{@errorName(err)}); return; }; const header: moo.NoBodyHeaders, _ = moo.NoBodyHeaders.parse(msg, header_ctx) catch |err| { std.log.err("Invalid MOO message header: {s}", .{@errorName(err)}); return; }; if (self.internal.zone_subscription_request_id) |req_id| { if (header.request_id == req_id) { if (std.mem.eql(u8, meta.service, "Subscribed")) { const res = TransportService.SubscribeZoneChanges.initialResponse( allocator, header_ctx, msg, ) catch |err| { std.log.err( "Received unexpected zone subscription response: {s}", .{@errorName(err)}, ); return; }; defer res.deinit(); const event = Event.makeZoneListFromInitial(res.value) catch |make_err| { std.log.err("Unable to compose {s} event: {s}", .{ @tagName(Event.Kind.zone_list), @errorName(make_err), }); return; }; self.queueEvent(event) catch |err| { std.log.err("Failed to send {s} event: {s}", .{ @tagName(Event.Kind.zone_list), @errorName(err) }); }; return; } if (std.mem.eql(u8, meta.service, "Changed")) { const res = TransportService.SubscribeZoneChanges.response( allocator, header_ctx, msg, ) catch |err| { std.log.err( "Received unexpected zone change response: {s}", .{@errorName(err)}, ); return; }; defer res.deinit(); const event = Event.makeZoneListFromChanges(res.value) catch |err| { std.log.err("Unable to compose {s} event: {s}", .{ @tagName(Event.Kind.zone_list), @errorName(err), }); return; }; self.queueEvent(event) catch |err| { std.log.err("Failed to send {s} event: {s}", .{ @tagName(Event.Kind.zone_list), @errorName(err) }); }; return; } if (std.mem.eql(u8, meta.service, "Unsubscribed")) { self.internal.zone_subscription_request_id = null; // TODO: Queue unsubscribed event return; } std.log.warn("Unknown response received for zone subscription: {s}", .{ meta.service, }); return; } } if (self.internal.browse_listeners.get(header.request_id)) |listener| { defer self.internal.browse_listeners.unlock(); std.log.debug("Received /browse response (ID={d})", .{header.request_id}); const res = BrowseService.Browse.Response.parse( allocator, &meta, header_ctx, msg, ) catch |err| { std.log.err("Received unexpected browse response: {s}", .{@errorName(err)}); return; }; listener.write(res); return; } if (self.internal.load_listeners.get(header.request_id)) |listener| { defer self.internal.load_listeners.unlock(); std.log.debug("Received /load response (ID={d})", .{header.request_id}); const res = BrowseService.Load.Response.parse( allocator, &meta, header_ctx, msg, ) catch |err| { std.log.err("Received unexpected load response: {s}", .{@errorName(err)}); return; }; listener.write(res); return; } if (self.internal.control_events.get(header.request_id)) |listener| { defer self.internal.control_events.unlock(); std.log.debug("Received /control response (ID={d})", .{header.request_id}); listener.write( if (std.mem.eql(u8, "Success", meta.service)) .ok else .server_error, ); return; } if (self.internal.seek_events.get(header.request_id)) |listener| { defer self.internal.seek_events.unlock(); std.log.debug("Received /seek response (ID={d})", .{header.request_id}); listener.write(code: { _ = TransportService.Seek.Response.decode(&meta) catch { break :code .server_error; }; break :code .ok; }); return; } if (self.internal.change_volume_events.get(header.request_id)) |listener| { defer self.internal.change_volume_events.unlock(); std.log.debug("Received /change_volume response (ID={d})", .{header.request_id}); listener.write(code: { _ = TransportService.ChangeVolume.Response.decode(&meta) catch { break :code .server_error; }; break :code .ok; }); return; } std.log.warn("Unhandle message on {s}", .{meta.service}); std.log.debug("{s}", .{msg}); } /// Caller is responsible for closing message by calling `ws.done()`. fn readMessage(ws: *websocket.Client) !struct { moo.Metadata, moo.HeaderParsingContext, websocket.Message, } { while (true) { const msg = ws.read() catch |err| { switch (err) { error.Closed => return ReadError.ReadClosedConnection, else => { std.log.warn("Unable to read WebSocket message: {s}", .{@errorName(err)}); continue; }, } } orelse unreachable; switch (msg.type) { // NOTE: roon-node-api does not check whether message is binaryType. .text, .binary => { const meta, const header_ctx = moo.Metadata.parse(msg.data) catch |err| { std.log.warn("Failed to parse MOO metadata: {s}", .{@errorName(err)}); continue; }; if (std.mem.eql(u8, PingService.ping_id, meta.service)) { writePong(ws, header_ctx, msg.data) catch |err| { std.log.warn( "Failed to respond to ping request: {s}", .{@errorName(err)}, ); }; continue; } return .{ meta, header_ctx, msg }; }, .ping => ws.writePong(msg.data) catch |err| { std.log.warn("Failed to respond to ping: {s}", .{@errorName(err)}); }, .pong => {}, .close => return ReadError.ClosedByServer, } } } fn writePong( ws: *websocket.Client, header_ctx: moo.HeaderParsingContext, message: []const u8, ) !void { var buffer = std.ArrayList(u8).init(allocator); defer buffer.deinit(); try PingService.ping(buffer.writer(), header_ctx, message); try ws.writeBin(buffer.items); } pub fn subscribeZones(ptr: ?*Connection) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); var ws = self.internal.ws orelse { std.log.err("{s}_{s} called, but WebSocket connection is not ready", .{ cname, @src().fn_name }); return; }; const req_id = self.getRequestId(); const sub_id = self.internal.subscription_id; self.internal.subscription_id += 1; { std.log.debug("Subscribing to zone changes...", .{}); const req = TransportService.SubscribeZoneChanges.request( allocator, req_id, sub_id, ) catch |err| { std.log.err("Unable to compose zone subscription request: {s}", .{@errorName(err)}); return; }; defer allocator.free(req); ws.writeBin(req) catch |err| { std.log.err("Unable to write subscription request: {s}", .{@errorName(err)}); return; }; } self.internal.zone_subscription_request_id = req_id; } pub fn control( ptr: ?*Connection, zone_ptr: ?*transport.Zone, action: transport.Action, ) callconv(.C) transport.ControlResultCode { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); var zone = zone_ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); _ = zone.retain(); defer zone.release(); var ws = self.internal.ws orelse { std.log.err("{s}_{s} called, but WebSocket connection is not ready", .{ cname, @src().fn_name }); return .closed; }; const req_id = self.getRequestId(); std.log.debug("Sending control request... (ID={d})", .{req_id}); const kind = control: { if (action.next) { break :control TransportService.Control.next; } if (action.prev) { break :control TransportService.Control.previous; } if (action.pause) { break :control TransportService.Control.pause; } if (action.play) { break :control TransportService.Control.play; } const action_num: u16 = @bitCast(action); std.log.err( "action parameter has no effective bit turned on at {s}_{s}: {b}", .{ cname, @src().fn_name, action_num }, ); return .no_action_bit_set; }; const req = kind.request(allocator, req_id, std.mem.span(zone.id)) catch |err| { std.log.err("Unable to compose control request: {s}", .{@errorName(err)}); return .unknown_error; }; defer allocator.free(req); var listener = self.internal.control_events.add(req_id) catch |err| { std.log.err("Unable to set listener for control response: {s}", .{@errorName(err)}); return .unknown_error; }; defer self.internal.control_events.remove(req_id); ws.writeBin(req) catch |err| { std.log.err("Unable to write control request: {s}", .{@errorName(err)}); return .failed_to_send; }; return listener.listen() catch { return .timeout; }; } pub fn seek( ptr: ?*Connection, zone_ptr: ?*transport.Zone, seconds: i64, ) callconv(.C) transport.SeekResultCode { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); var zone = zone_ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); _ = zone.retain(); defer zone.release(); var ws = self.internal.ws orelse { std.log.err("{s}_{s} called, but WebSocket connection is not ready", .{ cname, @src().fn_name }); return .closed; }; const req_id = self.getRequestId(); std.log.debug("Sending seek request...", .{}); const req = TransportService.Seek.Request{ .zone_or_output_id = std.mem.span(zone.id), .how = .absolute, .seconds = seconds, }; const req_msg = req.encode(allocator, req_id) catch |err| { std.log.err("Unable to compose seek request: {s}", .{@errorName(err)}); return .unknown_error; }; defer allocator.free(req_msg); var listener = self.internal.seek_events.add(req_id) catch |err| { std.log.err("Unable to set listener for seek response: {s}", .{@errorName(err)}); return .unknown_error; }; defer self.internal.seek_events.remove(req_id); ws.writeBin(req_msg) catch |err| { std.log.err("Unable to write seek request: {s}", .{@errorName(err)}); return .failed_to_send; }; return listener.listen() catch { return .timeout; }; } pub fn increaseVolume( ptr: ?*Connection, output_ptr: ?*transport.Output, ) callconv(.C) transport.VolumeControlResultCode { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); var output = output_ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); _ = output.retain(); defer output.release(); var ws = self.internal.ws orelse { std.log.err("{s}_{s} called, but WebSocket connection is not ready", .{ cname, @src().fn_name }); return .closed; }; const req_id = self.getRequestId(); std.log.debug("Sending volume increase request...(ID={d})", .{req_id}); const req = TransportService.ChangeVolume.Request{ .output_id = std.mem.span(output.id), .how = .relative, .value = if (output.is_incremental_volume) 1.0 else output.volume.step, }; const req_msg = req.encode(allocator, req_id) catch |err| { std.log.err("Unable to compose volume increase request: {s}", .{@errorName(err)}); return .unknown_error; }; defer allocator.free(req_msg); var listener = self.internal.change_volume_events.add(req_id) catch |err| { std.log.err("Unable to set listener for volume increase response: {s}", .{@errorName(err)}); return .out_of_memory; }; defer self.internal.change_volume_events.remove(req_id); ws.writeBin(req_msg) catch |err| { std.log.err("Unable to write volume increase request: {s}", .{@errorName(err)}); return .failed_to_send; }; return listener.listen() catch { return .timeout; }; } pub fn decreaseVolume( ptr: ?*Connection, output_ptr: ?*transport.Output, ) callconv(.C) transport.VolumeControlResultCode { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); var output = output_ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); _ = output.retain(); defer output.release(); var ws = self.internal.ws orelse { std.log.err("{s}_{s} called, but WebSocket connection is not ready", .{ cname, @src().fn_name }); return .closed; }; const req_id = self.getRequestId(); std.log.debug("Sending volume decrease request...(ID={d})", .{req_id}); const req = TransportService.ChangeVolume.Request{ .output_id = std.mem.span(output.id), .how = .relative, .value = if (output.is_incremental_volume) -1.0 else -output.volume.step, }; const req_msg = req.encode(allocator, req_id) catch |err| { std.log.err("Unable to compose volume decrease request: {s}", .{@errorName(err)}); return .unknown_error; }; defer allocator.free(req_msg); var listener = self.internal.change_volume_events.add(req_id) catch |err| { std.log.err("Unable to set listener for volume decrease response: {s}", .{@errorName(err)}); return .out_of_memory; }; defer self.internal.change_volume_events.remove(req_id); ws.writeBin(req_msg) catch |err| { std.log.err("Unable to write volume decrease request: {s}", .{@errorName(err)}); return .failed_to_send; }; return listener.listen() catch { return .timeout; }; } pub fn changeVolume( ptr: ?*Connection, output_ptr: ?*transport.Output, abs_value: f64, ) callconv(.C) transport.VolumeControlResultCode { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); var output = output_ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); _ = output.retain(); defer output.release(); var ws = self.internal.ws orelse { std.log.err("{s}_{s} called, but WebSocket connection is not ready", .{ cname, @src().fn_name }); return .closed; }; const req_id = self.getRequestId(); std.log.debug("Sending volume change request...(ID={d})", .{req_id}); const req = TransportService.ChangeVolume.Request{ .output_id = std.mem.span(output.id), .how = .absolute, .value = abs_value, }; const req_msg = req.encode(allocator, req_id) catch |err| { std.log.err("Unable to compose volume change request: {s}", .{@errorName(err)}); return .unknown_error; }; defer allocator.free(req_msg); var listener = self.internal.change_volume_events.add(req_id) catch |err| { std.log.err("Unable to set listener for volume change response: {s}", .{@errorName(err)}); return .out_of_memory; }; defer self.internal.change_volume_events.remove(req_id); ws.writeBin(req_msg) catch |err| { std.log.err("Unable to write volume change request: {s}", .{@errorName(err)}); return .failed_to_send; }; return listener.listen() catch { return .timeout; }; } pub fn requestBrowse( ptr: ?*Connection, hierarchy: browse.Hierarchy, zone: ?*transport.Zone, item: ?*browse.Item, pop: bool, ) callconv(.C) ?*browse.Result { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (zone) |z| _ = z.retain(); defer if (zone) |z| z.release(); if (item) |i| _ = i.retain(); defer if (item) |i| i.release(); var ws = self.internal.ws orelse { std.log.err("{s}_{s} called, but WebSocket connection is not ready", .{ cname, @src().fn_name }); return browse.Result.makeRetainedError(.closed) catch null; }; const req_id = self.getRequestId(); std.log.debug("Sending browse request...", .{}); const req = BrowseService.Browse.Request{ .hierarchy = @tagName(hierarchy), .zone_or_output_id = if (zone) |z| std.mem.span(z.id) else null, .item_key = if (item) |i| if (i.item_key) |key| std.mem.span(key) else null else null, .pop_all = item == null and !pop, .pop_levels = if (pop) 1 else null, }; const req_msg = req.encode(allocator, req_id) catch |err| { std.log.err("Unable to compose browse request: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.unknown_error) catch null; }; defer allocator.free(req_msg); var browse_listener = self.internal.browse_listeners.add(req_id) catch |err| { std.log.err("Unable to set listener for browse response: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.unknown_error) catch null; }; defer self.internal.browse_listeners.remove(req_id); ws.writeBin(req_msg) catch |err| { std.log.err("Unable to write browse request: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.failed_to_send) catch null; }; std.log.debug("Sent browse request (ID={d})", .{req_id}); const resp = browse_listener.listen() catch { std.log.err("Browse request timeout", .{}); return browse.Result.makeRetainedError(.timeout) catch null; }; defer resp.deinit(); switch (resp.value.action) { .message => { const message = resp.value.message orelse { std.log.err("Got `message` action, but `message` property is missing", .{}); return browse.Result.makeRetainedError(.missing_property) catch null; }; if (resp.value.is_error orelse false) { const action = browse.ErrorMessageAction.make(message) catch { return browse.Result.makeRetainedError(.out_of_memory) catch null; }; return browse.Result.makeRetained(.{ .error_message = action }) catch null; } else { const action = browse.MessageAction.make(message) catch { return browse.Result.makeRetainedError(.out_of_memory) catch null; }; return browse.Result.makeRetained(.{ .message = action }) catch null; } }, .replace_item => { const replace_item = resp.value.item orelse { std.log.err("Got `replace_item` action, but `item` property is missing", .{}); return browse.Result.makeRetainedError(.missing_property) catch null; }; const action = browse.ReplaceItemAction.make(&replace_item) catch { return browse.Result.makeRetainedError(.out_of_memory) catch null; }; return browse.Result.makeRetained(.{ .replace_item = action }) catch null; }, .remove_item => { return browse.Result.makeRetained(.remove_item) catch null; }, .list => { const list = resp.value.list orelse { std.log.err("Got `list` action, but `list` property is missing", .{}); return browse.Result.makeRetainedError(.missing_property) catch null; }; const load_req = BrowseService.Load.Request{ .hierarchy = @tagName(hierarchy), .count = std.math.maxInt(u16), .level = list.level, }; const load_req_id = self.internal.request_id; self.internal.request_id += 1; const load_req_msg = load_req.encode(allocator, load_req_id) catch |err| { std.log.err("Unable to compose load request: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.unknown_error) catch null; }; defer allocator.free(load_req_msg); var load_listener = self.internal.load_listeners.add(load_req_id) catch |err| { std.log.err("Unable to set listener for load response: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.unknown_error) catch null; }; defer self.internal.load_listeners.remove(load_req_id); ws.writeBin(load_req_msg) catch |err| { std.log.err("Unable to write load request: {s}", .{@errorName(err)}); return browse.Result.makeRetainedError(.failed_to_send) catch null; }; std.log.debug("Sent load request (ID={d})", .{load_req_id}); const load_resp = load_listener.listen() catch { std.log.err("Load request timeout", .{}); return browse.Result.makeRetainedError(.timeout) catch null; }; defer load_resp.deinit(); const action = browse.ListAction.make(load_resp.value) catch { return browse.Result.makeRetainedError(.out_of_memory) catch null; }; return browse.Result.makeRetained(.{ .list = action }) catch null; }, .none => { return browse.Result.makeRetained(.none) catch null; }, } } pub fn getImageUrl( ptr: ?*Connection, image_key_ptr: [*:0]const u8, opts_ptr: ?*image.GetOptions, ) callconv(.C) ?[*:0]u8 { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); const image_key = std.mem.span(image_key_ptr); const req = req: { const opts = image.GetOptions.retain(opts_ptr); defer opts.release(); var r = ImageService.Get.Request{ .image_key = image_key, .format = if (opts.internal.data.content_type) |t| switch (t) { .jpeg => .jpeg, .png => .png, } else null, }; if (opts.internal.data.size) |size| { r.scale = switch (size.scaling_method) { .fit => .fit, .fill => .fill, .stretch => .stretch, }; r.width = size.width; r.height = size.height; } break :req r; }; const slice = req.url( [:0]u8, self.internal.tsa.allocator(), self.internal.server.internal.address, ) catch return null; return slice.ptr; } pub fn export_capi() void { @export(&make, .{ .name = std.fmt.comptimePrint("{s}_make", .{cname}) }); @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); @export(&getEvent, .{ .name = std.fmt.comptimePrint("{s}_get_event", .{cname}) }); @export(&subscribeZones, .{ .name = std.fmt.comptimePrint("{s}_subscribe_zones", .{cname}) }); @export(&control, .{ .name = std.fmt.comptimePrint("{s}_control", .{cname}) }); @export(&seek, .{ .name = std.fmt.comptimePrint("{s}_seek", .{cname}) }); @export(&increaseVolume, .{ .name = std.fmt.comptimePrint("{s}_increase_volume", .{cname}) }); @export(&decreaseVolume, .{ .name = std.fmt.comptimePrint("{s}_decrease_volume", .{cname}) }); @export(&changeVolume, .{ .name = std.fmt.comptimePrint("{s}_change_volume", .{cname}) }); @export(&requestBrowse, .{ .name = std.fmt.comptimePrint("{s}_browse", .{cname}) }); @export(&getImageUrl, .{ .name = std.fmt.comptimePrint("{s}_get_image_url", .{cname}) }); @export(&disconnect, .{ .name = std.fmt.comptimePrint("{s}_disconnect", .{cname}) }); } }; pub fn export_capi() void { ConnectionErrorEvent.export_capi(); ConnectedEvent.export_capi(); Event.export_capi(); Connection.export_capi(); }
-
-
src/core/discovery.zig (deleted)
-
@@ -1,187 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); const sood = @import("sood"); const websocket = @import("websocket"); const Arc = @import("./Arc.zig"); const freelog = @import("./log.zig").freelog; const RegistryService = @import("./services/registry.zig").RegistryService; pub const Server = extern struct { const cname = "plac_discovery_server"; const allocator = std.heap.c_allocator; internal: *Internal, id: [*:0]const u8, name: [*:0]const u8, version: [*:0]const u8, ip_addr: [*:0]const u8, http_port: u16, pub const Internal = struct { address: std.net.Address, arc: Arc = .{}, }; pub fn make(resp: *const sood.discovery.Response, addr: *const std.net.Address) !*Server { const self = try allocator.create(Server); errdefer allocator.destroy(self); const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); const id = try allocator.dupeZ(u8, resp.unique_id); errdefer allocator.free(id); const name = try allocator.dupeZ(u8, resp.name); errdefer allocator.free(name); const version = try allocator.dupeZ(u8, resp.display_version); errdefer allocator.free(version); var ip_addr_buf = std.ArrayList(u8).init(allocator); defer ip_addr_buf.deinit(); try std.fmt.format(ip_addr_buf.writer(), "{}", .{addr}); if (std.mem.lastIndexOfScalar(u8, ip_addr_buf.items, ':')) |last_colon| { ip_addr_buf.shrinkAndFree(last_colon); } const ip_addr = try allocator.dupeZ(u8, ip_addr_buf.items); errdefer allocator.free(ip_addr); var address = addr.*; address.setPort(resp.http_port); internal.* = .{ .address = address, }; self.* = .{ .internal = internal, .id = id.ptr, .name = name.ptr, .version = version.ptr, .ip_addr = ip_addr.ptr, .http_port = resp.http_port, }; return self; } fn init( id: [*:0]const u8, name: [*:0]const u8, version: [*:0]const u8, ip_addr: [*:0]const u8, http_port: u16, ) !*Server { const self = try allocator.create(Server); errdefer allocator.destroy(self); const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); const id_copy = try allocator.dupeZ(u8, std.mem.span(id)); errdefer allocator.free(id_copy); const name_copy = try allocator.dupeZ(u8, std.mem.span(name)); errdefer allocator.free(name_copy); const version_copy = try allocator.dupeZ(u8, std.mem.span(version)); errdefer allocator.free(version_copy); const ip_addr_slice = std.mem.span(ip_addr); const ip_addr_copy = try allocator.dupeZ(u8, ip_addr_slice); errdefer allocator.free(ip_addr_copy); const colon = std.mem.indexOfScalar(u8, ip_addr_slice, ':') orelse { return error.UnexpectedIpFormat; }; const internal_addr = try std.net.Address.parseIp4(ip_addr_slice[0..colon], http_port); internal.* = .{ .address = internal_addr, }; self.* = .{ .internal = internal, .id = id_copy.ptr, .name = name_copy.ptr, .version = version_copy.ptr, .ip_addr = ip_addr_copy.ptr, .http_port = http_port, }; return self; } pub fn new( id: [*:0]const u8, name: [*:0]const u8, version: [*:0]const u8, ip_addr: [*:0]const u8, http_port: u16, ) callconv(.C) ?*Server { return init(id, name, version, ip_addr, http_port) catch |err| { std.log.err("Unable to construct Discovery.Server: {s}", .{@errorName(err)}); return null; }; } pub fn retain(ptr: ?*Server) callconv(.C) *Server { var self = ptr orelse @panic(std.fmt.comptimePrint( "Received null pointer on {s}_{s}", .{ cname, @src().fn_name }, )); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*Server) callconv(.C) void { var self = ptr orelse @panic(std.fmt.comptimePrint( "Received null pointer on {s}_{s}", .{ cname, @src().fn_name }, )); if (self.internal.arc.unref()) { freelog(self); allocator.free(std.mem.span(self.id)); allocator.free(std.mem.span(self.name)); allocator.free(std.mem.span(self.version)); allocator.free(std.mem.span(self.ip_addr)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); @export(&new, .{ .name = std.fmt.comptimePrint("{s}_new", .{cname}) }); } }; pub fn export_capi() void { Server.export_capi(); }
-
-
src/core/extension.zig (deleted)
-
@@ -1,34 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const config = @import("config"); const Extension = @import("./services/registry.zig").Extension; const BrowseService = @import("./services/BrowseService.zig"); const ImageService = @import("./services/ImageService.zig"); const PingService = @import("./services/PingService.zig"); const TransportService = @import("./services/TransportService.zig"); pub const extension = Extension{ .id = config.extension.id, .display_name = config.extension.name, .version = config.extension.version, .publisher = "Shota FUJI", .email = "pockawoooh@gmail.com", .required_services = &.{ TransportService.id, BrowseService.id, ImageService.id }, .optional_services = &.{}, .provided_services = &.{PingService.id}, };
-
-
src/core/image.zig (deleted)
-
@@ -1,132 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const Arc = @import("./Arc.zig"); const Server = @import("./discovery.zig").Server; const freelog = @import("./log.zig").freelog; const ImageService = @import("./services/ImageService.zig"); pub const ScalingMethod = enum(c_int) { fit = 0, fill = 1, stretch = 2, }; pub const ContentType = enum(c_int) { jpeg = 0, png = 1, }; pub const GetOptions = extern struct { const cname = "plac_image_get_options"; const allocator = std.heap.c_allocator; internal: *Internal, const Internal = struct { arc: Arc = .{}, data: Data = .{}, const Data = struct { size: ?Size = null, content_type: ?ContentType = null, const Size = struct { scaling_method: ScalingMethod, width: usize, height: usize, }; }; }; pub fn makeRetained() callconv(.C) ?*@This() { const internal = allocator.create(Internal) catch { return null; }; internal.* = .{}; const self = allocator.create(@This()) catch { allocator.destroy(internal); return null; }; self.* = .{ .internal = internal }; return self.retain(); } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn setSize( ptr: ?*@This(), scaling: ScalingMethod, width: usize, height: usize, ) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); _ = self.retain(); defer self.release(); self.internal.data.size = .{ .scaling_method = scaling, .width = width, .height = height, }; } pub fn setContentType(ptr: ?*@This(), content_type: ContentType) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); _ = self.retain(); defer self.release(); self.internal.data.content_type = content_type; } pub fn export_capi() void { @export(&makeRetained, .{ .name = std.fmt.comptimePrint("{s}_make", .{cname}) }); @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); @export(&setSize, .{ .name = std.fmt.comptimePrint("{s}_set_size", .{cname}) }); @export(&setContentType, .{ .name = std.fmt.comptimePrint("{s}_set_content_type", .{cname}) }); } }; pub fn export_capi() void { GetOptions.export_capi(); }
-
-
-
@@ -14,13 +14,16 @@ // limitations under the License.// // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); namespace Plac { namespace V2 { public class Response : Object { public Moo.Metadata meta { get; construct; } public Moo.Headers headers { get; construct; } public string message { get; construct; } const config = @import("config"); /// Logs memory release event, if `freelog` option is active. pub fn freelog(ptr: anytype) void { if (config.freelog) { std.log.debug("Releasing {*}...", .{ptr}); } public Response(Moo.Metadata meta, Moo.Headers headers, string message) { Object(meta: meta, headers: headers, message: message); } } } }
-
-
src/core/main.zig (deleted)
-
@@ -1,72 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); pub const browse = @import("./browse.zig"); pub const connection = @import("./connection.zig"); pub const discovery = @import("./discovery.zig"); pub const image = @import("./image.zig"); pub const transport = @import("./transport.zig"); const glib = @cImport({ @cInclude("glib.h"); }); pub const std_options: std.Options = .{ .log_level = .debug, .logFn = log, }; pub fn log( comptime level: std.log.Level, comptime _: @Type(.enum_literal), comptime format: []const u8, args: anytype, ) void { var arena = std.heap.ArenaAllocator.init(std.heap.c_allocator); defer arena.deinit(); const allocator = arena.allocator(); var buffer = std.ArrayList(u8).init(allocator); std.fmt.format(buffer.writer(), format, args) catch return; const message = buffer.toOwnedSliceSentinel(0) catch return; const g_level: glib.GLogLevelFlags = switch (level) { .debug => glib.G_LOG_LEVEL_DEBUG, .info => glib.G_LOG_LEVEL_INFO, .warn => glib.G_LOG_LEVEL_WARNING, .err => glib.G_LOG_LEVEL_CRITICAL, }; glib.g_log("Plac", g_level, message.ptr); } comptime { browse.export_capi(); connection.export_capi(); discovery.export_capi(); image.export_capi(); transport.export_capi(); } test { _ = @import("./browse.zig"); _ = @import("./connection.zig"); _ = @import("./image.zig"); _ = @import("./services/ImageService.zig"); }
-
-
src/core/plac.h (deleted)
-
@@ -1,394 +0,0 @@/* * Copyright 2025 Shota FUJI * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * SPDX-License-Identifier: Apache-2.0 * * === * * C99 header file for helper C API for Plac core library. * This file is not checked against a generated library file: carefully write and review * definitions and implementations. */ #ifndef PLAC_CORE_H #define PLAC_CORE_H #include <stdbool.h> #include <stddef.h> #include <stdint.h> // discovery.Server typedef struct { void *__pri; char *id; char *name; char *version; char *ip_addr; uint16_t http_port; } plac_discovery_server; plac_discovery_server *plac_discovery_server_retain(plac_discovery_server*); void plac_discovery_server_release(plac_discovery_server*); plac_discovery_server *plac_discovery_server_new( const char *id, const char *name, const char *version, const char *ip_addr, uint16_t http_port ); // transport.NowPlaying typedef struct { void *__pri; const char *one_line_line1; const char *two_line_line1; const char *two_line_line2; const char *three_line_line1; const char *three_line_line2; const char *three_line_line3; uint64_t seek_position; uint64_t length; bool has_seek_position; bool has_length; const char *image_key; } plac_transport_now_playing; plac_transport_now_playing *plac_transport_now_playing_retain(plac_transport_now_playing*); void plac_transport_now_playing_release(plac_transport_now_playing*); // transport.SeekChange typedef struct { void *__pri; const char *zone_id; uint64_t seek_position; bool has_seek_position; } plac_transport_seek_change; plac_transport_seek_change *plac_transport_seek_change_retain(plac_transport_seek_change*); void plac_transport_seek_change_release(plac_transport_seek_change*); // transport.OutputVolumeUnit typedef enum { PLAC_TRANSPORT_OUTPUT_VOLUME_UNKNOWN = 0, PLAC_TRANSPORT_OUTPUT_VOLUME_NUMBER = 1, PLAC_TRANSPORT_OUTPUT_VOLUME_DECIBEL = 2, } plac_transport_output_volume_unit; // transport.OutputVolume typedef struct { plac_transport_output_volume_unit unit; double min; double max; double value; double step; bool muted; } plac_transport_output_volume; // transport.Output typedef struct { void *__pri; const char *id; const char *display_name; bool is_incremental_volume; // This field is useless if "is_incremental_volume" is true plac_transport_output_volume volume; } plac_transport_output; plac_transport_output *plac_transport_output_retain(plac_transport_output*); void plac_transport_output_release(plac_transport_output*); // transport.PlaybackState typedef enum { PLAC_TRANSPORT_PLAYBACK_LOADING = 0, PLAC_TRANSPORT_PLAYBACK_STOPPED = 1, PLAC_TRANSPORT_PLAYBACK_PAUSED = 2, PLAC_TRANSPORT_PLAYBACK_PLAYING = 3, } plac_transport_playback_state; // transport.Action #define PLAC_TRANSPORT_ACTION_NEXT (1 << 0) #define PLAC_TRANSPORT_ACTION_PREV (1 << 1) #define PLAC_TRANSPORT_ACTION_PAUSE (1 << 2) #define PLAC_TRANSPORT_ACTION_PLAY (1 << 3) #define PLAC_TRANSPORT_ACTION_SEEK (1 << 4) // transport.Zone typedef struct { void *__pri; char *id; char *name; plac_transport_output **outputs; size_t outputs_len; plac_transport_now_playing *now_playing; plac_transport_playback_state playback; uint16_t allowed_action; } plac_transport_zone; plac_transport_zone *plac_transport_zone_retain(plac_transport_zone*); void plac_transport_zone_release(plac_transport_zone*); // transport.ZoneListEvent typedef struct { void *__pri; plac_transport_zone **added_zones_ptr; size_t added_zones_len; plac_transport_zone **changed_zones_ptr; size_t changed_zones_len; char **removed_zone_ids_ptr; size_t removed_zone_ids_len; plac_transport_seek_change **seek_changes_ptr; size_t seek_changes_len; } plac_transport_zone_list_event; plac_transport_zone_list_event *plac_transport_zone_list_event_retain(plac_transport_zone_list_event*); void plac_transport_zone_list_event_release(plac_transport_zone_list_event*); // transport.ControlResultCode typedef enum { PLAC_TRANSPORT_CONTROL_RESULT_OK = 0, PLAC_TRANSPORT_CONTROL_RESULT_UNKNOWN_ERROR = 1, PLAC_TRANSPORT_CONTROL_RESULT_OUT_OF_MEMORY = 2, PLAC_TRANSPORT_CONTROL_RESULT_FAILED_TO_SEND = 3, PLAC_TRANSPORT_CONTROL_RESULT_CLOSED = 4, PLAC_TRANSPORT_CONTROL_RESULT_NO_ACTION_BIT_SET = 5, PLAC_TRANSPORT_CONTROL_RESULT_SERVER_ERROR = 6, PLAC_TRANSPORT_CONTROL_RESULT_TIMEOUT = 7, } plac_transport_control_result_code; // transport.SeekResultCode typedef enum { PLAC_TRANSPORT_SEEK_RESULT_OK = 0, PLAC_TRANSPORT_SEEK_RESULT_UNKNOWN_ERROR = 1, PLAC_TRANSPORT_SEEK_RESULT_OUT_OF_MEMORY = 2, PLAC_TRANSPORT_SEEK_RESULT_FAILED_TO_SEND = 3, PLAC_TRANSPORT_SEEK_RESULT_CLOSED = 4, PLAC_TRANSPORT_SEEK_RESULT_SERVER_ERROR = 5, PLAC_TRANSPORT_SEEK_RESULT_TIMEOUT = 6, } plac_transport_seek_result_code; // transport.VolumeControlResultCode typedef enum { PLAC_TRANSPORT_VOLUME_CONTROL_RESULT_OK = 0, PLAC_TRANSPORT_VOLUME_CONTROL_RESULT_UNKNOWN_ERROR = 1, PLAC_TRANSPORT_VOLUME_CONTROL_RESULT_OUT_OF_MEMORY = 2, PLAC_TRANSPORT_VOLUME_CONTROL_RESULT_FAILED_TO_SEND = 3, PLAC_TRANSPORT_VOLUME_CONTROL_RESULT_CLOSED = 4, PLAC_TRANSPORT_VOLUME_CONTROL_RESULT_SERVER_ERROR = 5, PLAC_TRANSPORT_VOLUME_CONTROL_RESULT_TIMEOUT = 6, PLAC_TRANSPORT_VOLUME_CONTROL_RESULT_ILLEGAL_METHOD = 7, } plac_transport_volume_control_result_code; // browse.Hierarchy typedef enum { PLAC_BROWSE_HIERARCHY_BROWSE = 0, PLAC_BROWSE_HIERARCHY_PLAYLISTS = 1, PLAC_BROWSE_HIERARCHY_SETTINGS = 2, PLAC_BROWSE_HIERARCHY_INTERNET_RADIO = 3, PLAC_BROWSE_HIERARCHY_ALBUMS = 4, PLAC_BROWSE_HIERARCHY_ARTISTS = 5, PLAC_BROWSE_HIERARCHY_GENRES = 6, PLAC_BROWSE_HIERARCHY_COMPOSERS = 7, PLAC_BROWSE_HIERARCHY_SEARCH = 8, } plac_browse_hierarchy; // browse.ItemHint typedef enum { PLAC_BROWSE_ITEM_HINT_UNKNOWN = 0, PLAC_BROWSE_ITEM_HINT_ACTION = 1, PLAC_BROWSE_ITEM_HINT_ACTION_LIST = 2, PLAC_BROWSE_ITEM_HINT_LIST = 3, PLAC_BROWSE_ITEM_HINT_HEADER = 4, } plac_browse_item_hint; // browse.InputPrompt typedef struct { void *__pri; const char* prompt; const char* action; const char* default_value; bool is_password; } plac_browse_input_prompt; plac_browse_input_prompt *plac_browse_input_prompt_retain(plac_browse_input_prompt*); void plac_browse_input_prompt_release(plac_browse_input_prompt*); // browse.Item typedef struct { void *__pri; const char *title; const char *subtitle; const char *image_key; const char *item_key; plac_browse_item_hint hint; plac_browse_input_prompt *prompt; } plac_browse_item; plac_browse_item *plac_browse_item_retain(plac_browse_item*); void plac_browse_item_release(plac_browse_item*); // browse.ResultCode typedef enum { PLAC_BROWSE_RESULT_OK = 0, PLAC_BROWSE_RESULT_UNKNOWN_ERROR = 1, PLAC_BROWSE_RESULT_MISSING_PROPERTY = 2, PLAC_BROWSE_RESULT_OUT_OF_MEMORY = 3, PLAC_BROWSE_RESULT_FAILED_TO_SEND = 4, PLAC_BROWSE_RESULT_CLOSED = 5, PLAC_BROWSE_RESULT_TIMEOUT = 6, } plac_browse_result_code; // browse.ResultAction typedef enum { PLAC_BROWSE_RESULT_ACTION_NONE = 0, PLAC_BROWSE_RESULT_ACTION_REPLACE_ITEM = 1, PLAC_BROWSE_RESULT_ACTION_REMOVE_ITEM = 2, PLAC_BROWSE_RESULT_ACTION_LIST = 3, PLAC_BROWSE_RESULT_ACTION_ERROR_MESSAGE = 4, PLAC_BROWSE_RESULT_ACTION_MESSAGE = 5, } plac_browse_result_action; // browse.ReplaceItemAction typedef struct { void *__pri; plac_browse_item *item; } plac_browse_replace_item_action; plac_browse_replace_item_action *plac_browse_replace_item_action_retain(plac_browse_replace_item_action*); void plac_browse_replace_item_action_release(plac_browse_replace_item_action*); // browse.ListAction typedef struct { void *__pri; const char *title; const char *subtitle; const char *image_key; uint64_t level; plac_browse_item **items_ptr; size_t items_len; } plac_browse_list_action; plac_browse_list_action *plac_browse_list_action_retain(plac_browse_list_action*); void plac_browse_list_action_release(plac_browse_list_action*); // browse.ErrorMessageAction typedef struct { void *__pri; const char *message; } plac_browse_error_message_action; plac_browse_error_message_action *plac_browse_error_message_action_retain(plac_browse_error_message_action*); void plac_browse_error_message_action_release(plac_browse_error_message_action*); // browse.MessageAction typedef struct { void *__pri; const char *message; } plac_browse_message_action; plac_browse_message_action *plac_browse_message_action_retain(plac_browse_message_action*); void plac_browse_message_action_release(plac_browse_message_action*); // browse.Result typedef struct { void *__pri; plac_browse_result_code code; plac_browse_result_action action; } plac_browse_result; plac_browse_result *plac_browse_result_retain(plac_browse_result*); void plac_browse_result_release(plac_browse_result*); plac_browse_replace_item_action *plac_browse_result_get_replace_item_action(plac_browse_result*); plac_browse_list_action *plac_browse_result_get_list_action(plac_browse_result*); plac_browse_error_message_action *plac_browse_result_get_error_message_action(plac_browse_result*); plac_browse_message_action *plac_browse_result_get_message_action(plac_browse_result*); // browse.Label typedef struct { void *__pri; const char* plain_text; } plac_browse_label; plac_browse_label *plac_browse_label_from_string(const char*); plac_browse_label *plac_browse_label_retain(plac_browse_label*); void plac_browse_label_release(plac_browse_label*); // image.ScalingMethod typedef enum { PLAC_IMAGE_SCALING_METHOD_FIT = 0, PLAC_IMAGE_SCALING_METHOD_FILL = 1, PLAC_IMAGE_SCALING_METHOD_STRETCH = 2, } plac_image_scaling_method; // image.ContentType typedef enum { PLAC_IMAGE_CONTENT_TYPE_JPEG = 0, PLAC_IMAGE_CONTENT_TYPE_PNG = 1, } plac_image_content_type; // image.GetOptions typedef struct { void *__pri; } plac_image_get_options; plac_image_get_options *plac_image_get_options_make(); plac_image_get_options *plac_image_get_options_retain(plac_image_get_options*); void *plac_image_get_options_release(plac_image_get_options*); void *plac_image_get_options_set_size(plac_image_get_options*, plac_image_scaling_method, size_t width, size_t height); void *plac_image_get_options_set_content_type(plac_image_get_options*, plac_image_content_type); // connection.ConnectedEvent typedef struct { void *__pri; char *token; } plac_connection_connected_event; plac_connection_connected_event *plac_connection_connected_event_retain(plac_connection_connected_event*); void plac_connection_connected_event_release(plac_connection_connected_event*); // connection.ConnectionError typedef enum { PLAC_CONNECTION_ERROR_UNKNOWN = 0, PLAC_CONNECTION_ERROR_CLOSED_BY_SERVER = 1, PLAC_CONNECTION_ERROR_OUT_OF_MEMORY = 2, PLAC_CONNECTION_ERROR_UNEXPECTED_RESPONSE = 3, PLAC_CONNECTION_ERROR_NETWORK_UNAVAILABLE = 4, PLAC_CONNECTION_ERROR_NETWORK_ERROR = 5, } plac_connection_connection_error; // connection.ConnectionErrorEvent typedef struct { void *__pri; plac_connection_connection_error code; } plac_connection_connection_error_event; plac_connection_connection_error_event *plac_connection_connection_error_event_retain(plac_connection_connection_error_event*); void plac_connection_connection_error_event_release(plac_connection_connection_error_event*); // connection.Event.Kind typedef enum { PLAC_CONNECTION_EVENT_ERROR = 0, PLAC_CONNECTION_EVENT_CONNECTED = 1, PLAC_CONNECTION_EVENT_ZONE_LIST = 10, } plac_connection_event_kind; // connection.Event typedef struct { void *__pri; plac_connection_event_kind kind; } plac_connection_event; plac_connection_event *plac_connection_event_retain(plac_connection_event*); void plac_connection_event_release(plac_connection_event*); plac_connection_connection_error_event *plac_connection_event_get_connection_error_event(plac_connection_event*); plac_connection_connected_event *plac_connection_event_get_connected_event(plac_connection_event*); plac_transport_zone_list_event *plac_connection_event_get_zone_list_event(plac_connection_event*); // connection.Connection typedef struct { void *__pri; } plac_connection; plac_connection *plac_connection_make(plac_discovery_server*, const char *token); plac_connection *plac_connection_retain(plac_connection*); void plac_connection_release(plac_connection*); void plac_connection_disconnect(plac_connection*); plac_connection_event *plac_connection_get_event(plac_connection*); void plac_connection_subscribe_zones(plac_connection*); plac_transport_control_result_code plac_connection_control(plac_connection*, plac_transport_zone*, uint16_t action); plac_transport_seek_result_code plac_connection_seek(plac_connection*, plac_transport_zone*, int64_t seconds); plac_transport_volume_control_result_code plac_connection_change_volume(plac_connection*, plac_transport_output*, double abs_value); plac_transport_volume_control_result_code plac_connection_increase_volume(plac_connection*, plac_transport_output*); plac_transport_volume_control_result_code plac_connection_decrease_volume(plac_connection*, plac_transport_output*); plac_browse_result *plac_connection_browse(plac_connection*, plac_browse_hierarchy, plac_transport_zone*, plac_browse_item*, bool pop); const char *plac_connection_get_image_url(plac_connection*, const char *image_key, plac_image_get_options*); #endif
-
-
src/core/plac.vapi (deleted)
-
@@ -1,586 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 [CCode (cheader_filename = "plac.h")] namespace Plac { namespace Discovery { [CCode ( cname = "plac_discovery_server", ref_function = "plac_discovery_server_retain", unref_function = "plac_discovery_server_release" )] [Compact] public class Server { [CCode (cname = "plac_discovery_server_retain")] public void @ref (); [CCode (cname = "plac_discovery_server_release")] public void unref (); public Server(string id, string name, string version, string ip_addr, uint16 http_port); public string id; public string name; public string version; public string ip_addr; public uint16 http_port; } } namespace Transport { [CCode ( cname = "plac_transport_now_playing", ref_function = "plac_transport_now_playing_retain", unref_function = "plac_transport_now_playing_release" )] [Compact] public class NowPlaying { public string one_line_line1; public string two_line_line1; public string? two_line_line2; public string three_line_line1; public string? three_line_line2; public string? three_line_line3; public uint64 seek_position; public uint64 length; public bool has_seek_position; public bool has_length; public string? image_key; } [CCode ( cname = "plac_transport_seek_change", ref_function = "plac_transport_seek_change_retain", unref_function = "plac_transport_seek_change_release" )] [Compact] public class SeekChange { public string zone_id; public uint64 seek_position; public bool has_seek_position; } [CCode ( cname = "plac_transport_output_volume_unit", cprefix = "PLAC_TRANSPORT_OUTPUT_VOLUME_", has_type_id = false )] public enum OutputVolumeUnit { UNKNOWN = 0, NUMBER = 1, DECIBEL = 2, } [CCode (cname = "plac_transport_output_volume", has_type_id = false)] public struct OutputVolume { public OutputVolumeUnit unit; public double min; public double max; public double value; public double step; public bool muted; } [CCode ( cname = "plac_transport_output", ref_function = "plac_transport_output_retain", unref_function = "plac_transport_output_release" )] [Compact] public class Output { public string id; public string display_name; public bool is_incremental_volume; public OutputVolume volume; } [CCode ( cname = "plac_transport_playback_state", cprefix = "PLAC_TRANSPORT_PLAYBACK_", has_type_id = false )] public enum PlaybackState { LOADING = 0, STOPPED = 1, PAUSED = 2, PLAYING = 3, } [CCode ( cname = "plac_transport_zone", ref_function = "plac_transport_zone_retain", unref_function = "plac_transport_zone_release" )] [Compact] public class Zone { public string id; public string name; [CCode ( cname = "outputs", array_length_cname = "outputs_len", array_length_type = "size_t" )] public Output[] outputs; public PlaybackState playback; public uint16 allowed_action; public NowPlaying? now_playing; [CCode (cname = "plac_transport_zone_retain")] public void @ref (); [CCode (cname = "plac_transport_zone_release")] public void unref (); } [CCode ( cname = "plac_transport_zone_list_event", ref_function = "plac_transport_zone_list_event_retain", unref_function = "plac_transport_zone_list_event_release" )] [Compact] public class ZoneListEvent { [CCode (cname = "plac_transport_zone_list_event_retain")] public void @ref (); [CCode (cname = "plac_transport_zone_list_event_release")] public void unref (); [CCode ( cname = "added_zones_ptr", array_length_cname = "added_zones_len", array_length_type = "size_t" )] public Zone[] added; [CCode ( cname = "changed_zones_ptr", array_length_cname = "changed_zones_len", array_length_type = "size_t" )] public Zone[] changed; [CCode ( cname = "removed_zone_ids_ptr", array_length_cname = "removed_zone_ids_len", array_length_type = "size_t" )] public string[] removed; [CCode ( cname = "seek_changes_ptr", array_length_cname = "seek_changes_len", array_length_type = "size_t" )] public SeekChange[] seek_changed; } [CCode (cname = "PLAC_TRANSPORT_ACTION_NEXT")] public const uint16 ACTION_NEXT; [CCode (cname = "PLAC_TRANSPORT_ACTION_PREV")] public const uint16 ACTION_PREV; [CCode (cname = "PLAC_TRANSPORT_ACTION_PAUSE")] public const uint16 ACTION_PAUSE; [CCode (cname = "PLAC_TRANSPORT_ACTION_PLAY")] public const uint16 ACTION_PLAY; [CCode (cname = "PLAC_TRANSPORT_ACTION_SEEK")] public const uint16 ACTION_SEEK; [CCode ( cname = "plac_transport_control_result_code", cprefix = "PLAC_TRANSPORT_CONTROL_RESULT_", has_type_id = false )] public enum ControlResultCode { OK = 0, UNKNOWN_ERROR = 1, OUT_OF_MEMORY = 2, FAILED_TO_SEND = 3, CLOSED = 4, NO_ACTION_BIT_SET = 5, SERVER_ERROR = 6, TIMEOUT = 7, } [CCode ( cname = "plac_transport_seek_result_code", cprefix = "PLAC_TRANSPORT_SEEK_RESULT_", has_type_id = false )] public enum SeekResultCode { OK = 0, UNKNOWN_ERROR = 1, OUT_OF_MEMORY = 2, FAILED_TO_SEND = 3, CLOSED = 4, SERVER_ERROR = 5, TIMEOUT = 6, } [CCode ( cname = "plac_transport_volume_control_result_code", cprefix = "PLAC_TRANSPORT_VOLUME_CONTROL_RESULT_", has_type_id = false )] public enum VolumeControlResultCode { OK = 0, UNKNOWN_ERROR = 1, OUT_OF_MEMORY = 2, FAILED_TO_SEND = 3, CLOSED = 4, SERVER_ERROR = 5, TIMEOUT = 6, ILLEGAL_METHOD = 7, } } namespace Browse { [CCode ( cname = "plac_browse_hierarchy", cprefix = "PLAC_BROWSE_HIERARCHY_", has_type_id = false )] public enum Hierarchy { BROWSE = 0, PLAYLISTS = 1, SETTINGS = 2, INTERNET_RADIO = 3, ALBUMS = 4, ARTISTS = 5, GENRES = 6, COMPOSERS = 7, SEARCH = 8, } [CCode ( cname = "plac_browse_item_hint", cprefix = "PLAC_BROWSE_ITEM_HINT_", has_type_id = false )] public enum ItemHint { UNKNOWN = 0, ACTION = 1, ACTION_LIST = 2, LIST = 3, HEADER = 4, } [CCode ( cname = "plac_browse_input_prompt", ref_function = "plac_browse_input_prompt_retain", unref_function = "plac_browse_input_prompt_release" )] [Compact] public class InputPrompt { public string prompt; public string action; public string? default_value; public bool is_password; } [CCode ( cname = "plac_browse_item", ref_function = "plac_browse_item_retain", unref_function = "plac_browse_item_release" )] [Compact] public class Item { public string title; public string? subtitle; public string? image_key; public string? item_key; public ItemHint hint; public InputPrompt prompt; } [CCode ( cname = "plac_browse_result_code", cprefix = "PLAC_BROWSE_RESULT_", has_type_id = false )] public enum ResultCode { OK = 0, UNKNOWN_ERROR = 1, MISSING_PROPERTY = 2, OUT_OF_MEMORY = 3, FAILED_TO_SEND = 4, CLOSED = 5, TIMEOUT = 6, } [CCode ( cname = "plac_browse_result_action", cprefix = "PLAC_BROWSE_RESULT_ACTION_", has_type_id = false )] public enum ResultAction { NONE = 0, REPLACE_ITEM = 1, REMOVE_ITEM = 2, LIST = 3, ERROR_MESSAGE = 4, MESSAGE = 5, } [CCode ( cname = "plac_browse_replace_item_action", ref_function = "plac_browse_replace_item_action_retain", unref_function = "plac_browse_replace_item_action_release" )] [Compact] public class ReplaceItemAction { public Item item; } [CCode ( cname = "plac_browse_list_action", ref_function = "plac_browse_list_action_retain", unref_function = "plac_browse_list_action_release" )] [Compact] public class ListAction { public string title; public string? subtitle; public string? image_key; public uint64 level; [CCode ( cname = "items_ptr", array_length_cname = "items_len", array_length_type = "size_t" )] public Item[] items; } [CCode ( cname = "plac_browse_error_message_action", ref_function = "plac_browse_error_message_action_retain", unref_function = "plac_browse_error_message_action_release" )] [Compact] public class ErrorMessageAction { public string message; } [CCode ( cname = "plac_browse_message_action", ref_function = "plac_browse_message_action_retain", unref_function = "plac_browse_message_action_release" )] [Compact] public class MessageAction { public string message; } [CCode ( cname = "plac_browse_result", ref_function = "plac_browse_result_retain", unref_function = "plac_browse_result_release" )] [Compact] public class Result { public ResultCode code; public ResultAction action; public ReplaceItemAction get_replace_item_action(); public ListAction get_list_action(); public ErrorMessageAction get_error_message_action(); public MessageAction get_message_action(); } [CCode ( cname = "plac_browse_label", ref_function = "plac_browse_label_retain", unref_function = "plac_browse_label_release" )] [Compact] public class Label { public string plain_text; [CCode (cname = "plac_browse_label_from_string")] public Label(string src); } } namespace Image { [CCode ( cname = "plac_image_scaling_method", cprefix = "PLAC_IMAGE_SCALING_METHOD_", has_type_id = false )] public enum ScalingMethod { FIT = 0, FILL = 1, STRETCH = 2, } [CCode ( cname = "plac_image_content_type", cprefix = "PLAC_IMAGE_CONTENT_TYPE_", has_type_id = false )] public enum ContentType { JPEG = 0, PNG = 1, } [CCode ( cname = "plac_image_get_options", ref_function = "plac_image_get_options_retain", unref_function = "plac_image_get_options_release" )] [Compact] public class GetOptions { [CCode (cname = "plac_image_get_options_make")] public GetOptions(); public void set_size(ScalingMethod scaling, size_t width, size_t height); public void set_content_type(ContentType content_type); } } [CCode ( cname = "plac_connection_connection_error", cprefix = "PLAC_CONNECTION_ERROR_", has_type_id = false )] public enum ConnectionError { UNKNOWN = 0, CLOSED_BY_SERVER = 1, OUT_OF_MEMORY = 2, UNEXPECTED_RESPONSE = 3, NETWORK_UNAVAILABLE = 4, NETWORK_ERROR = 5, } [CCode ( cname = "plac_connection_connection_error_event", ref_function = "plac_connection_connection_error_event_retain", unref_function = "plac_connection_connection_error_event_release" )] [Compact] public class ConnectionErrorEvent { [CCode (cname = "plac_connection_connection_error_event_retain")] public void @ref (); [CCode (cname = "plac_connection_connection_error_event_release")] public void unref (); public ConnectionError code; } [CCode ( cname = "plac_connection_connected_event", ref_function = "plac_connection_connected_event_retain", unref_function = "plac_connection_connected_event_release" )] [Compact] public class ConnectedEvent { [CCode (cname = "plac_connection_connected_event_retain")] public void @ref (); [CCode (cname = "plac_connection_connected_event_release")] public void unref (); public string token; } [CCode ( cname = "plac_connection_event", ref_function = "plac_connection_event_retain", unref_function = "plac_connection_event_release" )] [Compact] public class ConnectionEvent { [CCode (cname = "plac_connection_event_retain")] public void @ref (); [CCode (cname = "plac_connection_event_release")] public void unref (); [CCode ( cname = "plac_connection_event_kind", cprefix = "PLAC_CONNECTION_EVENT_", has_type_id = false )] public enum Kind { ERROR = 0, CONNECTED = 1, ZONE_LIST = 10, } public Kind kind; [CCode (cname = "plac_connection_event_get_connection_error_event")] public ConnectionErrorEvent get_connection_error_event(); [CCode (cname = "plac_connection_event_get_connected_event")] public ConnectedEvent get_connected_event(); [CCode (cname = "plac_connection_event_get_zone_list_event")] public Transport.ZoneListEvent get_zone_list_event(); } [CCode ( cname = "plac_connection", ref_function = "plac_connection_retain", unref_function = "plac_connection_release" )] [Compact] private class Connection { [CCode (cname = "plac_connection_make")] public Connection(Discovery.Server server, string? token); [CCode (cname = "plac_connection_retain")] public void @ref (); [CCode (cname = "plac_connection_release")] public void unref (); [CCode (cname = "plac_connection_get_event")] public ConnectionEvent get_event(); [CCode (cname = "plac_connection_disconnect")] public void disconnect(); [CCode (cname = "plac_connection_subscribe_zones")] public void subscribe_zones(); [CCode (cname = "plac_connection_control")] public void control(Transport.Zone zone, uint16 action); [CCode (cname = "plac_connection_change_volume")] public Transport.VolumeControlResultCode change_volume(Transport.Output output, double abs_value); [CCode (cname = "plac_connection_increase_volume")] public Transport.VolumeControlResultCode increase_volume(Transport.Output output); [CCode (cname = "plac_connection_decrease_volume")] public Transport.VolumeControlResultCode decrease_volume(Transport.Output output); [CCode (cname = "plac_connection_seek")] public Transport.SeekResultCode seek(Transport.Zone zone, int64 seconds); [CCode (cname = "plac_connection_browse")] public Browse.Result? browse(Browse.Hierarchy hierarchy, Transport.Zone? zone, Browse.Item? item, bool pop); [CCode (cname = "plac_connection_get_image_url")] public string? get_image_url(string image_key, Image.GetOptions options); } }
-
-
src/core/services/BrowseService.zig (deleted)
-
@@ -1,257 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); pub const id = "com.roonlabs.browse:1"; pub const Item = struct { title: []const u8, subtitle: ?[]const u8 = null, image_key: ?[]const u8 = null, item_key: ?[]const u8 = null, hint: Hint = .unknown, input_prompt: ?InputPrompt = null, pub const Hint = enum { unknown, action, action_list, list, header, pub fn jsonParseFromValue( _: std.mem.Allocator, value: std.json.Value, _: std.json.ParseOptions, ) std.json.ParseFromValueError!@This() { return switch (value) { .string => |v| { inline for (@typeInfo(@This()).@"enum".fields) |field| { if (std.mem.eql(u8, field.name, "unknown")) { continue; } if (std.mem.eql(u8, field.name, v)) { return @enumFromInt(field.value); } } return .unknown; }, else => .unknown, }; } }; pub const InputPrompt = struct { prompt: []const u8, action: []const u8, value: ?[]const u8 = null, is_password: bool = false, }; }; pub const List = struct { title: []const u8, count: usize = 0, subtitle: ?[]const u8 = null, image_key: ?[]const u8 = null, level: usize = 0, display_offset: ?i64 = null, hint: Hint = .unknown, pub const Hint = enum { unknown, action_list, pub fn jsonParseFromValue( _: std.mem.Allocator, value: std.json.Value, _: std.json.ParseOptions, ) std.json.ParseFromValueError!@This() { return switch (value) { .string => |v| { inline for (@typeInfo(@This()).@"enum".fields) |field| { if (std.mem.eql(u8, field.name, "unknown")) { continue; } if (std.mem.eql(u8, field.name, v)) { return @enumFromInt(field.value); } } return .unknown; }, else => .unknown, }; } }; }; pub const Browse = struct { const method = "/browse"; pub const Request = struct { hierarchy: []const u8, item_key: ?[]const u8 = null, input: ?[]const u8 = null, zone_or_output_id: ?[]const u8 = null, pop_all: ?bool = null, pop_levels: ?usize = null, refresh_list: ?bool = null, /// Caller owns the returned memory pub fn encode( self: Request, allocator: std.mem.Allocator, request_id: u64, ) ![]u8 { const meta = moo.Metadata{ .service = id ++ method, .verb = "REQUEST", }; const body = moo.JsonBody(Request).init(&self, .{ .emit_null_optional_fields = false, }); const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } }; pub const Response = struct { action: Action, item: ?Item = null, list: ?List = null, message: ?[]const u8 = null, is_error: ?bool = null, const Action = enum { message, none, list, replace_item, remove_item, }; pub const Error = error{ NonSuccessResponse, }; pub fn parse( allocator: std.mem.Allocator, meta: *const moo.Metadata, header_ctx: moo.HeaderParsingContext, message: []const u8, ) !moo.JsonBody(Response) { if (!std.mem.eql(u8, meta.service, "Success")) { std.log.err("Expected \"Success\" for {s} endpoint, got \"{s}\"", .{ method, meta.service }); return Error.NonSuccessResponse; } _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); return try moo.JsonBody(Response).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true, .allocate = .alloc_always, }); } }; }; pub const Load = struct { const method = "/load"; pub const Request = struct { set_display_offset: ?i64 = null, level: ?usize = 0, offset: ?i64 = 0, count: ?usize = 0, hierarchy: []const u8, multi_session_key: ?[]const u8 = null, /// Caller owns the returned memory pub fn encode( self: Request, allocator: std.mem.Allocator, request_id: u64, ) ![]u8 { const meta = moo.Metadata{ .service = id ++ method, .verb = "REQUEST", }; const body = moo.JsonBody(Request).init(&self, .{ .emit_null_optional_fields = false, }); const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } }; pub const Response = struct { items: []const Item, offset: i64 = 0, list: List, pub const Error = error{ NonSuccessResponse, }; pub fn parse( allocator: std.mem.Allocator, meta: *const moo.Metadata, header_ctx: moo.HeaderParsingContext, message: []const u8, ) !moo.JsonBody(Response) { if (!std.mem.eql(u8, meta.service, "Success")) { std.log.err("Expected \"Success\" for {s} endpoint, got \"{s}\"", .{ method, meta.service }); return Error.NonSuccessResponse; } _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); return try moo.JsonBody(Response).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true, .allocate = .alloc_always, }); } }; };
-
-
src/core/services/ImageService.zig (deleted)
-
@@ -1,237 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); pub const id = "com.roonlabs.image:1"; pub const ScalingMethod = enum { fit, fill, stretch, pub fn jsonStringify(self: @This(), jws: anytype) !void { try jws.write(@tagName(self)); } }; pub const ContentType = enum { jpeg, png, pub fn jsonStringify(self: @This(), jws: anytype) !void { try jws.write(switch (self) { .jpeg => "image/jpeg", .png => "image/png", }); } pub const FromStringError = error{ UnknownContentType, }; pub fn fromString(input: []const u8) FromStringError!@This() { inline for (@typeInfo(@This()).@"enum".fields) |field| { if (std.mem.eql(u8, input, std.fmt.comptimePrint("image/{s}", .{field.name}))) { return @enumFromInt(field.value); } } return FromStringError.UnknownContentType; } }; pub const Get = struct { const method = "/get_image"; pub const Request = struct { scale: ?ScalingMethod = null, width: ?usize = null, height: ?usize = null, format: ?ContentType = null, image_key: []const u8, pub fn encode( self: @This(), allocator: std.mem.Allocator, request_id: u64, ) ![]u8 { const meta = moo.Metadata{ .service = id ++ method, .verb = "REQUEST", }; const body = moo.JsonBody(Request).init(&self, .{ .emit_null_optional_fields = false, }); const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } pub fn url( self: *const @This(), comptime T: type, allocator: std.mem.Allocator, addr: std.net.Address, ) !T { const path = try std.fmt.allocPrint(allocator, "/api/image/{s}", .{self.image_key}); defer allocator.free(path); var query = std.ArrayList(u8).init(allocator); defer query.deinit(); const query_writer = query.writer(); if (self.scale) |scale| { try std.fmt.format(query_writer, "&scale={s}", .{@tagName(scale)}); } if (self.width) |width| { try std.fmt.format(query_writer, "&width={d}", .{width}); } if (self.height) |height| { try std.fmt.format(query_writer, "&height={d}", .{height}); } if (self.format) |format| { try std.fmt.format(query_writer, "&format=image/{s}", .{@tagName(format)}); } const query_component: ?std.Uri.Component = if (query.items.len > 0) .{ .raw = query.items[1..], } else null; var origin = std.ArrayList(u8).init(allocator); defer origin.deinit(); try addr.format("", .{}, origin.writer()); var uri = std.Uri{ .scheme = "http", .host = .{ // std.net.Address is POSIX's address, which contains both IP address and port. // The type has no method to print only IP address part. .percent_encoded = origin.items, }, .path = .{ .raw = path, }, .query = query_component, }; var result = std.ArrayList(u8).init(allocator); errdefer result.deinit(); try uri.writeToStream(.{ .scheme = true, .authority = true, .path = true, .query = true, }, result.writer()); return switch (T) { []const u8, []u8 => result.toOwnedSlice(), [:0]const u8, [:0]u8 => result.toOwnedSliceSentinel(0), else => @compileError(std.fmt.comptimePrint( "Unsupported type {s}. Must be a slice of u8", .{@typeName(T)}, )), }; } test url { const req = Request{ .image_key = "foo", }; const result = try req.url( []const u8, std.testing.allocator, try std.net.Address.parseIp("127.0.0.1", 8080), ); defer std.testing.allocator.free(result); try std.testing.expectEqualStrings("http://127.0.0.1:8080/api/image/foo", result); } test "url constructs search params" { const req = Request{ .image_key = "foo", .scale = .fit, .width = 100, .height = 200, .format = .png, }; const result = try req.url([]const u8, std.testing.allocator, try std.net.Address.parseIp("127.0.0.1", 8080)); defer std.testing.allocator.free(result); try std.testing.expectEqualStrings( "http://127.0.0.1:8080/api/image/foo?scale=fit&width=100&height=200&format=image/png", result, ); } }; pub const Response = struct { content_type: ContentType, data: []const u8, pub const DecodeError = error{ NonSuccessResponse, }; /// Returned response's `data` field is a slice of `message` bytes. pub fn decode( meta: *const moo.Metadata, header_ctx: moo.HeaderParsingContext, message: []const u8, ) !@This() { if (!std.mem.eql(u8, meta.service, "Success")) { std.log.err( "Expected \"Success\" for {s}{s}, got \"{s}\"", .{ id, method, meta.service }, ); return DecodeError.NonSuccessResponse; } const header, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); const content_type = try ContentType.fromString(header.content_type); const body = try moo.RawBody.parse(message, body_ctx); return .{ .content_type = content_type, .data = body.bytes }; } }; }; test { _ = Get.Request.url; }
-
-
src/core/services/PingService.zig (deleted)
-
@@ -1,37 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); pub const id = "com.roonlabs.ping:1"; pub const ping_id = id ++ "/ping"; pub fn ping(writer: anytype, parser_ctx: moo.HeaderParsingContext, message: []const u8) !void { const req_header, _ = try moo.NoBodyHeaders.parse(message, parser_ctx); const meta = moo.Metadata{ .service = "Success", .verb = "COMPLETE", }; const body = moo.NoBody{}; const header = body.getHeader(req_header.request_id); try moo.encode(writer, meta, header, body); }
-
-
src/core/services/TransportService.zig (deleted)
-
@@ -1,343 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); pub const id = "com.roonlabs.transport:2"; pub const NowPlaying = struct { seek_position: ?u64, length: ?u64, image_key: ?[]const u8 = null, one_line: struct { line1: []const u8, }, two_line: struct { line1: []const u8, line2: ?[]const u8 = null, }, three_line: struct { line1: []const u8, line2: ?[]const u8 = null, line3: ?[]const u8 = null, }, }; pub const PlaybackState = enum { playing, paused, loading, stopped, pub fn jsonParseFromValue( _: std.mem.Allocator, value: std.json.Value, _: std.json.ParseOptions, ) std.json.ParseFromValueError!@This() { return switch (value) { .string => |v| { inline for (@typeInfo(@This()).@"enum".fields) |field| { if (std.mem.eql(u8, field.name, v)) { return @enumFromInt(field.value); } } return std.json.ParseFromValueError.InvalidEnumTag; }, else => std.json.ParseFromValueError.UnexpectedToken, }; } }; pub const OutputVolume = struct { type: []const u8, min: ?f64 = null, max: ?f64 = null, value: ?f64 = null, step: ?f64 = null, is_muted: ?bool = null, }; pub const Output = struct { output_id: []const u8, display_name: []const u8, volume: ?OutputVolume = null, }; pub const Zone = struct { zone_id: []const u8, display_name: []const u8, outputs: []const Output, now_playing: ?NowPlaying = null, state: PlaybackState, is_next_allowed: bool = false, is_previous_allowed: bool = false, is_pause_allowed: bool = false, is_play_allowed: bool = false, is_seek_allowed: bool = false, }; pub const SeekChange = struct { zone_id: []const u8, seek_position: ?u64 = null, }; pub const SubscribeZoneChanges = struct { pub const Request = struct { subscription_key: []const u8, }; pub fn request(allocator: std.mem.Allocator, request_id: u64, subscription_id: u64) ![]u8 { const meta = moo.Metadata{ .service = id ++ "/subscribe_zones", .verb = "REQUEST", }; var sub_id_buf: [std.fmt.count("{}", .{std.math.maxInt(u64)})]u8 = undefined; var sub_id_fbs = std.io.fixedBufferStream(&sub_id_buf); try std.fmt.format(sub_id_fbs.writer(), "{}", .{subscription_id}); const body = moo.JsonBody(Request).init(&.{ .subscription_key = sub_id_fbs.getWritten(), }, .{ .emit_null_optional_fields = false, }); const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } pub const InitialResponse = struct { zones: []const Zone, }; pub fn initialResponse( allocator: std.mem.Allocator, header_ctx: moo.HeaderParsingContext, message: []const u8, ) !moo.JsonBody(InitialResponse) { _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); return try moo.JsonBody(InitialResponse).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true, .allocate = .alloc_always, }); } pub const Response = struct { zones_removed: []const []const u8 = &.{}, zones_added: []const Zone = &.{}, zones_changed: []const Zone = &.{}, zones_seek_changed: []const SeekChange = &.{}, }; pub fn response( allocator: std.mem.Allocator, header_ctx: moo.HeaderParsingContext, message: []const u8, ) !moo.JsonBody(Response) { _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); return try moo.JsonBody(Response).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true, .allocate = .alloc_always, }); } }; pub const Control = enum { play, pause, playpause, stop, previous, next, const method = "/control"; pub const Request = struct { zone_or_output_id: []const u8, control: []const u8, }; /// Caller owns the returned memory pub fn request( self: Control, allocator: std.mem.Allocator, request_id: u64, zone_id: []const u8, ) ![]u8 { const meta = moo.Metadata{ .service = id ++ method, .verb = "REQUEST", }; const body = moo.JsonBody(Request).init(&Request{ .zone_or_output_id = zone_id, .control = @tagName(self), }, .{}); const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } pub const ResponseError = error{ NonSuccessResponse, }; pub fn response( meta: *const moo.Metadata, ) ResponseError!void { if (!std.mem.eql(u8, meta.service, "Success")) { std.log.err("Expected \"Success\" for {s} endpoint, got \"{s}\"", .{ method, meta.service }); return ResponseError.NonSuccessResponse; } } }; pub const Seek = struct { const method = "/seek"; pub const Request = struct { zone_or_output_id: []const u8, how: enum { relative, absolute, }, /// Can be negative when `how` is `relative` seconds: i64, pub fn encode( self: @This(), allocator: std.mem.Allocator, request_id: u64, ) ![]u8 { const meta = moo.Metadata{ .service = id ++ method, .verb = "REQUEST", }; const body = moo.JsonBody(Request).init(&self, .{}); const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } }; pub const Response = struct { pub const DecodeError = error{ NonSuccessResponse, }; pub fn decode(meta: *const moo.Metadata) !@This() { if (!std.mem.eql(u8, meta.service, "Success")) { std.log.err( "Expected \"Success\" for {s}{s}, got \"{s}\"", .{ id, method, meta.service }, ); return DecodeError.NonSuccessResponse; } return .{}; } }; }; pub const ChangeVolume = struct { const method = "/change_volume"; pub const Request = struct { output_id: []const u8, how: enum { absolute, relative, relative_step, }, value: f64, /// Caller owns the returned memory. pub fn encode( self: @This(), allocator: std.mem.Allocator, request_id: u64, ) ![]u8 { const meta = moo.Metadata{ .service = id ++ method, .verb = "REQUEST", }; const body = moo.JsonBody(Request).init(&self, .{}); const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } }; pub const Response = struct { pub const DecodeError = error{ NonSuccessResponse, }; pub fn decode(meta: *const moo.Metadata) !@This() { if (!std.mem.eql(u8, meta.service, "Success")) { std.log.err( "Expected \"Success\" for {s}{s}, got \"{s}\"", .{ id, method, meta.service }, ); return DecodeError.NonSuccessResponse; } return .{}; } }; };
-
-
src/core/services/registry.zig (deleted)
-
@@ -1,178 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); pub const Extension = struct { id: []const u8, display_name: []const u8, version: []const u8, publisher: []const u8, email: []const u8, required_services: []const []const u8, optional_services: []const []const u8, provided_services: []const []const u8, }; /// Extension object to send to Roon Server. const ExtensionRegistration = struct { static: *const Extension, token: ?[]const u8 = null, pub fn jsonStringify(self: *const @This(), jws: anytype) !void { try jws.beginObject(); try jws.objectField("extension_id"); try jws.write(self.static.id); try jws.objectField("display_name"); try jws.write(self.static.display_name); try jws.objectField("display_version"); try jws.write(self.static.version); try jws.objectField("publisher"); try jws.write(self.static.publisher); try jws.objectField("email"); try jws.write(self.static.email); if (self.token) |token| { try jws.objectField("token"); try jws.write(token); } try jws.objectField("required_services"); try jws.write(self.static.required_services); try jws.objectField("optional_services"); try jws.write(self.static.optional_services); try jws.objectField("provided_services"); try jws.write(self.static.provided_services); try jws.endObject(); } }; pub const RegistryService = struct { const id = "com.roonlabs.registry:1"; pub const Info = struct { pub const Response = struct { core_id: []const u8, display_name: []const u8, display_version: []const u8, }; /// Caller owns the returned memory pub fn request(allocator: std.mem.Allocator, request_id: u64) ![]u8 { const meta = moo.Metadata{ .service = id ++ "/info", .verb = "REQUEST", }; const body = moo.NoBody{}; const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } /// Caller is responsible to release the returned resource by calling `.deinit()`. pub fn response( allocator: std.mem.Allocator, header_ctx: moo.HeaderParsingContext, message: []const u8, ) !moo.JsonBody(Response) { _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); return try moo.JsonBody(Response).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true, .allocate = .alloc_always, }); } }; pub const Register = struct { pub const Response = struct { core_id: []const u8, token: []const u8, }; pub const Error = error{ NonSuccessResponse, }; /// Caller owns the returned memory pub fn request( allocator: std.mem.Allocator, request_id: u64, extension: *const Extension, token: ?[]const u8, ) ![]u8 { const meta = moo.Metadata{ .service = id ++ "/register", .verb = "REQUEST", }; const body = moo.JsonBody(ExtensionRegistration).init(&.{ .static = extension, .token = token, }, .{}); const header = body.getHeader(request_id); const buf = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); errdefer allocator.free(buf); var fbs = std.io.fixedBufferStream(buf); try moo.encode(fbs.writer(), meta, header, body); return buf; } /// Caller is responsible to release the returned resource by calling `.deinit()`. pub fn response( allocator: std.mem.Allocator, meta: *const moo.Metadata, header_ctx: moo.HeaderParsingContext, message: []const u8, ) !moo.JsonBody(Response) { if (!std.mem.eql(u8, meta.service, "Registered")) { std.log.err("Expected \"Registered\" for /register endpoint, got \"{s}\"\n", .{meta.service}); return Error.NonSuccessResponse; } _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); return try moo.JsonBody(Response).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true, .allocate = .alloc_always, }); } }; };
-
-
src/core/transport.zig (deleted)
-
@@ -1,655 +0,0 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const Arc = @import("./Arc.zig"); const TransportService = @import("./services/TransportService.zig"); const freelog = @import("./log.zig").freelog; pub const NowPlaying = extern struct { const cname = "plac_transport_now_playing"; const allocator = std.heap.c_allocator; internal: *Internal, one_line_line1: [*:0]const u8, two_line_line1: [*:0]const u8, two_line_line2: ?[*:0]const u8, three_line_line1: [*:0]const u8, three_line_line2: ?[*:0]const u8, three_line_line3: ?[*:0]const u8, seek_position: u64, length: u64, has_seek_position: bool, has_length: bool, image_key: ?[*:0]const u8, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const TransportService.NowPlaying) std.mem.Allocator.Error!*NowPlaying { const one_line_line1 = try allocator.dupeZ(u8, src.one_line.line1); errdefer allocator.free(one_line_line1); const two_line_line1 = try allocator.dupeZ(u8, src.two_line.line1); errdefer allocator.free(two_line_line1); const two_line_line2 = if (src.two_line.line2) |input| try allocator.dupeZ(u8, input) else null; errdefer if (two_line_line2) |buf| allocator.free(buf); const three_line_line1 = try allocator.dupeZ(u8, src.three_line.line1); errdefer allocator.free(three_line_line1); const three_line_line2 = if (src.three_line.line2) |input| try allocator.dupeZ(u8, input) else null; errdefer if (three_line_line2) |buf| allocator.free(buf); const three_line_line3 = if (src.three_line.line3) |input| try allocator.dupeZ(u8, input) else null; errdefer if (three_line_line3) |buf| allocator.free(buf); const image_key = if (src.image_key) |input| try allocator.dupeZ(u8, input) else null; errdefer if (image_key) |buf| allocator.free(buf); const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const self = try allocator.create(NowPlaying); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .one_line_line1 = one_line_line1.ptr, .two_line_line1 = two_line_line1.ptr, .two_line_line2 = if (two_line_line2) |slice| slice.ptr else null, .three_line_line1 = three_line_line1, .three_line_line2 = if (three_line_line2) |slice| slice.ptr else null, .three_line_line3 = if (three_line_line3) |slice| slice.ptr else null, .seek_position = src.seek_position orelse 0, .length = src.length orelse 0, .has_seek_position = src.seek_position != null, .has_length = src.length != null, .image_key = if (image_key) |slice| slice.ptr else null, }; return self; } pub fn retain(ptr: ?*NowPlaying) callconv(.C) *NowPlaying { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*NowPlaying) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); allocator.free(std.mem.span(self.one_line_line1)); allocator.free(std.mem.span(self.two_line_line1)); if (self.two_line_line2) |line| { allocator.free(std.mem.span(line)); } allocator.free(std.mem.span(self.three_line_line1)); if (self.three_line_line2) |line| { allocator.free(std.mem.span(line)); } if (self.three_line_line3) |line| { allocator.free(std.mem.span(line)); } if (self.image_key) |image_key| { allocator.free(std.mem.span(image_key)); } allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const SeekChange = extern struct { const cname = "plac_transport_seek_change"; const allocator = std.heap.c_allocator; internal: *Internal, zone_id: [*:0]const u8, seek_position: u64, has_seek_position: bool, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const TransportService.SeekChange) std.mem.Allocator.Error!*SeekChange { const zone_id = try allocator.dupeZ(u8, src.zone_id); errdefer allocator.free(zone_id); const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const self = try allocator.create(SeekChange); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .zone_id = zone_id.ptr, .seek_position = src.seek_position orelse 0, .has_seek_position = src.seek_position != null, }; return self; } pub fn retain(ptr: ?*SeekChange) callconv(.C) *SeekChange { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*SeekChange) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); allocator.free(std.mem.span(self.zone_id)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const OutputVolumeUnit = enum(c_int) { unknown = 0, number = 1, decibel = 2, }; pub const OutputVolume = extern struct { unit: OutputVolumeUnit = .unknown, min: f64 = 0, max: f64 = 0, value: f64 = 0, step: f64 = 0.0, muted: bool = false, }; pub const Output = extern struct { const cname = "plac_transport_output"; const allocator = std.heap.c_allocator; internal: *Internal, id: [*:0]const u8, display_name: [*:0]const u8, is_incremental_volume: bool, volume: OutputVolume, const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const TransportService.Output) std.mem.Allocator.Error!*@This() { const id = try allocator.dupeZ(u8, src.output_id); errdefer allocator.free(id); const display_name = try allocator.dupeZ(u8, src.display_name); errdefer allocator.free(display_name); const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const self = try allocator.create(@This()); errdefer allocator.destroy(self); const volume: OutputVolume = if (src.volume) |v| .{ .unit = if (std.mem.eql(u8, "number", v.type)) .number else if (std.mem.eql(u8, "db", v.type)) .decibel else .unknown, .min = v.min orelse 0, .max = v.max orelse 0, .value = v.value orelse 0, .step = v.step orelse 0, .muted = v.is_muted orelse false, } else .{}; self.* = .{ .internal = internal, .id = id.ptr, .display_name = display_name.ptr, .is_incremental_volume = if (src.volume) |v| std.mem.eql(u8, "incremental", v.type) else false, .volume = volume, }; return self; } pub fn retain(ptr: ?*@This()) callconv(.C) *@This() { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*@This()) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); allocator.free(std.mem.span(self.id)); allocator.free(std.mem.span(self.display_name)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const PlaybackState = enum(c_int) { loading = 0, stopped = 1, paused = 2, playing = 3, }; pub const Action = packed struct(u16) { next: bool, prev: bool, pause: bool, play: bool, seek: bool, _padding: u11 = 0, }; pub const Zone = extern struct { const cname = "plac_transport_zone"; const allocator = std.heap.c_allocator; internal: *Internal, id: [*:0]const u8, name: [*:0]const u8, outputs: [*]const *Output, outputs_len: usize, now_playing: ?*NowPlaying, playback: PlaybackState, allowed_action: Action, pub const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const TransportService.Zone) std.mem.Allocator.Error!*Zone { const id = try allocator.dupeZ(u8, src.zone_id); errdefer allocator.free(id); const name = try allocator.dupeZ(u8, src.display_name); errdefer allocator.free(name); const now_playing = if (src.now_playing) |*input| now_playing: { const n = try NowPlaying.make(input); break :now_playing n.retain(); } else null; errdefer if (now_playing) |n| n.release(); const outputs = try allocator.alloc(*Output, src.outputs.len); errdefer allocator.free(outputs); var i: usize = 0; errdefer for (0..i) |x| { outputs[x].release(); }; for (src.outputs) |*output| { outputs[i] = try Output.make(output); _ = outputs[i].retain(); i += 1; } const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const self = try allocator.create(Zone); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .id = id.ptr, .name = name.ptr, .outputs = outputs.ptr, .outputs_len = outputs.len, .playback = switch (src.state) { .loading => .loading, .stopped => .stopped, .paused => .paused, .playing => .playing, }, .allowed_action = .{ .next = src.is_next_allowed, .prev = src.is_previous_allowed, .pause = src.is_pause_allowed, .play = src.is_play_allowed, .seek = src.is_seek_allowed, }, .now_playing = now_playing, }; return self; } pub fn retain(ptr: ?*Zone) callconv(.C) *Zone { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*Zone) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); for (self.outputs[0..self.outputs_len]) |output| { output.release(); } allocator.free(self.outputs[0..self.outputs_len]); if (self.now_playing) |now_playing| { now_playing.release(); } allocator.free(std.mem.span(self.id)); allocator.free(std.mem.span(self.name)); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ZoneListEvent = extern struct { const cname = "plac_transport_zone_list_event"; const allocator = std.heap.c_allocator; internal: *Internal, added_zones_ptr: [*]const *Zone, added_zones_len: usize, changed_zones_ptr: [*]const *Zone, changed_zones_len: usize, removed_zone_ids_ptr: [*]const [*:0]const u8, removed_zone_ids_len: usize, seek_changes_ptr: [*]const *SeekChange, seek_changes_len: usize, pub const Internal = struct { arc: Arc = .{}, }; pub fn makeFromChanges(event: *const TransportService.SubscribeZoneChanges.Response) std.mem.Allocator.Error!*ZoneListEvent { const added_zones = try allocator.alloc(*Zone, event.zones_added.len); errdefer allocator.free(added_zones); var added_i: usize = 0; errdefer { for (0..added_i) |i| { added_zones[i].release(); } } for (event.zones_added) |src| { const zone = try Zone.make(&src); added_zones[added_i] = zone.retain(); added_i += 1; } const changed_zones = try allocator.alloc(*Zone, event.zones_changed.len); errdefer allocator.free(changed_zones); var changed_i: usize = 0; errdefer { for (0..changed_i) |i| { changed_zones[i].release(); } } for (event.zones_changed) |src| { const zone = try Zone.make(&src); changed_zones[changed_i] = zone.retain(); changed_i += 1; } const removed_zone_ids = try allocator.alloc([*:0]const u8, event.zones_removed.len); errdefer allocator.free(removed_zone_ids); var removed_i: usize = 0; errdefer { for (0..removed_i) |i| { allocator.free(std.mem.span(removed_zone_ids[i])); } } for (event.zones_removed) |id| { removed_zone_ids[removed_i] = (try allocator.dupeZ(u8, id)).ptr; removed_i += 1; } const seek_changes = try allocator.alloc(*SeekChange, event.zones_seek_changed.len); errdefer allocator.free(seek_changes); var seek_i: usize = 0; errdefer { for (0..seek_i) |i| { seek_changes[i].release(); } } for (event.zones_seek_changed) |src| { const change = try SeekChange.make(&src); seek_changes[seek_i] = change.retain(); seek_i += 1; } const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const self = try allocator.create(ZoneListEvent); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .added_zones_ptr = added_zones.ptr, .added_zones_len = added_zones.len, .changed_zones_ptr = changed_zones.ptr, .changed_zones_len = changed_zones.len, .removed_zone_ids_ptr = removed_zone_ids.ptr, .removed_zone_ids_len = removed_zone_ids.len, .seek_changes_ptr = seek_changes.ptr, .seek_changes_len = seek_changes.len, }; return self; } pub fn makeFromInitial( event: *const TransportService.SubscribeZoneChanges.InitialResponse, ) std.mem.Allocator.Error!*ZoneListEvent { const added_zones = try allocator.alloc(*Zone, event.zones.len); errdefer allocator.free(added_zones); var added_i: usize = 0; errdefer { for (0..added_i) |i| { added_zones[i].release(); } } for (event.zones) |src| { const zone = try Zone.make(&src); added_zones[added_i] = zone.retain(); added_i += 1; } const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const self = try allocator.create(ZoneListEvent); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .added_zones_ptr = added_zones.ptr, .added_zones_len = added_zones.len, .changed_zones_ptr = undefined, .changed_zones_len = 0, .removed_zone_ids_ptr = undefined, .removed_zone_ids_len = 0, .seek_changes_ptr = undefined, .seek_changes_len = 0, }; return self; } pub fn retain(ptr: ?*ZoneListEvent) callconv(.C) *ZoneListEvent { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); self.internal.arc.ref(); return self; } pub fn release(ptr: ?*ZoneListEvent) callconv(.C) void { var self = ptr orelse @panic( std.fmt.comptimePrint("Received null pointer on {s}_{s}", .{ cname, @src().fn_name }), ); if (self.internal.arc.unref()) { freelog(self); for (self.added_zones_ptr[0..self.added_zones_len]) |zone| { zone.release(); } allocator.free(self.added_zones_ptr[0..self.added_zones_len]); for (self.changed_zones_ptr[0..self.changed_zones_len]) |zone| { zone.release(); } allocator.free(self.changed_zones_ptr[0..self.changed_zones_len]); for (self.removed_zone_ids_ptr[0..self.removed_zone_ids_len]) |id| { allocator.free(std.mem.span(id)); } allocator.free(self.removed_zone_ids_ptr[0..self.removed_zone_ids_len]); for (self.seek_changes_ptr[0..self.seek_changes_len]) |change| { change.release(); } allocator.free(self.seek_changes_ptr[0..self.seek_changes_len]); allocator.destroy(self.internal); allocator.destroy(self); } } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) }); } }; pub const ControlResultCode = enum(c_int) { ok = 0, unknown_error = 1, out_of_memory = 2, failed_to_send = 3, closed = 4, no_action_bit_set = 5, server_error = 6, timeout = 7, }; pub const SeekResultCode = enum(c_int) { ok = 0, unknown_error = 1, out_of_memory = 2, failed_to_send = 3, closed = 4, server_error = 5, timeout = 6, }; pub const VolumeControlResultCode = enum(c_int) { ok = 0, unknown_error = 1, out_of_memory = 2, failed_to_send = 3, closed = 4, server_error = 5, timeout = 6, illegal_method = 7, }; pub fn export_capi() void { SeekChange.export_capi(); NowPlaying.export_capi(); Output.export_capi(); Zone.export_capi(); ZoneListEvent.export_capi(); }
-