Changes
94 changed files (+14/-12715)
-
-
@@ -26,11 +26,6 @@ [*.md]# Two trailing spaces represents a line break. trim_trailing_whitespace = false [*.{zig,zon}] # Zig insists on 4 spaces, ignoring accessibility problems. indent_style = space indent_size = 4 [*.nix] # Nix's string interpolation has problem with tab character indent_style = space
-
-
.gitignore (deleted)
-
@@ -1,32 +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 # # === # # This file contains rules for project-wide ignores. # Do not put rules for a file specific to your environment. (e.g. OS garbage) # Also, if ignore rules only specific to a package subdirectory should be in the # subdirectory's ".gitignore" file. # # Each rule or a group of closely related rules should have a comment describe "What is this" # and preferably "Why this is ignored" if the reason is not so obvious. # What: Zig's build cache directory. .zig-cache # What: Build artifacts. (executables, copied headers, etc.) zig-out
-
-
-
@@ -16,23 +16,12 @@SPDX-License-Identifier: Apache-2.0 --> # Plac Third-party [Roon](https://roon.app/) clients. ## [Plac for GTK4](./gtk-adwaita/)  # Plac for macOS GTK4 application implemented using GNOME widgets. Best suited for Linux, but it also works on other systems where GTK4 and libadwaita are installed. ## [Plac for macOS](./macos/) Third-party [Roon](https://roon.app/) clients written in SwiftUI.  SwiftUI application written for Apple platforms. ## License This project is licensed under [Apache-2.0 License](https://www.apache.org/licenses/LICENSE-2.0).
-
@@ -41,18 +30,6 @@Every file has [REUSE][reuse-license] annotation for copyright and license. ## Development ### Internal Packages #### [Core](./core/) Core logic available as C API, written in Zig. This package provides clean API abstracting Roon API and networkings. #### [CLI](./cli/) Simple command line application for testing and profiling core API. Internal only and not intended for real world uses. ### System Dependencies
-
-
assets/screenshot-gtk-adwaita.png (deleted)
-
-
@@ -1,3 +0,0 @@Copyright 2025 Shota FUJI SPDX-License-Identifier: CC0-1.0
-
-
cli/.gitignore (deleted)
-
@@ -1,23 +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 # What: Connection state file, containing tokens and IDs. # Why: CLI automatically generates and loads this file, and the content is unique to each # local network and changes by user operations. .roon.json # What: Valgrind's core dump. vgcore.*
-
-
cli/README.md (deleted)
-
@@ -1,42 +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 --> # CLI Simple command line application for accessing Roon API. This is not intended for real-world uses—the sole reason of this program is to test core's functionality. Because this program does not have any runtime dependencies, you can easily check memory errors and profile using tools such as Valgrind. ## Development ### Building ``` zig build ``` This command builds and outputs binary to `zig-out/bin/plac`. ### Enable freelog To output debug log for releasing memory, enable `-Dfreelog` build option: ``` zig build -Dfreelog ```
-
-
cli/build.zig (deleted)
-
@@ -1,66 +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 //! This file defines how `zig build` command behaves. //! Run `zig build --help` for available subcommands and options. //! //! Learn more at //! https://ziglang.org/learn/build-system/ const std = @import("std"); 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 clap = clap: { const dep = b.dependency("clap", .{}); break :clap dep.module("clap"); }; const core = core: { const dep = b.dependency("plac_core", .{ .target = target, .optimize = optimize, .freelog = freelog, .@"extension-id" = @as([]const u8, "jp.pocka.plac.cli"), .@"extension-name" = @as([]const u8, "Plac CLI"), }); break :core dep.module("core"); }; // Executable { const exe = b.addExecutable(.{ .name = "plac", .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }), }); exe.root_module.addImport("core", core); exe.root_module.addImport("clap", clap); exe.linkLibC(); b.installArtifact(exe); } }
-
-
cli/build.zig.zon (deleted)
-
@@ -1,35 +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 .{ .name = .plac_cli, .version = "0.0.0", .fingerprint = 0xbc872ed057582c78, .minimum_zig_version = "0.14.0", .dependencies = .{ .plac_core = .{ .path = "../core", }, .clap = .{ .url = "https://github.com/Hejsil/zig-clap/archive/refs/tags/0.10.0.tar.gz", .hash = "clap-0.10.0-oBajB434AQBDh-Ei3YtoKIRxZacVPF1iSwp3IX_ZB8f0", }, }, .paths = .{ "build.zig", "build.zig.zon", "src/", }, }
-
-
cli/src/State.zig (deleted)
-
@@ -1,59 +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 filename: []const u8 = ".roon.json"; connection: ?Connection = null, const Connection = struct { server_id: []const u8, token: []const u8, }; pub fn clone(self: *const @This(), allocator: std.mem.Allocator) @This() { return .{ .connection = if (self.connection) |conn| conn: { const server_id = try allocator.dupe(u8, conn.server_id); errdefer allocator.free(server_id); const token = allocator.dupe(u8, conn.token); errdefer allocator.free(token); break :conn .{ .server_id = server_id, .token = token, }; } catch null, }; } pub fn write(self: *const @This()) !void { var file = try std.fs.cwd().createFile(filename, .{}); defer file.close(); try std.json.stringify(self, .{ .whitespace = .indent_tab }, file.writer()); } pub fn read(allocator: std.mem.Allocator) !std.json.Parsed(@This()) { const contents = try std.fs.cwd().readFileAlloc(allocator, filename, std.math.maxInt(usize)); defer allocator.free(contents); return try std.json.parseFromSlice(@This(), allocator, contents, .{ .allocate = .alloc_always, }); }
-
-
cli/src/commands/albums.zig (deleted)
-
@@ -1,194 +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 clap = @import("clap"); const core = @import("core"); const connection = @import("../connection.zig"); const State = @import("../State.zig"); const ExitCode = @import("../exit.zig").ExitCode; const OutputFormat = enum { text, tsv, jsonl, }; const parser = .{ .format = clap.parsers.enumeration(OutputFormat), }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\--header Whether emits header row (only on --format=tsv) \\-f, --format <format> Output format. \\Available values are: \\* text ... Plain text format for human consumption. \\* tsv ... TSV (tab-separated values). \\* jsonl ... Newline delimited list of JSON documents. \\ ); pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator) ExitCode { var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, ¶ms, parser, iter, .{ .diagnostic = &diag, .allocator = allocator, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return ExitCode.incorrect_usage; }; defer res.deinit(); if (res.args.help > 0) { clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.ok; } const conn = connection.make(allocator) catch |err| { std.log.err("Unable to establish a connection: {s}", .{@errorName(err)}); return switch (err) { error.OutOfMemory => ExitCode.out_of_memory, else => ExitCode.not_ok, }; }; defer conn.release(); while (true) { const ev = conn.getEvent() orelse { std.log.err("Got empty event, terminating", .{}); return ExitCode.not_ok; }; defer ev.release(); switch (ev.internal.payload) { .connection_error => |err_ev| { std.log.err("Failed to read event: {s}", .{@tagName(err_ev.code)}); return ExitCode.not_ok; }, .connected => { break; }, else => { continue; }, } } const loop_thread = std.Thread.spawn(.{}, consumeEvent, .{conn}) catch |err| { std.log.err("Failed to spawn message read thread: {s}", .{@errorName(err)}); return ExitCode.not_ok; }; defer { conn.disconnect(); loop_thread.join(); } const browse_result = conn.requestBrowse(.albums, null, null, false) orelse { std.log.err("Failed to browse albums: OutOfMemory", .{}); return ExitCode.out_of_memory; }; defer browse_result.release(); if (browse_result.code != .ok) { std.log.err("Failed to browse albums: {s}", .{@tagName(browse_result.code)}); return if (browse_result.code == .out_of_memory) ExitCode.out_of_memory else ExitCode.not_ok; } if (browse_result.action != .list) { std.log.err("Expected `list` response, found `{s}`", .{@tagName(browse_result.action)}); return ExitCode.not_ok; } const list = browse_result.internal.payload.list; const stdout = std.io.getStdOut().writer(); switch (res.args.format orelse .text) { .text => { for (list.items_ptr[0..list.items_len]) |item| { std.fmt.format( stdout, "Title=\"{s}\" Artist=\"{s}\"\n", .{ item.title, item.subtitle orelse "" }, ) catch |err| { std.log.err("Failed to write to stdout: {s}", .{@errorName(err)}); return ExitCode.stdout_write_failed; }; } }, .tsv => { if (res.args.header > 0) { stdout.writeAll("Title\tArtist\n") catch |err| { std.log.err("Failed to write to stdout: {s}", .{@errorName(err)}); return ExitCode.stdout_write_failed; }; } for (list.items_ptr[0..list.items_len]) |item| { stdout.print( "{s}\t{s}\n", .{ item.title, item.subtitle orelse "" }, ) catch |err| { std.log.err("Failed to write to stdout: {s}", .{@errorName(err)}); return ExitCode.stdout_write_failed; }; } }, .jsonl => { for (list.items_ptr[0..list.items_len]) |item| { stdout.print("{}\n", .{ std.json.fmt(ItemPrinter{ .item = item }, .{ .whitespace = .minified }), }) catch |err| { std.log.err("Failed to write to stdout: {s}", .{@errorName(err)}); return ExitCode.stdout_write_failed; }; } }, } return ExitCode.ok; } const ItemPrinter = struct { item: *const core.browse.Item, pub fn jsonStringify(self: *const ItemPrinter, jws: anytype) !void { try jws.beginObject(); try jws.objectField("title"); try jws.write(self.item.title); try jws.objectField("artist"); try jws.write(self.item.subtitle); if (self.item.image_key) |image_key| { try jws.objectField("image_key"); try jws.write(image_key); } try jws.endObject(); } }; fn consumeEvent(conn: *core.connection.Connection) void { const ev = conn.getEvent() orelse { return; }; defer ev.release(); }
-
-
cli/src/commands/connect.zig (deleted)
-
@@ -1,120 +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 clap = @import("clap"); const core = @import("core"); const State = @import("../State.zig"); const ExitCode = @import("../exit.zig").ExitCode; const parser = .{ .server_id = clap.parsers.string, }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\<server_id> \\ ); pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator) ExitCode { var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, ¶ms, parser, iter, .{ .diagnostic = &diag, .allocator = allocator, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return ExitCode.incorrect_usage; }; defer res.deinit(); if (res.args.help > 0) { clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.ok; } const server_id: []const u8 = res.positionals[0] orelse { std.log.err("server_id is required", .{}); clap.help(std.io.getStdErr().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.incorrect_usage; }; const server_id_z = allocator.dupeZ(u8, server_id) catch { std.log.err("Unable to allocate memory for server ID (Out of memory)", .{}); return ExitCode.out_of_memory; }; defer allocator.free(server_id_z); const find_result = core.discovery.find(server_id_z.ptr) orelse { std.log.err("Unable to get server info: Out of memory", .{}); return ExitCode.out_of_memory; }; defer find_result.release(); if (find_result.code != .ok) { std.log.err("Failed to get server info: {s}", .{@tagName(find_result.code)}); return ExitCode.not_ok; } if (find_result.servers_len == 0) { std.log.err("Server not found for ID={s}", .{server_id}); return ExitCode.not_ok; } const server = find_result.servers_ptr[0]; const conn = core.connection.Connection.make(server, null) orelse { std.log.err("Unable to connect: Out of memory", .{}); return ExitCode.out_of_memory; }; defer conn.release(); while (true) { const ev = conn.getEvent() orelse { std.log.err("Got empty event, terminating", .{}); return ExitCode.not_ok; }; defer ev.release(); switch (ev.internal.payload) { .connection_error => |err_ev| { std.log.err("Failed to read event: {s}", .{@tagName(err_ev.code)}); return ExitCode.not_ok; }, .connected => |conn_ev| { const state = State{ .connection = .{ .server_id = server_id, .token = std.mem.span(conn_ev.token), }, }; state.write() catch |err| { std.log.err("Failed to write state file: {s}", .{@errorName(err)}); return ExitCode.not_ok; }; std.log.info("Connected to {s}", .{server.name}); return ExitCode.ok; }, else => {}, } } }
-
-
cli/src/commands/image.zig (deleted)
-
@@ -1,172 +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 clap = @import("clap"); const core = @import("core"); const connection = @import("../connection.zig"); const State = @import("../State.zig"); const ExitCode = @import("../exit.zig").ExitCode; const parser = .{ .format = clap.parsers.enumeration(core.image.ContentType), .px = clap.parsers.int(usize, 10), .scaling = clap.parsers.enumeration(core.image.ScalingMethod), .image_key = clap.parsers.string, }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\-f, --format <format> Image format. \\Available values are: \\* jpeg \\* png \\-s, --scaling <scaling> Scaling method to use. \\Available values are: \\* fit \\* fill \\* stretch \\-w, --width <px> Required when --scaling is set. \\-h, --height <px> Required when --scaling is set. \\<image_key> \\ ); pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator) ExitCode { var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, ¶ms, parser, iter, .{ .diagnostic = &diag, .allocator = allocator, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return ExitCode.incorrect_usage; }; defer res.deinit(); if (res.args.help > 0) { clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.ok; } const image_key: []const u8 = res.positionals[0] orelse { std.log.err("image_key is required", .{}); clap.help(std.io.getStdErr().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.incorrect_usage; }; const image_key_z = allocator.dupeZ(u8, image_key) catch { std.log.err("Unable to allocate memory for image key (Out of memory)", .{}); return ExitCode.out_of_memory; }; defer allocator.free(image_key_z); const opts = core.image.GetOptions.makeRetained() orelse { std.log.err("Unable to allocate options struct (Out of memory)", .{}); return ExitCode.out_of_memory; }; defer opts.release(); if (res.args.format) |format| { opts.setContentType(format); } if (res.args.scaling) |scaling| { const width = res.args.width orelse { std.log.err("--width is required when --scaling is set", .{}); return ExitCode.incorrect_usage; }; const height = res.args.height orelse { std.log.err("--height is required when --scaling is set", .{}); return ExitCode.incorrect_usage; }; opts.setSize(scaling, width, height); } const conn = connection.make(allocator) catch |err| { std.log.err("Unable to establish a connection: {s}", .{@errorName(err)}); return switch (err) { error.OutOfMemory => ExitCode.out_of_memory, else => ExitCode.not_ok, }; }; defer conn.release(); while (true) { const ev = conn.getEvent() orelse { std.log.err("Got empty event, terminating", .{}); return ExitCode.not_ok; }; defer ev.release(); switch (ev.internal.payload) { .connection_error => |err_ev| { std.log.err("Failed to read event: {s}", .{@tagName(err_ev.code)}); return ExitCode.not_ok; }, .connected => { break; }, else => { continue; }, } } const loop_thread = std.Thread.spawn(.{}, consumeEvent, .{conn}) catch |err| { std.log.err("Failed to spawn message read thread: {s}", .{@errorName(err)}); return ExitCode.not_ok; }; defer { conn.disconnect(); loop_thread.join(); } const result = conn.getImage(image_key_z.ptr, opts) orelse { std.log.err("Failed to get image: OutOfMemory", .{}); return ExitCode.out_of_memory; }; defer result.release(); if (result.code != .ok) { std.log.err("Failed to get image: {s}", .{@tagName(result.code)}); return if (result.code == .out_of_memory) ExitCode.out_of_memory else ExitCode.not_ok; } const data = result.image orelse { std.log.err("get_image returned ok result, but image field is null", .{}); return ExitCode.not_ok; }; std.io.getStdOut().writeAll(data.data_ptr[0..data.data_len]) catch |err| { std.log.err("Failed to write to stdouit: {s}", .{@errorName(err)}); return ExitCode.stdout_write_failed; }; return ExitCode.ok; } fn consumeEvent(conn: *core.connection.Connection) void { const ev = conn.getEvent() orelse { return; }; defer ev.release(); }
-
-
cli/src/commands/playback.zig (deleted)
-
@@ -1,106 +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 clap = @import("clap"); const core = @import("core"); const connection = @import("../connection.zig"); const State = @import("../State.zig"); const ExitCode = @import("../exit.zig").ExitCode; const parser = .{ .string = clap.parsers.string, }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\-z, --zone <string> Name of zone to display playback state. \\ ); pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator) ExitCode { var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, ¶ms, parser, iter, .{ .diagnostic = &diag, .allocator = allocator, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return ExitCode.incorrect_usage; }; defer res.deinit(); if (res.args.help > 0) { clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.ok; } const zone_name: []const u8 = res.args.zone orelse { std.log.err("--zone is required", .{}); return ExitCode.incorrect_usage; }; const conn = connection.make(allocator) catch |err| { std.log.err("Unable to establish a connection: {s}", .{@errorName(err)}); return switch (err) { error.OutOfMemory => ExitCode.out_of_memory, else => ExitCode.not_ok, }; }; defer conn.release(); while (true) { const ev = conn.getEvent() orelse { std.log.err("Got empty event, terminating", .{}); return ExitCode.not_ok; }; defer ev.release(); switch (ev.internal.payload) { .connection_error => |err_ev| { std.log.err("Failed to read event: {s}", .{@tagName(err_ev.code)}); return ExitCode.not_ok; }, .connected => { conn.subscribeZones(); continue; }, .zone_list => |event| { const added_zones = event.added_zones_ptr[0..event.added_zones_len]; const changed_zones = event.changed_zones_ptr[0..event.changed_zones_len]; inline for (.{ added_zones, changed_zones }) |zones| { for (zones) |zone| { if (std.mem.eql(u8, std.mem.span(zone.name), zone_name)) { std.fmt.format( std.io.getStdOut().writer(), "{s}\n", .{@tagName(zone.playback)}, ) catch |err| { std.log.err("Failed to write to stdout: {s}", .{@errorName(err)}); return ExitCode.stdout_write_failed; }; return ExitCode.ok; } } } continue; }, } } }
-
-
cli/src/commands/server.zig (deleted)
-
@@ -1,67 +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 clap = @import("clap"); const ExitCode = @import("../exit.zig").ExitCode; const list = @import("./server/list.zig"); const Commands = enum { list, ls, }; const parser = .{ .command = clap.parsers.enumeration(Commands), }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\<command> \\Available commands: \\* list ... List available Roon Server on network (alias: ls) \\ ); pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator) ExitCode { var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, ¶ms, parser, iter, .{ .diagnostic = &diag, .allocator = allocator, .terminating_positional = 0, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return ExitCode.incorrect_usage; }; defer res.deinit(); if (res.args.help > 0) { clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.ok; } const command = res.positionals[0] orelse { clap.help(std.io.getStdErr().writer(), clap.Help, ¶ms, .{}) catch {}; return ExitCode.incorrect_usage; }; return switch (command) { .ls, .list => list.run(allocator, iter), }; }
-
-
cli/src/commands/server/list.zig (deleted)
-
@@ -1,141 +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 clap = @import("clap"); const core = @import("core"); const ExitCode = @import("../../exit.zig").ExitCode; const OutputFormat = enum { text, tsv, jsonl, }; const parser = .{ .format = clap.parsers.enumeration(OutputFormat), }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\--header Whether emits header row (only on --format=tsv) \\-f, --format <format> Output format. \\Available values are: \\* text ... Plain text format for human consumption. \\* tsv ... TSV (tab-separated values). \\* jsonl ... Newline delimited list of JSON documents. \\ ); pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator) ExitCode { var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, ¶ms, parser, iter, .{ .diagnostic = &diag, .allocator = allocator, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return ExitCode.incorrect_usage; }; defer res.deinit(); if (res.args.help > 0) { clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.ok; } const result = core.discovery.scan() orelse { std.log.err("Unable to scan: out of memory", .{}); return ExitCode.out_of_memory; }; defer result.release(); switch (result.code) { .ok => {}, .out_of_memory => { std.log.err("Failed to scan: out of memory", .{}); return ExitCode.out_of_memory; }, else => |code| { std.log.err("Failed to scan: {s}", .{@tagName(code)}); return ExitCode.not_ok; }, } const servers = result.servers_ptr[0..result.servers_len]; if (servers.len == 0) { std.log.info("No servers found.", .{}); return ExitCode.ok; } const stdout = std.io.getStdOut().writer(); switch (res.args.format orelse .text) { .text => { for (servers) |server| { stdout.print("ID={s} IP={} VERSION=\"{s}\"\n", .{ server.id, server.internal.address, server.version, }) catch { return ExitCode.stdout_write_failed; }; } }, .tsv => { if (res.args.header > 0) { stdout.writeAll("ID\tName\tIP address\tVersion\n") catch { return ExitCode.stdout_write_failed; }; } for (servers) |server| { const name = allocator.dupe(u8, std.mem.span(server.name)) catch { return ExitCode.out_of_memory; }; std.mem.replaceScalar(u8, name, '\t', ' '); const version = allocator.dupe(u8, std.mem.span(server.version)) catch { return ExitCode.out_of_memory; }; std.mem.replaceScalar(u8, version, '\t', ' '); stdout.print("{s}\t{s}\t{}\t{s}\n", .{ server.id, name, // Only CLI needs this... handling POSIX addr is messy. server.internal.address, version, }) catch { return ExitCode.stdout_write_failed; }; } }, .jsonl => { for (servers) |server| { stdout.print("{}\n", .{ std.json.fmt(server, .{ .whitespace = .minified }), }) catch { return ExitCode.stdout_write_failed; }; } }, } return ExitCode.ok; }
-
-
cli/src/connection.zig (deleted)
-
@@ -1,80 +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 core = @import("core"); const State = @import("./State.zig"); pub const MakeError = error{ NoSavedConnection, FailedToFindServer, ServerNotFound, }; pub fn make(allocator: std.mem.Allocator) !*core.connection.Connection { const server_id, const saved_token = state: { const state = State.read(allocator) catch |err| { std.log.err("Failed to read state file: {s}", .{@errorName(err)}); return err; }; defer state.deinit(); const conn = state.value.connection orelse { std.log.err("No previous connection: connect to server first", .{}); return MakeError.NoSavedConnection; }; const server_id = allocator.dupeZ(u8, conn.server_id) catch { std.log.err("Failed to restore server ID from state file: Out of memory", .{}); return std.mem.Allocator.Error.OutOfMemory; }; errdefer allocator.free(server_id); const saved_token = allocator.dupeZ(u8, conn.token) catch { std.log.err("Failed to restore token from state file: Out of memory", .{}); return std.mem.Allocator.Error.OutOfMemory; }; break :state .{ server_id, saved_token }; }; defer allocator.free(server_id); defer allocator.free(saved_token); const find_result = core.discovery.find(server_id.ptr) orelse { std.log.err("Unable to get server info: Out of memory", .{}); return std.mem.Allocator.Error.OutOfMemory; }; defer find_result.release(); if (find_result.code != .ok) { std.log.err("Failed to get server info: {s}", .{@tagName(find_result.code)}); return MakeError.FailedToFindServer; } if (find_result.servers_len == 0) { std.log.err("Server not found for ID={s}", .{server_id}); return MakeError.ServerNotFound; } const server = find_result.servers_ptr[0]; return core.connection.Connection.make(server, saved_token.ptr) orelse { std.log.err("Unable to connect: Out of memory", .{}); return std.mem.Allocator.Error.OutOfMemory; }; }
-
-
cli/src/exit.zig (deleted)
-
@@ -1,33 +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 ExitCode = enum(u8) { ok = 0, /// Generic error code. Something prevented process from running successfully. not_ok = 1, /// Unknown flags, invalid option types, etc. incorrect_usage = 2, /// Out of memory, or a NULL pointer passed to a function due to bug. out_of_memory = 3, /// Unable to write command output to stdout. stdout_write_failed = 4, };
-
-
cli/src/main.zig (deleted)
-
@@ -1,130 +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 clap = @import("clap"); const core = @import("core"); const ExitCode = @import("./exit.zig").ExitCode; const albums = @import("./commands/albums.zig"); const connect = @import("./commands/connect.zig"); const image = @import("./commands/image.zig"); const playback = @import("./commands/playback.zig"); const server = @import("./commands/server.zig"); const version = "0.0.0"; pub const std_options = std.Options{ .log_level = .debug, .logFn = log, }; var log_level: std.log.Level = .debug; pub fn log( comptime level: std.log.Level, comptime scope: @Type(.enum_literal), comptime format: []const u8, args: anytype, ) void { if (@intFromEnum(level) <= @intFromEnum(log_level)) { std.log.defaultLog(level, scope, format, args); } } const Commands = enum { connect, albums, image, playback, server, version, }; const global_parser = .{ .command = clap.parsers.enumeration(Commands), .id = clap.parsers.string, .path = clap.parsers.string, .level = clap.parsers.enumeration(std.log.Level), }; const global_params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\-l, --log-level <level> Log level to output. \\-v, --verbose Enables debug logging. \\<command> \\Available commands: \\* version ... Prints version to stdout and exits. \\* albums ... List albums in library. \\* image ... Download image. \\* playback ... Display playback state of a zone. \\* server ... Lists or gets information of Roon Server on network \\* connect ... Connects to Roon server and save state to a file. \\ ); pub fn main() !u8 { var debug_allocator = std.heap.DebugAllocator(.{ .safety = true }){ .backing_allocator = std.heap.c_allocator, }; const allocator = debug_allocator.allocator(); defer { _ = debug_allocator.deinit(); } var iter = try std.process.ArgIterator.initWithAllocator(allocator); defer iter.deinit(); // Skip process name. _ = iter.next(); var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, &global_params, global_parser, &iter, .{ .diagnostic = &diag, .allocator = allocator, .terminating_positional = 0, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return @intFromEnum(ExitCode.incorrect_usage); }; defer res.deinit(); log_level = res.args.@"log-level" orelse .info; if (res.args.help > 0) { clap.help(std.io.getStdOut().writer(), clap.Help, &global_params, .{}) catch {}; return @intFromEnum(ExitCode.ok); } const command = res.positionals[0] orelse { clap.help(std.io.getStdErr().writer(), clap.Help, &global_params, .{}) catch {}; return @intFromEnum(ExitCode.incorrect_usage); }; switch (command) { .albums => return @intFromEnum(albums.run(allocator, &iter)), .image => return @intFromEnum(image.run(allocator, &iter)), .playback => return @intFromEnum(playback.run(allocator, &iter)), .server => return @intFromEnum(server.run(allocator, &iter)), .version => { try std.fmt.format(std.io.getStdOut().writer(), "{s}\n", .{version}); return @intFromEnum(ExitCode.ok); }, .connect => return @intFromEnum(connect.run(allocator, &iter)), } }
-
-
core/README.md (deleted)
-
@@ -1,145 +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 --> # core Clients use this module and renders state struct managed by this. Every function is blocking, so client is responsible for making them asynchronous. ## Code Styles This section describes module internal rules for writing code. ### Struct creation function A function that creates `struct Foo`, - should be named `init` - should return a value (`Foo`), rather than a pointer of `Foo` (`*Foo`) - can return an error union This is what many Zig projects, including `std`, uses. Let the caller decide where to store. ```zig const std = @import("std"); pub const Foo = struct { bar: i32, pub fn init() Foo { return .{ bar = 0 }; } } ``` ### Struct creation and allocation function A function that allocates and initializes `struct Foo`, - should be named `make` - should return a pointer (`*Foo`) - should NOT take an allocator - can return an error union ```zig const std = @import("std"); pub const Foo = extern struct { const allocator = std.heap.c_allocator; bar: i32, pub fn make() callconv(.C) std.mem.Allocator.Error!*Foo { const dst = try allocator.create(Foo); dst.* = .{ .bar = 0, }; return dst; } } comptime { @export(&Foo.make, .{ .name = "plac_foo_make" }); } ``` ### Exported struct internal Exported structs' initial field must be a pointer to an internal struct: ```c // plac.h typedef struct { void *__pri; uint32_t bar; } plac_foo; ``` ```zig // foo.zig pub const Foo = extern struct { internal: *Internal, bar: u32, const Internal = struct { baz: u32, }; }; ``` Non-ABI stable fields (e.g. slices, reference counter) and internal fields should go inside `internal` field. ### Function parameters Zig team has a plan to drop automatic pass-by-reference conversion for function parameters. To minimize the cost of future migration, do not annotate a parameter with value type if pass-by-value is not desirable. For this reason, struct methods should use pointer type for `self` parameter. ```zig pub const Foo = struct { // OK pub fn bar(self: *Foo): void {} // OK pub fn bar(self: *const Foo): void {} // NG pub fn bar(self: Foo): void {} } ``` ### Exported functions When an exported function takes a pointer, it should be an optional pointer rather than regular pointer, to guard against accidentally passing NULL pointer. ```zig pub const Foo = extern struct { pub fn bar(self: *const Foo) void { // ... } } export fn do_something(foo_ptr: ?*const Foo) void { const foo = foo_ptr orelse return; foo.bar(); } ``` If the function returns result code, define a code for receiving a NULL pointer. If the function returns a pointer, return a NULL pointer and document about it in header file.
-
-
core/build.zig (deleted)
-
@@ -1,136 +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 //! This file defines how `zig build` command behaves. //! Run `zig build --help` for available subcommands and options. //! //! Learn more at //! https://ziglang.org/learn/build-system/ const std = @import("std"); const RoonExtension = struct { id: []const u8, name: []const u8, version: []const u8, }; const ModuleOptions = struct { freelog: bool, extension: RoonExtension, }; pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const opts = ModuleOptions{ .freelog = b.option(bool, "freelog", "Enable logging memory releases") orelse false, .extension = .{ .id = b.option([]const u8, "extension-id", "Roon extension ID") orelse "jp.pocka.plac", .name = b.option([]const u8, "extension-name", "Roon extension name") orelse "Plac", .version = b.option([]const u8, "extension-version", "Roon extension version") orelse "0.0.0-dev", }, }; // Zig module { const mod = b.addModule("core", .{ .root_source_file = b.path("src/main.zig"), }); setupModule(b, mod, opts); } // Test { const t = b.addTest(.{ .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, }); const run = b.addRunArtifact(t); const step = b.step("test", "Run unit tests"); step.dependOn(&run.step); } // Static library for GLib { const linkage = b.option( std.builtin.LinkMode, "linkage", "Link mode of the generated library file", ) orelse .static; const lib = b.addLibrary(.{ .name = "plac_glib", .linkage = linkage, .root_module = b.createModule(.{ .root_source_file = b.path("src/main.glib.zig"), .target = target, .optimize = optimize, }), }); setupModule(b, lib.root_module, opts); lib.linkLibC(); lib.linkSystemLibrary2("glib-2.0", .{ .preferred_link_mode = .dynamic }); lib.installHeader(b.path("src/plac.h"), "plac.h"); lib.installHeader(b.path("src/plac.vapi"), "plac.vapi"); b.installArtifact(lib); const artifact = b.addInstallArtifact(lib, .{}); const step = b.step("glib", "Build library for GLib application"); step.dependOn(&artifact.step); b.default_step.dependOn(step); } } fn setupModule(b: *std.Build, mod: *std.Build.Module, opts: ModuleOptions) void { const sood = sood: { const dep = b.dependency("sood", .{}); break :sood dep.module("sood"); }; const moo = moo: { const dep = b.dependency("libmoo", .{}); break :moo dep.module("moo"); }; const websocket = websocket: { const dep = b.dependency("websocket", .{}); break :websocket dep.module("websocket"); }; mod.addImport("sood", sood); mod.addImport("moo", moo); mod.addImport("websocket", websocket); const options = b.addOptions(); options.addOption(bool, "freelog", opts.freelog); options.addOption(RoonExtension, "extension", opts.extension); mod.addOptions("config", options); }
-
-
core/build.zig.zon (deleted)
-
@@ -1,40 +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 .{ .name = .plac_core, .version = "0.0.0", .fingerprint = 0xc2c55a635cc4644d, .minimum_zig_version = "0.14.0", .dependencies = .{ .sood = .{ .url = "https://git.pocka.jp/libsood.git/archive/8080245c2696cc6404b0628e5c3eb363cb80014f.tar.gz", .hash = "sood-0.0.0-A_jj-7ITAQAPlaaR2AHFXwPvBWzNBCCPTT--OCHnRQ_i", }, .libmoo = .{ .url = "https://git.pocka.jp/libmoo.git/archive/2f073dbe0e73f46e0832e1a2f0501969a929c1f3.tar.gz", .hash = "libmoo-0.0.0-HVqw0kUaAQBEDpkiC5_DX2Hh1ZTZvQ62nFL9IfuVxqdS", }, .websocket = .{ .url = "https://github.com/karlseguin/websocket.zig/archive/c1c53b062eab871b95b70409daadfd6ac3d6df61.tar.gz", .hash = "websocket-0.1.0-ZPISdYBIAwB1yO6AFDHRHLaZSmpdh4Bz4dCmaQUqNNWh", }, }, .paths = .{ "build.zig", "build.zig.zon", "src/", }, }
-
-
core/nix/deps.nix (deleted)
-
@@ -1,38 +0,0 @@# generated by zon2nix (https://github.com/nix-community/zon2nix) { linkFarm, fetchzip, fetchgit, }: linkFarm "zig-packages" [ { name = "clap-0.10.0-oBajB434AQBDh-Ei3YtoKIRxZacVPF1iSwp3IX_ZB8f0"; path = fetchzip { url = "https://github.com/Hejsil/zig-clap/archive/refs/tags/0.10.0.tar.gz"; hash = "sha256-leXnA97ITdvmBhD2YESLBZAKjBg+G4R/+PPPRslz/ec="; }; } { name = "libmoo-0.0.0-HVqw0kUaAQBEDpkiC5_DX2Hh1ZTZvQ62nFL9IfuVxqdS"; path = fetchzip { url = "https://git.pocka.jp/libmoo.git/archive/2f073dbe0e73f46e0832e1a2f0501969a929c1f3.tar.gz"; hash = "sha256-kdbdC99TgSFDJcOePplpby0X0q2/Wm9wgHWSVDbpBNI="; }; } { name = "sood-0.0.0-A_jj-7ITAQAPlaaR2AHFXwPvBWzNBCCPTT--OCHnRQ_i"; path = fetchzip { url = "https://git.pocka.jp/libsood.git/archive/8080245c2696cc6404b0628e5c3eb363cb80014f.tar.gz"; hash = "sha256-ikHN1tvw/zNsSYgSNWVembjJogK1aSjmeCnN8tRh/dc="; }; } { name = "websocket-0.1.0-ZPISdYBIAwB1yO6AFDHRHLaZSmpdh4Bz4dCmaQUqNNWh"; path = fetchzip { url = "https://github.com/karlseguin/websocket.zig/archive/c1c53b062eab871b95b70409daadfd6ac3d6df61.tar.gz"; hash = "sha256-/2RBC2/LxDMzrM+57WU8Oshf7P15qI8ykT7MFmT3O4M="; }; } ]
-
-
core/nix/deps.nix.license (deleted)
-
@@ -1,20 +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 --- "deps.nix" is autogenerated file. Applying same copyright and license for simplicity and consistency.
-
-
core/nix/package.nix (deleted)
-
@@ -1,33 +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 { stdenvNoCC }: stdenvNoCC.mkDerivation { pname = "plac"; version = "0.0.0"; src = ../.; phases = [ "unpackPhase" "installPhase" ]; installPhase = '' mkdir -p $out cp $src/build.zig $out cp $src/build.zig.zon $out cp -r $src/src $out ''; }
-
-
core/src/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; } }
-
-
core/src/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(); }
-
-
core/src/connection.zig (deleted)
-
@@ -1,1626 +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 ImageDownloader = image.Downloader(.{}); 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, image_downloads: ImageDownloader, 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, .image_downloads = 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.image_downloads = ImageDownloader.init(self.tsa.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.image_downloads.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 getImage( ptr: ?*Connection, image_key_ptr: [*:0]const u8, opts_ptr: ?*image.GetOptions, ) callconv(.C) ?*image.GetResult { 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; }; return self.internal.image_downloads.download(self.internal.tsa.allocator(), &req, self.internal.server) catch { std.log.err("Out of memory during image download", .{}); return image.GetResult.makeRetainedError(.out_of_memory) 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(&getImage, .{ .name = std.fmt.comptimePrint("{s}_get_image", .{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(); }
-
-
core/src/discovery.zig (deleted)
-
@@ -1,651 +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; const udp_dst = std.net.Address.initIp4( sood.discovery.multicast_ipv4_address, sood.discovery.udp_port, ); const udp_send_tries = 4; const udp_receive_window_ms = 1300; 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; } 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); } } /// For CLI pub fn jsonStringify(self: *const Server, jws: anytype) !void { try jws.beginObject(); try jws.objectField("unique_id"); try jws.write(std.mem.span(self.id)); try jws.objectField("name"); try jws.write(std.mem.span(self.name)); try jws.objectField("version"); try jws.write(std.mem.span(self.version)); try jws.objectField("address"); try jws.print("\"{}\"", .{self.internal.address}); try jws.endObject(); } 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 ScanResult = extern struct { const cname = "plac_discovery_scan_result"; const allocator = std.heap.c_allocator; internal: *Internal, servers_ptr: [*]*Server, servers_len: usize, code: Code = .unknown, pub const Code = enum(c_int) { ok = 0, unknown = 1, network_unavailable = 2, socket_permission_denied = 3, socket_error = 4, out_of_memory = 5, }; pub const Internal = struct { arc: Arc = .{}, }; pub fn make() !*ScanResult { const self = try allocator.create(ScanResult); errdefer allocator.destroy(self); const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); const servers: []*Server = &.{}; internal.* = .{}; self.* = .{ .internal = internal, .servers_ptr = servers.ptr, .servers_len = servers.len, }; return self; } pub fn setServers(self: *ScanResult, input: *const std.StringHashMap(*Server)) !void { const servers = try allocator.alloc(*Server, input.count()); var i: usize = 0; var iter = input.valueIterator(); while (iter.next()) |server| { std.log.debug("Found server ({s})", .{server.*.name}); servers[i] = server.*.retain(); i += 1; } self.servers_ptr = servers.ptr; self.servers_len = servers.len; } pub fn initFindResult(self: *ScanResult, found: *Server) std.mem.Allocator.Error!void { const servers = try allocator.alloc(*Server, 1); errdefer allocator.free(servers); servers[0] = found.retain(); self.servers_ptr = servers.ptr; self.servers_len = 1; self.code = .ok; } pub fn retain(ptr: ?*ScanResult) callconv(.C) *ScanResult { 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: ?*ScanResult) 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 servers = self.servers_ptr[0..self.servers_len]; for (servers) |server| { server.release(); } allocator.free(servers); 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 fn scan() callconv(.C) ?*ScanResult { const result = ScanResult.make() catch { return null; }; var arena = std.heap.ArenaAllocator.init(std.heap.c_allocator); defer arena.deinit(); scanInternal(arena.allocator(), result) catch |err| { result.code = switch (err) { error.OutOfMemory => .out_of_memory, ScanError.NetworkUnavailable => .network_unavailable, ScanError.SocketPermissionDenied => .socket_permission_denied, ScanError.SocketError => .socket_error, }; }; return result.retain(); } pub fn find(server_id: [*:0]const u8) callconv(.C) ?*ScanResult { const result = ScanResult.make() catch { return null; }; const id = std.mem.span(server_id); findInternal(id, result) catch |err| { result.code = switch (err) { error.OutOfMemory => .out_of_memory, ScanError.NetworkUnavailable => .network_unavailable, ScanError.SocketPermissionDenied => .socket_permission_denied, ScanError.SocketError => .socket_error, }; }; return result.retain(); } pub fn resolve(server_id: [*:0]const u8, ip_addr: [*:0]const u8, http_port: u16) callconv(.C) ?*ScanResult { var arena = std.heap.ArenaAllocator.init(std.heap.c_allocator); defer arena.deinit(); const result = ScanResult.make() catch { return null; }; const id = std.mem.span(server_id); const addr = std.mem.span(ip_addr); resolveInternal(arena.allocator(), id, addr, http_port, result) catch |err| { result.code = switch (err) { error.OutOfMemory => .out_of_memory, else => .unknown, }; }; return result.retain(); } const ScanError = error{ NetworkUnavailable, SocketPermissionDenied, SocketError, } || std.mem.Allocator.Error; fn scanInternal(allocator: std.mem.Allocator, result: *ScanResult) !void { const sockfd = try createSocket(); defer std.posix.close(sockfd); var servers = std.StringHashMap(*Server).init(allocator); errdefer servers.deinit(); for (0..udp_send_tries) |_| { try sendDiscoveryQuery(sockfd); try broadcastDiscoveryQuery(sockfd); while (true) { std.log.debug("Waiting for UDP message...", .{}); // Discovery response from servers usually fits under 300 bytes. // Extra bytes for safety. var received: [512]u8 = undefined; var src: std.net.Address = undefined; var src_len: std.posix.socklen_t = udp_dst.getOsSockLen(); const size = std.posix.recvfrom( sockfd, &received, 0, &src.any, &src_len, ) catch |err| switch (err) { std.posix.RecvFromError.WouldBlock => { std.log.debug("UDP read timeout.", .{}); break; }, std.posix.RecvFromError.MessageTooBig => { std.log.warn("Unable to read UDP message (message too big)", .{}); continue; }, else => return ScanError.SocketError, }; std.log.debug("Got UDP message.", .{}); const response = sood.discovery.Response.parse(received[0..size]) catch |err| { std.log.warn( "Unable to parse received UDP message as SOOD message: {s}", .{@errorName(err)}, ); // Non-SOOD message. Unlikely but technically possible. continue; }; const stale = servers.getPtr(response.unique_id); defer if (stale) |server| { server.*.release(); }; var server = try Server.make(&response, &src); errdefer server.release(); try servers.put(std.mem.span(server.id), server); } } try result.setServers(&servers); result.code = .ok; } fn findInternal(server_id: []const u8, result: *ScanResult) !void { const sockfd = try createSocket(); defer std.posix.close(sockfd); for (0..udp_send_tries) |_| { try sendDiscoveryQuery(sockfd); try broadcastDiscoveryQuery(sockfd); while (true) { std.log.debug("Waiting for UDP message...", .{}); // Discovery response from servers usually fits under 300 bytes. // Extra bytes for safety. var received: [512]u8 = undefined; var src: std.net.Address = undefined; var src_len: std.posix.socklen_t = udp_dst.getOsSockLen(); const size = std.posix.recvfrom( sockfd, &received, 0, &src.any, &src_len, ) catch |err| switch (err) { std.posix.RecvFromError.WouldBlock => { std.log.debug("UDP read timeout.", .{}); break; }, std.posix.RecvFromError.MessageTooBig => { std.log.warn("Unable to read UDP message (message too big)", .{}); continue; }, else => return ScanError.SocketError, }; std.log.debug("Got UDP message.", .{}); const response = sood.discovery.Response.parse(received[0..size]) catch |err| { std.log.warn( "Unable to parse received UDP message as SOOD message: {s}", .{@errorName(err)}, ); // Non-SOOD message. Unlikely but technically possible. continue; }; if (!std.mem.eql(u8, response.unique_id, server_id)) { continue; } var server = try Server.make(&response, &src); errdefer server.release(); try result.initFindResult(server); return; } } result.code = .ok; return; } fn resolveInternal( allocator: std.mem.Allocator, server_id: []const u8, ip_addr: []const u8, http_port: u16, result: *ScanResult, ) !void { std.log.debug("Establishing WebSocket connection to {s}:{d}...", .{ ip_addr, http_port, }); var ws = try websocket.Client.init(allocator, .{ .host = ip_addr, .port = http_port, .max_size = std.math.maxInt(u32), }); defer ws.deinit(); std.log.debug("Performing WebSocket handshake...", .{}); ws.handshake("/api", .{ .timeout_ms = 1_000 }) catch |err| { std.log.warn("Failed to perform WebSocket handshake, fallback to scan: {s}", .{@errorName(err)}); result.code = .ok; return; }; std.log.debug("Requesting server info...", .{}); { const req = try RegistryService.Info.request(allocator, 0); defer allocator.free(req); try ws.writeBin(req); } const msg = (try ws.read()) orelse { return error.ConnectionClosed; }; defer ws.done(msg); _, const header_ctx = try moo.Metadata.parse(msg.data); const resp = try RegistryService.Info.response(allocator, header_ctx, msg.data); defer resp.deinit(); if (!std.mem.eql(u8, server_id, resp.value.core_id)) { std.log.info( "Found Roon Server on saved IP address, got different server instance, fallback to scan: saved ID={s}, found ID={s}", .{ server_id, resp.value.core_id, }, ); result.code = .ok; return; } const addr = try std.net.Address.parseIp4(ip_addr, http_port); const sood_resp = sood.discovery.Response{ .display_version = resp.value.display_version, .name = resp.value.display_name, .unique_id = server_id, .http_port = http_port, }; var server = try Server.make(&sood_resp, &addr); errdefer server.release(); try result.initFindResult(server); return; } fn createSocket() !std.posix.socket_t { std.log.debug("Opening UDP socket...", .{}); const sockfd = std.posix.socket(std.posix.AF.INET, std.posix.SOCK.DGRAM, 0) catch |err| { return switch (err) { std.posix.SocketError.PermissionDenied => ScanError.SocketPermissionDenied, std.posix.SocketError.SystemResources => ScanError.OutOfMemory, else => ScanError.SocketError, }; }; errdefer std.posix.close(sockfd); std.posix.setsockopt( sockfd, std.posix.SOL.SOCKET, std.posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)), ) catch |err| return switch (err) { std.posix.SetSockOptError.PermissionDenied => ScanError.SocketPermissionDenied, std.posix.SetSockOptError.SystemResources => ScanError.OutOfMemory, else => ScanError.SocketError, }; const sec = comptime std.math.divFloor(u32, udp_receive_window_ms, 1_000) catch |err| { @compileError( std.fmt.comptimePrint( "Cannot divide udp_receive_window_ms ({d}) by 1,000: {s}", .{ udp_receive_window_ms, @errorName(err) }, ), ); }; const usec = comptime usec: { break :usec @min( std.math.maxInt(i32), 1_000 * (std.math.rem(u32, udp_receive_window_ms, 1_000) catch |err| { @compileError( std.fmt.comptimePrint( "Cannot get reminder of udp_receive_window_ms ({d}): {s}", .{ udp_receive_window_ms, @errorName(err) }, ), ); }), ); }; std.log.debug("Setting UDP read timeout to {d}ms ({d}sec, {d}usec)", .{ udp_receive_window_ms, sec, usec, }); const timeout = std.posix.timeval{ .sec = sec, .usec = usec }; std.posix.setsockopt( sockfd, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, &std.mem.toBytes(timeout), ) catch |err| return switch (err) { std.posix.SetSockOptError.PermissionDenied => ScanError.SocketPermissionDenied, std.posix.SetSockOptError.SystemResources => ScanError.OutOfMemory, else => ScanError.SocketError, }; return sockfd; } fn sendDiscoveryQuery(sockfd: std.posix.socket_t) !void { std.log.debug("Sending server discovery message to {}", .{udp_dst}); std.posix.setsockopt( sockfd, std.posix.SOL.SOCKET, std.posix.SO.BROADCAST, &std.mem.toBytes(@as(c_int, 0)), ) catch |err| return switch (err) { std.posix.SetSockOptError.PermissionDenied => ScanError.SocketPermissionDenied, std.posix.SetSockOptError.SystemResources => ScanError.OutOfMemory, else => ScanError.SocketError, }; _ = std.posix.sendto( sockfd, sood.discovery.Query.prebuilt, 0, &udp_dst.any, udp_dst.getOsSockLen(), ) catch |err| { std.log.err("Failed to send discovery message: {s}", .{@errorName(err)}); return switch (err) { std.posix.SendToError.NetworkSubsystemFailed, std.posix.SendToError.NetworkUnreachable, => ScanError.NetworkUnavailable, else => ScanError.SocketError, }; }; } fn broadcastDiscoveryQuery(sockfd: std.posix.socket_t) !void { std.log.debug("Broadcasting server discovery message", .{}); std.posix.setsockopt( sockfd, std.posix.SOL.SOCKET, std.posix.SO.BROADCAST, &std.mem.toBytes(@as(c_int, 1)), ) catch |err| return switch (err) { std.posix.SetSockOptError.PermissionDenied => { std.log.warn("No permission to broadcast UDP message, skipping", .{}); return; }, std.posix.SetSockOptError.SystemResources => ScanError.OutOfMemory, else => ScanError.SocketError, }; _ = std.posix.sendto( sockfd, sood.discovery.Query.prebuilt, 0, &udp_dst.any, udp_dst.getOsSockLen(), ) catch |err| { std.log.err("Failed to broadcast discovery message: {s}", .{@errorName(err)}); return switch (err) { std.posix.SendToError.NetworkSubsystemFailed, std.posix.SendToError.NetworkUnreachable, => ScanError.NetworkUnavailable, else => ScanError.SocketError, }; }; } pub fn export_capi() void { Server.export_capi(); ScanResult.export_capi(); @export(&scan, .{ .name = "plac_discovery_scan" }); @export(&find, .{ .name = "plac_discovery_find" }); @export(&resolve, .{ .name = "plac_discovery_resolve" }); }
-
-
core/src/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}, };
-
-
core/src/image.zig (deleted)
-
@@ -1,694 +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 GetResultCode = enum(c_int) { ok = 0, unknown_error = 1, out_of_memory = 2, unexpected_response = 3, socket_closed = 4, failed_to_send = 5, timeout = 6, }; 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 const Image = extern struct { const cname = "plac_image_image"; const allocator = std.heap.c_allocator; internal: *Internal, content_type: ContentType, data_ptr: [*]const u8, data_len: usize, const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const ImageService.Get.Response) std.mem.Allocator.Error!*@This() { const internal = try allocator.create(Internal); errdefer allocator.destroy(internal); internal.* = .{}; const data = try allocator.dupe(u8, src.data); errdefer allocator.free(data); const self = try allocator.create(@This()); errdefer allocator.destroy(self); self.* = .{ .internal = internal, .content_type = switch (src.content_type) { .jpeg => ContentType.jpeg, .png => ContentType.png, }, .data_ptr = data.ptr, .data_len = data.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); allocator.free(self.data_ptr[0..self.data_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 GetResult = extern struct { const cname = "plac_image_get_result"; const allocator = std.heap.c_allocator; internal: *Internal, code: GetResultCode = .ok, image: ?*Image = null, const Internal = struct { arc: Arc = .{}, }; pub fn make(src: *const ImageService.Get.Response) 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); const image = try Image.make(src); _ = image.retain(); self.* = .{ .internal = internal, .image = image, }; return self; } pub inline fn makeRetained(src: *const ImageService.Get.Response) std.mem.Allocator.Error!*@This() { const result = try make(src); return result.retain(); } pub fn makeError(code: GetResultCode) 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 inline fn makeRetainedError(code: GetResultCode) std.mem.Allocator.Error!*@This() { const result = try makeError(code); return result.retain(); } pub fn makeCached(image: *Image) 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, .image = image.retain(), }; return self; } pub inline fn makeCachedRetained(image: *Image) std.mem.Allocator.Error!*@This() { const result = try makeCached(image); return result.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); if (self.image) |image| { image.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 fn export_capi() void { Image.export_capi(); GetOptions.export_capi(); GetResult.export_capi(); } fn DoubleFifoCache(comptime T: type, short_size: usize, main_size: usize) type { return struct { pub const Data = T; pub const Entry = struct { hash: u64, data: *Data, accessed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), }; allocator: std.mem.Allocator, short: std.DoublyLinkedList(Entry) = .{}, main: std.DoublyLinkedList(Entry) = .{}, pub fn init(allocator: std.mem.Allocator) @This() { return .{ .allocator = allocator, }; } pub fn deinit(self: *@This()) void { while (self.short.pop()) |popped| { self.release(popped); } while (self.main.pop()) |popped| { self.release(popped); } } pub fn get(self: *@This(), hash: u64) ?*Data { var entry = self.short.first; while (entry) |e| : (entry = e.next) { if (e.data.hash == hash) { e.data.accessed.store(true, .release); return e.data.data; } } entry = self.main.first; while (entry) |e| : (entry = e.next) { if (e.data.hash == hash) { return e.data.data; } } return null; } fn release(self: *const @This(), node: *std.DoublyLinkedList(Entry).Node) void { if (@hasDecl(Data, "release")) { node.data.data.release(); } self.allocator.destroy(node); } fn evictMain(self: *@This()) void { if (self.main.len <= main_size) { return; } const popped = self.main.pop() orelse return; self.release(popped); } fn evictShort(self: *@This()) void { if (self.short.len <= short_size) { return; } const popped = self.short.pop() orelse return; if (!popped.data.accessed.load(.unordered)) { self.release(popped); return; } self.main.prepend(popped); } pub fn put(self: *@This(), hash: u64, data: *Data) std.mem.Allocator.Error!void { if (@hasDecl(Data, "retain")) { _ = data.retain(); } const node = try self.allocator.create(std.DoublyLinkedList(Entry).Node); node.* = .{ .data = .{ .data = data, .hash = hash, }, }; self.short.prepend(node); self.evictShort(); } }; } test DoubleFifoCache { const Cache = DoubleFifoCache(struct { n: u64, rc: usize = 0, pub fn retain(self: *@This()) void { self.rc += 1; } pub fn release(self: *@This()) void { self.rc -= 1; } }, 3, 10); var cache = Cache.init(std.testing.allocator); var n1 = Cache.Data{ .n = 1 }; try cache.put(1, &n1); var n2 = Cache.Data{ .n = 2 }; try cache.put(2, &n2); var n3 = Cache.Data{ .n = 3 }; try cache.put(3, &n3); try std.testing.expectEqual(2, cache.get(2).?.n); try std.testing.expectEqual(3, cache.get(3).?.n); try std.testing.expect(cache.get(4) == null); try std.testing.expectEqual(3, cache.short.len); try std.testing.expectEqual(0, cache.main.len); var n4 = Cache.Data{ .n = 4 }; try cache.put(4, &n4); try std.testing.expectEqual(4, cache.get(4).?.n); try std.testing.expectEqual(3, cache.short.len); try std.testing.expectEqual(0, cache.main.len); var n5 = Cache.Data{ .n = 5 }; try cache.put(5, &n5); try std.testing.expect(cache.get(1) == null); try std.testing.expectEqual(2, cache.get(2).?.n); try std.testing.expectEqual(3, cache.get(3).?.n); try std.testing.expectEqual(4, cache.get(4).?.n); try std.testing.expectEqual(5, cache.get(5).?.n); try std.testing.expectEqual(3, cache.short.len); try std.testing.expectEqual(1, cache.main.len); try std.testing.expectEqual(0, n1.rc); try std.testing.expectEqual(1, n2.rc); try std.testing.expectEqual(1, n3.rc); try std.testing.expectEqual(1, n4.rc); try std.testing.expectEqual(1, n5.rc); cache.deinit(); try std.testing.expectEqual(0, n1.rc); try std.testing.expectEqual(0, n2.rc); try std.testing.expectEqual(0, n3.rc); try std.testing.expectEqual(0, n4.rc); try std.testing.expectEqual(0, n5.rc); } pub const DownloaderOptions = struct { concurrent_download_limit: u5 = 10, short_cache_size: usize = 10, main_cache_size: usize = 50, }; pub fn Downloader(opts: DownloaderOptions) type { return struct { const Job = struct { ready: std.Thread.Condition = .{}, result: *GetResult = undefined, req_hash: u64, arc: Arc = .{}, }; const Cache = DoubleFifoCache(Image, opts.short_cache_size, opts.main_cache_size); // a bit is set (1) = available, otherwise occupied. // ex) truncating size to u8, concurrent download limit = 5 // 0b00011111 = Fully available // 0b00011110 = First item is occupied, second item is available // 0b00000000 = Fully occupied downloads: u32 = ~(@as(u32, std.math.maxInt(u32)) << opts.concurrent_download_limit), mutex: std.Thread.Mutex = .{}, cond: std.Thread.Condition = .{}, downloads_queue: std.DoublyLinkedList(Job) = .{}, queue_mutex: std.Thread.Mutex = .{}, http_client: std.http.Client, is_closed: bool = false, cache: Cache, pub fn init(allocator: std.mem.Allocator) @This() { return .{ .http_client = std.http.Client{ .allocator = allocator, }, .cache = Cache.init(allocator), }; } pub fn deinit(self: *@This()) void { self.is_closed = true; self.mutex.lock(); self.downloads = std.math.maxInt(u32); self.cond.signal(); self.mutex.unlock(); self.http_client.deinit(); self.cache.deinit(); } fn hash(url: []const u8) u64 { var hasher = std.hash.Wyhash.init(0); std.hash.autoHashStrat(&hasher, url, .Deep); return hasher.final(); } pub fn download( self: *@This(), allocator: std.mem.Allocator, req: *const ImageService.Get.Request, server: *Server, ) std.mem.Allocator.Error!*GetResult { _ = server.retain(); defer server.release(); const format = req.format orelse { std.log.err("Downloading image without specifying format is not currently supported", .{}); return try GetResult.makeRetainedError(.failed_to_send); }; const url = req.url([]const u8, allocator, server.internal.address) catch |err| { std.log.err("Unable to construct image download URL: {s}", .{@errorName(err)}); return try GetResult.makeRetainedError(.unknown_error); }; defer allocator.free(url); const req_hash = hash(url); if (self.cache.get(req_hash)) |cached| { std.log.debug("Using cached image for {s}", .{url}); return try GetResult.makeCachedRetained(cached); } { self.queue_mutex.lock(); defer self.queue_mutex.unlock(); var node = self.downloads_queue.first; while (node) |d| : (node = d.next) { if (d.data.req_hash == req_hash) { d.data.arc.ref(); defer if (d.data.arc.unref()) { self.downloads_queue.remove(d); allocator.destroy(d); }; d.data.ready.wait(&self.queue_mutex); return d.data.result.retain(); } } } const node = try allocator.create(std.DoublyLinkedList(Job).Node); node.* = .{ .data = .{ .req_hash = req_hash, }, }; node.data.arc.ref(); { self.queue_mutex.lock(); defer self.queue_mutex.unlock(); self.downloads_queue.prepend(node); } defer if (node.data.arc.unref()) { self.queue_mutex.lock(); defer self.queue_mutex.unlock(); self.downloads_queue.remove(node); allocator.destroy(node); }; const job_index: u5 = job_index: { self.mutex.lock(); defer self.mutex.unlock(); while (self.downloads == 0) { self.cond.wait(&self.mutex); } if (self.is_closed) { return try GetResult.makeRetainedError(.socket_closed); } const i = @min(@ctz(self.downloads), std.math.maxInt(u5)); self.downloads &= ~(@as(u32, 1) << i); break :job_index i; }; std.log.debug("Downloading image({d}) {s}", .{ job_index, url }); defer { self.mutex.lock(); defer self.mutex.unlock(); if (!self.is_closed) { self.downloads |= @as(u32, 1) << job_index; self.cond.signal(); } } var response = std.ArrayList(u8).init(allocator); defer response.deinit(); const result = self.http_client.fetch(.{ .location = .{ .url = url }, .method = .GET, .redirect_behavior = .not_allowed, .headers = .{ .content_type = .{ .override = if (format == .jpeg) "image/jpeg" else "image/png", }, }, .response_storage = .{ .dynamic = &response }, }) catch |err| { if (err == error.ConnectionTimedOut) { std.log.err("Image download timed out", .{}); return try GetResult.makeRetainedError(.timeout); } std.log.err("Failed to download image: {s}", .{@errorName(err)}); return try GetResult.makeRetainedError(.unknown_error); }; if (result.status != .ok) { std.log.err("Unexpected image download response: HTTP({d}) {s}", .{ @intFromEnum(result.status), @tagName(result.status), }); return try GetResult.makeRetainedError(.unexpected_response); } const moo_resp = ImageService.Get.Response{ // TODO: Set from parsed HTTP response header .content_type = format, .data = response.items, }; const get_result = try GetResult.make(&moo_resp); if (get_result.image) |image| { self.cache.put(req_hash, image) catch { std.log.warn("Unable to cache downloaded image due to out of memory error", .{}); }; } node.data.result = get_result; node.data.ready.broadcast(); return get_result.retain(); } }; }
-
-
core/src/log.zig (deleted)
-
@@ -1,26 +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 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}); } }
-
-
core/src/main.glib.zig (deleted)
-
@@ -1,59 +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 //! This file is an entrypoint for GLib integrated library, for GTK applications. const std = @import("std"); const main = @import("./main.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 { main.export_capi(); }
-
-
core/src/main.zig (deleted)
-
@@ -1,36 +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 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"); pub fn export_capi() void { 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"); }
-
-
core/src/plac.h (deleted)
-
@@ -1,447 +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*); // discovery.ScanResult.Code typedef enum { PLAC_DISCOVERY_SCAN_RESULT_OK = 0, PLAC_DISCOVERY_SCAN_RESULT_UNKNOWN = 1, PLAC_DISCOVERY_SCAN_RESULT_NETWORK_UNAVAILABLE = 2, PLAC_DISCOVERY_SCAN_RESULT_SOCKET_PERMISSION_DENIED = 3, PLAC_DISCOVERY_SCAN_RESULT_SOCKET_ERROR = 4, PLAC_DISCOVERY_SCAN_RESULT_OUT_OF_MEMORY = 5, } plac_discovery_scan_result_code; // discovery.ScanResult typedef struct { void *__pri; plac_discovery_server **servers_ptr; size_t servers_len; plac_discovery_scan_result_code code; } plac_discovery_scan_result; plac_discovery_scan_result *plac_discovery_scan_result_retain(plac_discovery_scan_result*); void plac_discovery_scan_result_release(plac_discovery_scan_result*); // discovery plac_discovery_scan_result *plac_discovery_scan(); plac_discovery_scan_result *plac_discovery_find(const char *server_id); plac_discovery_scan_result *plac_discovery_resolve( const char *server_id, 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.GetResultCode typedef enum { PLAC_IMAGE_GET_RESULT_OK = 0, PLAC_IMAGE_GET_RESULT_UNKNOWN_ERROR = 1, PLAC_IMAGE_GET_RESULT_OUT_OF_MEMORY = 2, PLAC_IMAGE_GET_RESULT_UNEXPECTED_RESPONSE = 3, PLAC_IMAGE_GET_RESULT_SOCKET_CLOSED = 4, PLAC_IMAGE_GET_RESULT_FAILED_TO_SEND = 5, PLAC_IMAGE_GET_RESULT_TIMEOUT = 6, } plac_image_get_result_code; // 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); // image.Image typedef struct { void *__pri; plac_image_content_type content_type; const uint8_t *data_ptr; size_t data_len; } plac_image_image; plac_image_image *plac_image_image_retain(plac_image_image*); void plac_image_image_release(plac_image_image*); // image.GetResult typedef struct { void *__pri; plac_image_get_result_code code; plac_image_image *image; } plac_image_get_result; plac_image_get_result *plac_image_get_result_retain(plac_image_get_result*); void plac_image_get_result_release(plac_image_get_result*); // 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); plac_image_get_result *plac_connection_get_image(plac_connection*, const char *image_key, plac_image_get_options*); const char *plac_connection_get_image_url(plac_connection*, const char *image_key, plac_image_get_options*); #endif
-
-
core/src/plac.modulemap (deleted)
-
@@ -1,19 +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 module PlacKit { umbrella header "plac.h" export * }
-
-
core/src/plac.vapi (deleted)
-
@@ -1,675 +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 string id; public string name; public string version; public string ip_addr; public uint16 http_port; } [CCode ( cname = "plac_discovery_scan_result_code", cprefix = "PLAC_DISCOVERY_SCAN_RESULT_", has_type_id = false )] public enum ScanResultCode { OK = 0, UNKNOWN = 1, NETWORK_UNAVAILABLE = 2, SOCKET_PERMISSION_DENIED = 3, SOCKET_ERROR = 4, OUT_OF_MEMORY = 5, } [CCode ( cname = "plac_discovery_scan_result", ref_function = "plac_discovery_scan_result_retain", unref_function = "plac_discovery_scan_result_release" )] [Compact] public class ScanResult { [CCode (cname = "plac_discovery_scan_result_retain")] public void @ref (); [CCode (cname = "plac_discovery_scan_result_release")] public void unref (); [CCode ( cname = "servers_ptr", array_length_cname = "servers_len", array_length_type = "size_t" )] public Server[] servers; public ScanResultCode code; } [CCode (cname = "plac_discovery_scan")] public ScanResult? scan(); [CCode (cname = "plac_discovery_find")] public ScanResult? find(string server_id); [CCode (cname = "plac_discovery_resolve")] public ScanResult? resolve(string server_id, string ip_addr, 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_image_get_result_code", cprefix = "PLAC_IMAGE_GET_RESULT_", has_type_id = false )] public enum GetResultCode { OK = 0, UNKNOWN_ERROR = 1, OUT_OF_MEMORY = 2, UNEXPECTED_RESPONSE = 3, SOCKET_CLOSED = 4, FAILED_TO_SEND = 5, TIMEOUT = 6, } [CCode ( cname = "plac_image_image", ref_function = "plac_image_image_retain", unref_function = "plac_image_image_release" )] [Compact] public class Image { public ContentType content_type; [CCode ( cname = "data_ptr", array_length_cname = "data_len", array_length_type = "size_t" )] public uint8[] data; } [CCode ( cname = "plac_image_get_result", ref_function = "plac_image_get_result_retain", unref_function = "plac_image_get_result_release" )] [Compact] public class GetResult { public GetResultCode code; public Image? image; } } [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")] public Image.GetResult? get_image(string image_key, Image.GetOptions options); [CCode (cname = "plac_connection_get_image_url")] public string? get_image_url(string image_key, Image.GetOptions options); } }
-
-
core/src/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, }); } }; };
-
-
core/src/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; }
-
-
core/src/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); }
-
-
core/src/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 .{}; } }; };
-
-
core/src/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, }); } }; };
-
-
core/src/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(); }
-
-
-
@@ -31,18 +31,6 @@ "exec": {"cwd": "${configDir}", "commands": [ { "exts": ["zig"], "command": "zig fmt --stdin", }, { "exts": ["zon"], "command": "zig fmt --zon --stdin", }, { "exts": ["vala"], "command": "uncrustify -c uncrustify.cfg -l VALA", }, { "exts": ["nix"], "command": "nixfmt --strict --width={{line_width}} --indent={{indent_width}}", },
-
-
-
@@ -15,7 +15,7 @@ ## SPDX-License-Identifier: Apache-2.0 { description = "Plac monorepo"; description = "Plac for Apple platform"; inputs = { nixpkgs = {
-
@@ -40,61 +40,20 @@ pkgs = nixpkgs.legacyPackages.${system};lib = pkgs.lib; in rec { packages = { gtk-adwaita = pkgs.callPackage ./gtk-adwaita/nix/package.nix { }; devShell = pkgs.mkShell { packages = with pkgs; [ # Code formatter # https://dprint.dev/ dprint update-deps = pkgs.writeShellApplication { name = "update-deps"; # Copyright and license linter based on SPDX # https://github.com/fsfe/reuse-tool reuse text = '' cd core ${pkgs.zon2nix}/bin/zon2nix > nix/deps.nix ''; }; }; devShell = pkgs.mkShell { buildInputs = [ (lib.lists.remove pkgs.zig.hook packages.gtk-adwaita.nativeBuildInputs) packages.gtk-adwaita.buildInputs # Official formatter for Nix code # https://hackage.haskell.org/package/nixfmt nixfmt-rfc-style ]; packages = with pkgs; [ # > Source Code Beautifier for C, C++, C#, ObjectiveC, D, Java, Pawn and VALA # https://uncrustify.sourceforge.net/ uncrustify # For text editors, optional. # > ZLS is a non-official implementation of the Language Server Protocol for Zig # https://github.com/zigtools/zls zls # For text editors, optional. # > Code Intelligence for Vala & Genie # https://github.com/vala-lang/vala-language-server vala-language-server # Code formatter # https://dprint.dev/ dprint # Copyright and license linter based on SPDX # https://github.com/fsfe/reuse-tool reuse # Official formatter for Nix code # https://hackage.haskell.org/package/nixfmt nixfmt-rfc-style ] # Non macOS packages (not supported, broken, etc.) ++ (lib.optionals (!stdenv.isDarwin) [ # For debugging, optional. # > Valgrind is an instrumentation framework for building dynamic analysis tools # https://valgrind.org/ valgrind ]); }; } );
-
-
gtk-adwaita/README.md (deleted)
-
@@ -1,51 +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 --> # Plac for GTK4 Plac's GTK4 application, adhering to Gnome Human Interface Guidline. ## Runtime Dependencies - gtk4 - libadwaita - librsvg - libgee ## Development Guide ### System Requirements If you installed Nix on your system and enabled Flake, run `nix develop` and every required dependencies will be installed. - Zig v0.14 - Vala 0.56 ### Running application ``` zig build run ``` ### Enable debug logging Add `Plac` to `G_MESSAGES_DEBUG` environment variable: ``` G_MESSAGES_DEBUG=Plac zig build run ```
-
-
gtk-adwaita/build.zig (deleted)
-
@@ -1,282 +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 //! This file defines how `zig build` command behaves. //! Run `zig build --help` for available subcommands and options. //! //! Learn more at //! https://ziglang.org/learn/build-system/ const std = @import("std"); const BuildError = error{ NonValaSourceInSourceListError, }; const app_name = "jp.pocka.plac.gtk-adwaita"; const glib_schemas_dir = "share/glib-2.0/schemas"; const glib_compiled_schema_name = "gschemas.compiled"; 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 compile_gschema = b.option(bool, "compile-gschema", "Compile gschema XML file for local run") orelse false; const core = core: { const dep = b.dependency("core", .{ .target = target, .optimize = optimize, .freelog = freelog, .@"extension-id" = @as([]const u8, "jp.pocka.plac.gtk-adwaita"), .@"extension-name" = @as([]const u8, "Plac GTK"), }); break :core dep.artifact("plac_glib"); }; // Vala source codes to compile. const vala_sources: [][]const u8 = vala_sources: { var list = std.ArrayList([]const u8).init(b.allocator); var dir = try std.fs.cwd().openDir("src", .{ .iterate = true }); defer dir.close(); var walker = try dir.walk(b.allocator); defer walker.deinit(); while (try walker.next()) |entry| { const ext = std.fs.path.extension(entry.basename); if (std.mem.eql(u8, ".vala", ext)) { try list.append(b.dupe(entry.path)); } } break :vala_sources try list.toOwnedSlice(); }; // System libraries to link. const system_libraries = [_][]const u8{ "gtk4", "libadwaita-1", // Neither GTK4 nor Libadwaita provides SVG rendering. We need to use librsvg for // that purpose, whilst undocumented. "librsvg-2.0", "gee-0.8", }; // A directory containing C source files compiled from Vala source code. // If you use this LazyPath in your artifact, the artifact automatically depends on // this Vala to C complication step. You don't have to manually `a.dependOn()`. const vala_cdir = vala: { const valac = b.addSystemCommand(&.{"valac"}); // Tell Vala compiler to emit C rather than compile using system C compiler. valac.addArg("--ccode"); // Vala's GTK binding incorrectly mark `Gtk.StyleContext` as deprecated, even though // its static method `add_provider_for_display` is the only future proof way to add // CSS. As Vala compiler has no CLI flag or magic comment to suppress specific use of // "deprecated" API, I resorted to suppress entire deprecation warnings. This has risk // of accidentally using deprecated API. It's still better than getting used to see // warnings on build and starting to ignore warnings. valac.addArg("--enable-deprecated"); // Vala compiler uses GResource XML to statically check attributes. valac.addArg("--gresources"); valac.addFileArg(b.path("data/gresource.xml")); valac.addArg("--vapidir"); valac.addDirectoryArg(core.getEmittedIncludeTree()); // Tell Vala what system libraries to use. Perhaps type checking things? for (system_libraries) |lib| { valac.addArgs(&.{ "--pkg", lib }); } valac.addArgs(&.{ "--pkg", "posix" }); valac.addArgs(&.{ "--pkg", "plac" }); // Tell Vala to emit C source files under the output directory. // The directory usually is Zig cache directory (like ".zig-cache/o/xxx"). // For example, when there is "src/Foo.vala", this step produces // ".zig-cache/o/hash-or-something-idk/vala/src/Foo.c" valac.addArg("--directory"); const dir = valac.addOutputDirectoryArg("vala"); // Let valac emit C source code without "src/" prefix. valac.addArg("--basedir"); valac.addDirectoryArg(b.path("src")); // Vala compiler takes a list of Vala source files, then process them all. for (vala_sources) |src| { valac.addFileArg(b.path(b.pathJoin(&.{ "src", src }))); } const t = b.addInstallDirectory(.{ .source_dir = dir, .install_dir = .{ .custom = "src" }, .install_subdir = "", }); const step = b.step("valac", "Compile C source code from Vala files for debugging purpose"); step.dependOn(&t.step); break :vala dir; }; const gresouce_c = gresource: { const compiler = b.addSystemCommand(&.{"glib-compile-resources"}); compiler.addArg("--sourcedir"); compiler.addDirectoryArg(b.path("data")); compiler.addFileArg(b.path("data/gresource.xml")); compiler.addArg("--target"); const built_c = compiler.addOutputFileArg("gresource.c"); compiler.addArg("--dependency-file"); _ = compiler.addDepFileOutputArg("resource.d"); compiler.addArg("--generate-source"); break :gresource built_c; }; const gschema_compiled = gschema: { const compiler = b.addSystemCommand(&.{"glib-compile-schemas"}); compiler.addArg("--targetdir"); const out = compiler.addOutputDirectoryArg("compiled"); compiler.addArg("--strict"); compiler.addDirectoryArg(b.path("data/")); compiler.addFileInput(b.path(b.fmt("data/{s}.gschema.xml", .{app_name}))); break :gschema out.path(b, glib_compiled_schema_name); }; // An executable. const exe = exe: { const exe = b.addExecutable(.{ .name = app_name, .target = target, .optimize = optimize, }); // This is a standard C application, so libc is required. exe.linkLibC(); // Vala does not bundle system libraries (of course): we have to tell the // linker. for (system_libraries) |lib| { exe.linkSystemLibrary(lib); } exe.linkLibrary(core); exe.addCSourceFile(.{ .file = gresouce_c }); // At this point, build does not run yet—we can't enumerate C source // directory. Since we already have a list of Vala source code, we can // build a list of paths of to-be-generated C source file. for (vala_sources) |src| { // Basically unreachable. Don't put non Vala source into `vala_sources`. if (!std.mem.endsWith(u8, src, ".vala")) { return BuildError.NonValaSourceInSourceListError; } // Looks fragile but it works. Even if it produced incorrect paths, // filesystem and Zig compiler will tell us a file is missing. const rel_path = b.fmt("{s}.c", .{src[0..(src.len - 5)]}); // src = "src/Foo.vala" // rel_path = "src/Foo.c" // file = "(step cache directory)/vala/src/Foo.c" exe.addCSourceFile(.{ .file = try vala_cdir.join(b.allocator, rel_path), }); } break :exe exe; }; const install_compiled_gschema = b.addInstallFile( gschema_compiled, b.pathJoin(&.{ glib_schemas_dir, glib_compiled_schema_name }), ); // 256x256 PNG icon (required for `.DirIcon`, icon file specific for AppImage) const icon_png256 = icon_png256: { const rsvg_convert = b.addSystemCommand(&.{"rsvg-convert"}); rsvg_convert.addArgs(&.{ "--width=256", "--height=256" }); rsvg_convert.addArg("--output"); const output = rsvg_convert.addOutputFileArg(b.fmt("{s}.png", .{app_name})); // Input filename must be the last argument. rsvg_convert.addFileArg(b.path("data/app-icon.svg")); break :icon_png256 output; }; // Default install step { const step = b.getInstallStep(); // Application binary b.installArtifact(exe); // GLib schema file b.installFile( b.fmt("data/{s}.gschema.xml", .{app_name}), b.pathJoin(&.{ glib_schemas_dir, b.fmt("{s}.gschema.xml", .{app_name}) }), ); // GLib schema file (compiled) if (compile_gschema) { step.dependOn(&install_compiled_gschema.step); } // Desktop entry b.installFile( "data/plac.desktop", b.pathJoin(&.{ "share/applications", b.fmt("{s}.desktop", .{app_name}) }), ); // Icons b.installFile( "data/app-icon.svg", b.pathJoin(&.{ "share/icons/hicolor/16x16/apps", b.fmt("{s}.svg", .{app_name}) }), ); step.dependOn(&b.addInstallFile( icon_png256, b.pathJoin(&.{ "share/icons/hicolor/256x256/apps", b.fmt("{s}.png", .{app_name}) }), ).step); } // `zig build run` { const run = b.addRunArtifact(exe); run.step.dependOn(&install_compiled_gschema.step); run.setEnvironmentVariable("GSETTINGS_SCHEMA_DIR", b.pathJoin(&.{ b.install_path, glib_schemas_dir })); const step = b.step("run", "Build and run Plac GTK-Adwaita app"); step.dependOn(&run.step); } }
-
-
gtk-adwaita/build.zig.zon (deleted)
-
@@ -1,31 +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 .{ .name = .plac_gtk, .version = "0.0.0", .fingerprint = 0xd79b7f79b9f2bab4, .minimum_zig_version = "0.14.0", .dependencies = .{ .core = .{ .path = "../core", }, }, .paths = .{ "build.zig", "build.zig.zon", "src/", }, }
-
-
gtk-adwaita/data/app-icon.svg (deleted)
-
@@ -1,19 +0,0 @@<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!-- 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 --> <svg width="100%" height="100%" viewBox="0 0 31 31" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1;"><g id="Icon"><g id="Label"><path d="M15.51,5.533l-0.875,-1.872l1.751,-0l-0.876,1.872Z" style="fill:none;stroke:#1c71d8;stroke-width:0.75px;"/><path d="M15.51,5.533l-0.875,-1.872l1.751,-0l-0.876,1.872Z" style="fill:#1c71d8;"/><circle cx="25.786" cy="11.249" r="0.72" style="fill:#1c71d8;"/><circle cx="5.781" cy="23.95" r="0.72" style="fill:#1c71d8;"/><circle cx="3.89" cy="17.577" r="0.72" style="fill:#1c71d8;"/><circle cx="4.936" cy="11.682" r="0.72" style="fill:#1c71d8;"/><circle cx="21.529" cy="6.869" r="0.72" style="fill:#1c71d8;"/><circle cx="9.495" cy="6.859" r="0.72" style="fill:#1c71d8;"/><circle cx="24.983" cy="23.992" r="0.72" style="fill:#1c71d8;"/><circle cx="26.884" cy="17.415" r="0.72" style="fill:#1c71d8;"/></g><g id="Knob"><g id="Knob-Base" serif:id="Knob Base"><g><path d="M6.618,18.912l0,-3.188c0,-4.885 3.966,-8.85 8.851,-8.85c4.884,-0 8.85,3.965 8.85,8.85l-0,3.188c-0,4.885 -3.966,8.85 -8.85,8.85c-4.885,0 -8.851,-3.965 -8.851,-8.85Z" style="fill:url(#_Linear1);"/></g><circle cx="15.469" cy="15.724" r="8.85" style="fill:#deddda;"/></g><g id="Knob-Accent" serif:id="Knob Accent"><path d="M11.617,16.63l-3.227,0c-0.316,0 -0.573,-0.256 -0.573,-0.573l-0,-1.146c-0,-0.316 0.257,-0.573 0.573,-0.573l3.227,0c0.316,0 0.573,0.257 0.573,0.573l0,1.146c0,0.317 -0.257,0.573 -0.573,0.573Z" style="fill:#e01b24;stroke:url(#_Linear2);stroke-width:0.6px;stroke-miterlimit:1.5;"/></g></g></g><defs><linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(17.6121,1.49012,-1.49012,17.6121,6.43081,19.625)"><stop offset="0" style="stop-color:#c0bfbc;stop-opacity:1"/><stop offset="0.23" style="stop-color:#a2a19e;stop-opacity:1"/><stop offset="0.5" style="stop-color:#9a9996;stop-opacity:1"/><stop offset="0.78" style="stop-color:#a2a19e;stop-opacity:1"/><stop offset="1" style="stop-color:#c0bfbc;stop-opacity:1"/></linearGradient><linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-2.89239e-17,2.29208,-4.37313,-2.5806e-16,10.0034,14.3384)"><stop offset="0" style="stop-color:#a51d2d;stop-opacity:1"/><stop offset="0.19" style="stop-color:#c32633;stop-opacity:1"/><stop offset="0.41" style="stop-color:#e02f38;stop-opacity:1"/><stop offset="1" style="stop-color:#ed333b;stop-opacity:1"/></linearGradient></defs></svg>
-
-
gtk-adwaita/data/css/main-window.css (deleted)
-
@@ -1,21 +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 */ .plac-playback-artwork { border-radius: 3px; }
-
-
gtk-adwaita/data/gresource.xml (deleted)
-
@@ -1,57 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <gresources> <gresource prefix="/jp/pocka/plac/gtk-adwaita"> <file preprocess="xml-stripblanks">metainfo.xml</file> <file preprocess="xml-stripblanks">ui/browse.ui</file> <file preprocess="xml-stripblanks">ui/browse-item-navigation.ui</file> <file preprocess="xml-stripblanks">ui/main-window.ui</file> <file preprocess="xml-stripblanks">ui/playback-toolbar.ui</file> <file preprocess="xml-stripblanks">ui/server-list.ui</file> <file preprocess="xml-stripblanks">ui/generic-error-dialog.ui</file> <file preprocess="xml-stripblanks">ui/server-connecting.ui</file> <file preprocess="xml-stripblanks">ui/server-list-unexpected-error-dialog.ui</file> <file preprocess="xml-stripblanks">ui/server-list-network-error-dialog.ui</file> <file preprocess="xml-stripblanks">ui/zone-output-row.ui</file> <file preprocess="xml-stripblanks">icons/scalable/actions/audio-volume-high-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/audio-volume-low-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/go-next-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/go-previous-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/image-missing-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/item-missing-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/library-music-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/pause-large-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/play-large-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/skip-backward-large-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/skip-backwards-10-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/skip-forward-10-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/skip-forward-large-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/sound-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/view-more-symbolic.svg</file> <file preprocess="xml-stripblanks" alias="icons/scalable/apps/jp.pocka.plac.gtk-adwaita.svg">app-icon.svg</file> <file preprocess="xml-stripblanks" alias="icons/hicolor/16x16/apps/jp.pocka.plac.gtk-adwaita.svg">app-icon.svg</file> <file preprocess="xml-stripblanks" alias="icons/hicolor/24x24/apps/jp.pocka.plac.gtk-adwaita.svg">app-icon.svg</file> <file preprocess="xml-stripblanks" alias="icons/hicolor/32x32/apps/jp.pocka.plac.gtk-adwaita.svg">app-icon.svg</file> <file preprocess="xml-stripblanks" alias="icons/hicolor/48x48/apps/jp.pocka.plac.gtk-adwaita.svg">app-icon.svg</file> <file preprocess="xml-stripblanks" alias="icons/hicolor/64x64/apps/jp.pocka.plac.gtk-adwaita.svg">app-icon.svg</file> <file preprocess="xml-stripblanks" alias="icons/hicolor/128x128/apps/jp.pocka.plac.gtk-adwaita.svg">app-icon.svg</file> <file preprocess="xml-stripblanks" alias="icons/hicolor/256x256/apps/jp.pocka.plac.gtk-adwaita.svg">app-icon.svg</file> <file>css/main-window.css</file> </gresource> </gresources>
-
-
-
@@ -1,8 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg"> <path d="m 7 1.007812 c -0.296875 -0.003906 -0.578125 0.125 -0.769531 0.351563 l -3.230469 3.640625 h -1 c -1.09375 0 -2 0.84375 -2 2 v 2 c 0 1.089844 0.910156 2 2 2 h 1 l 3.230469 3.640625 c 0.210937 0.253906 0.492187 0.363281 0.769531 0.359375 z m 6.460938 0.960938 c -0.191407 -0.003906 -0.386719 0.054688 -0.558594 0.167969 c -0.457032 0.3125 -0.578125 0.933593 -0.269532 1.390625 c 1.824219 2.707031 1.824219 6.238281 0 8.945312 c -0.308593 0.457032 -0.1875 1.078125 0.269532 1.390625 c 0.457031 0.308594 1.078125 0.1875 1.390625 -0.269531 c 1.136719 -1.691406 1.707031 -3.640625 1.707031 -5.59375 s -0.570312 -3.902344 -1.707031 -5.59375 c -0.195313 -0.285156 -0.511719 -0.4375 -0.832031 -0.4375 z m -3.421876 2.019531 c -0.222656 -0.007812 -0.453124 0.058594 -0.644531 0.203125 c -0.261719 0.199219 -0.394531 0.5 -0.394531 0.804688 v 0.058594 c 0.011719 0.191406 0.074219 0.375 0.199219 0.535156 c 1.074219 1.429687 1.074219 3.390625 0 4.816406 c -0.125 0.164062 -0.1875 0.347656 -0.199219 0.535156 v 0.0625 c 0 0.304688 0.132812 0.605469 0.394531 0.804688 c 0.441407 0.332031 1.066407 0.242187 1.398438 -0.199219 c 0.804687 -1.066406 1.207031 -2.335937 1.207031 -3.609375 s -0.402344 -2.542969 -1.207031 -3.613281 c -0.183594 -0.246094 -0.464844 -0.382813 -0.753907 -0.398438 z m 0 0" fill="#2e3436"/> </svg>
-
-
-
@@ -1,8 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg"> <path d="m 7 1.007812 c -0.296875 -0.003906 -0.578125 0.125 -0.769531 0.351563 l -3.230469 3.640625 h -1 c -1.09375 0 -2 0.84375 -2 2 v 2 c 0 1.089844 0.910156 2 2 2 h 1 l 3.230469 3.640625 c 0.210937 0.253906 0.492187 0.363281 0.769531 0.359375 z m 2.957031 2.980469 c -0.199219 0.011719 -0.394531 0.074219 -0.5625 0.203125 c -0.441406 0.332032 -0.53125 0.960938 -0.195312 1.402344 c 1.074219 1.425781 1.074219 3.386719 0 4.8125 c -0.335938 0.441406 -0.246094 1.070312 0.195312 1.402344 c 0.441407 0.332031 1.066407 0.242187 1.398438 -0.195313 c 0.804687 -1.070312 1.207031 -2.339843 1.207031 -3.613281 s -0.402344 -2.542969 -1.207031 -3.613281 c -0.183594 -0.246094 -0.464844 -0.382813 -0.753907 -0.398438 c -0.027343 0 -0.054687 0 -0.085937 0 z m 0 0" fill="#2e3436"/> </svg>
-
-
-
@@ -1,8 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg"> <path d="m 4 2 c 0 -0.265625 0.105469 -0.519531 0.292969 -0.707031 c 0.390625 -0.390625 1.023437 -0.390625 1.414062 0 l 6 6 c 0.1875 0.1875 0.292969 0.441406 0.292969 0.707031 s -0.105469 0.519531 -0.292969 0.707031 l -6 6 c -0.390625 0.390625 -1.023437 0.390625 -1.414062 0 c -0.1875 -0.1875 -0.292969 -0.441406 -0.292969 -0.707031 s 0.105469 -0.519531 0.292969 -0.707031 l 5.292969 -5.292969 l -5.292969 -5.292969 c -0.1875 -0.1875 -0.292969 -0.441406 -0.292969 -0.707031 z m 0 0" fill="#2e3436"/> </svg>
-
-
-
@@ -1,8 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg"> <path d="m 12 2 c 0 -0.265625 -0.105469 -0.519531 -0.292969 -0.707031 c -0.390625 -0.390625 -1.023437 -0.390625 -1.414062 0 l -6 6 c -0.1875 0.1875 -0.292969 0.441406 -0.292969 0.707031 s 0.105469 0.519531 0.292969 0.707031 l 6 6 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 c 0.1875 -0.1875 0.292969 -0.441406 0.292969 -0.707031 s -0.105469 -0.519531 -0.292969 -0.707031 l -5.292969 -5.292969 l 5.292969 -5.292969 c 0.1875 -0.1875 0.292969 -0.441406 0.292969 -0.707031 z m 0 0" fill="#2e3436"/> </svg>
-
-
-
@@ -1,8 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg"> <path d="m 4 1 c -1.644531 0 -3 1.355469 -3 3 v 1 h 1 v -1 c 0 -1.109375 0.890625 -2 2 -2 h 1 v -1 z m 2 0 v 1 h 4 v -1 z m 5 0 v 1 h 1 c 1.109375 0 2 0.890625 2 2 v 1 h 1 v -1 c 0 -1.644531 -1.355469 -3 -3 -3 z m -5 4 c -0.550781 0 -1 0.449219 -1 1 s 0.449219 1 1 1 s 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 z m -5 1 v 4 h 1 v -4 z m 13 0 v 4 h 1 v -4 z m -4.5 2 l -2 2 l -1.5 -1 l -2 2 v 0.5 c 0 0.5 0.5 0.5 0.5 0.5 h 7 s 0.472656 -0.035156 0.5 -0.5 v -1 z m -8.5 3 v 1 c 0 1.644531 1.355469 3 3 3 h 1 v -1 h -1 c -1.109375 0 -2 -0.890625 -2 -2 v -1 z m 13 0 v 1 c 0 1.109375 -0.890625 2 -2 2 h -1 v 1 h 1 c 1.644531 0 3 -1.355469 3 -3 v -1 z m -8 3 v 1 h 4 v -1 z m 0 0" fill="#2e3434" fill-opacity="0.34902"/> </svg>
-
-
-
@@ -1,6 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 4 1 c -1.644531 0 -3 1.355469 -3 3 v 1 h 1 v -1 c 0 -1.109375 0.890625 -2 2 -2 h 1 v -1 z m 2 0 v 1 h 4 v -1 z m 5 0 v 1 h 1 c 1.109375 0 2 0.890625 2 2 v 1 h 1 v -1 c 0 -1.644531 -1.355469 -3 -3 -3 z m -3.03125 2 c -0.050781 0 -0.101562 0 -0.15625 0.003906 c -1.640625 0.09375 -2.902344 1.359375 -2.8125 3 h 2 c -0.03125 -0.5625 0.375 -0.96875 0.9375 -1 c 0.566406 -0.03125 1.03125 0.375 1.0625 0.9375 v 0.03125 c 0.003906 0.128906 -0.03125 0.324219 -0.125 0.4375 c -0.226562 0.273438 -0.480469 0.496094 -0.6875 0.65625 c -0.226562 0.171875 -0.472656 0.382813 -0.71875 0.6875 c -0.242188 0.304688 -0.46875 0.746094 -0.46875 1.25 c -0.003906 0.527344 0.472656 1 1 1 s 1.003906 -0.46875 1 -0.992187 c 0.003906 0 0.011719 -0.011719 0.03125 -0.039063 c 0.050781 -0.0625 0.1875 -0.167968 0.375 -0.3125 c 1.21875 -0.921875 1.59375 -1.441406 1.59375 -2.84375 c -0.085938 -1.589844 -1.453125 -2.824218 -3.03125 -2.816406 z m -6.96875 3 v 4 h 1 v -4 z m 13 0 v 4 h 1 v -4 z m -13 5 v 1 c 0 1.644531 1.355469 3 3 3 h 1 v -1 h -1 c -1.109375 0 -2 -0.890625 -2 -2 v -1 z m 13 0 v 1 c 0 1.109375 -0.890625 2 -2 2 h -1 v 1 h 1 c 1.644531 0 3 -1.355469 3 -3 v -1 z m -6 0.003906 c -0.550781 0 -1 0.449219 -1 1 c 0 0.550782 0.449219 1 1 1 c 0.554688 0 1 -0.449218 1 -1 c 0 -0.550781 -0.445312 -1 -1 -1 z m -2 2.996094 v 1 h 4 v -1 z m 0 0" fill="#222222" fill-opacity="0.34902"/></svg>
-
-
-
@@ -1,6 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 5 0 c -1.09375 0 -2 0.910156 -2 2 v 9 c 0 1.089844 0.90625 2 2 2 h 9 c 1.089844 0 2 -0.910156 2 -2 v -9 c 0 -1.089844 -0.910156 -2 -2 -2 z m 8 2 v 2 h -2 v 5 c 0 1.105469 -0.898438 2 -2 2 c -1.105469 0 -2 -0.894531 -2 -2 s 0.894531 -2 2 -2 c 0.347656 0 0.695312 0.09375 1 0.269531 v -5.269531 z m 0 0"/><path d="m 2 3 c -1.09375 0 -2 0.910156 -2 2 v 9 c 0 1.089844 0.90625 2 2 2 h 9 c 1.089844 0 2 -0.910156 2 -2 h -11 z m 0 0"/></g></svg>
-
-
-
@@ -1,6 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 13 1 h -3 c -0.554688 0 -1 0.449219 -1 1 v 12 c 0 0.550781 0.445312 1 1 1 h 3 c 0.550781 0 1 -0.449219 1 -1 v -12 c 0 -0.550781 -0.449219 -1 -1 -1 z m 0 0"/><path d="m 6 1 h -3 c -0.554688 0 -1 0.449219 -1 1 v 12 c 0 0.550781 0.445312 1 1 1 h 3 c 0.550781 0 1 -0.449219 1 -1 v -12 c 0 -0.550781 -0.449219 -1 -1 -1 z m 0 0"/></g></svg>
-
-
-
@@ -1,6 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 2 13.492188 v -11 c 0 -1.5 1.265625 -1.492188 1.265625 -1.492188 h 0.132813 c 0.242187 0 0.484374 0.054688 0.699218 0.175781 l 9.796875 5.597657 c 0.433594 0.238281 0.65625 0.730468 0.65625 1.222656 c 0 0.492187 -0.222656 0.984375 -0.65625 1.226562 l -9.796875 5.597656 c -0.214844 0.121094 -0.457031 0.175782 -0.699218 0.171876 h -0.132813 s -1.265625 0 -1.265625 -1.5 z m 0 0" fill="#222222"/></svg>
-
-
-
@@ -1,6 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 13.136719 13 s 0.90625 0.003906 0.90625 -1.070312 v -7.859376 c 0 -1.070312 -0.90625 -1.070312 -0.90625 -1.070312 h -0.09375 c -0.175781 -0.003906 -0.347657 0.035156 -0.5 0.125 l -7 4 c -0.3125 0.171875 -0.46875 0.523438 -0.46875 0.875 s 0.15625 0.703125 0.46875 0.875 l 7 4 c 0.152343 0.085938 0.324219 0.125 0.5 0.125 z m -9.5625 0 c 0.273437 0 0.5 -0.226562 0.5 -0.5 v -9 c 0 -0.277344 -0.226563 -0.5 -0.5 -0.5 h -1 c -0.277344 0 -0.5 0.222656 -0.5 0.5 v 9 c 0 0.273438 0.222656 0.5 0.5 0.5 z m 0 0" fill="#222222"/></svg>
-
-
-
@@ -1,8 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg"> <path d="m 8.253906 1 c -0.132812 0.007812 -0.265625 0.046875 -0.375 0.117188 l -3.75 2.25 c -0.210937 0.140624 -0.335937 0.382812 -0.328125 0.632812 c 0 0.25 0.113281 0.5 0.328125 0.636719 l 3.75 2.25 c 0.109375 0.070312 0.242188 0.109375 0.375 0.113281 h 0.75 v -2 c 1.746094 -0.003906 3.292969 1.132812 3.816406 2.800781 c 0.523438 1.664063 -0.089843 3.480469 -1.523437 4.480469 c -0.296875 0.203125 -0.460937 0.546875 -0.429687 0.90625 s 0.25 0.671875 0.574218 0.824219 c 0.328125 0.152343 0.710938 0.113281 1.003906 -0.09375 c 2.140626 -1.503907 3.066407 -4.222657 2.28125 -6.71875 c -0.789062 -2.5 -3.105468 -4.195313 -5.722656 -4.199219 v -2 z m -1.09375 7.898438 c -1.535156 0 -2.566406 1.25 -2.566406 3.121093 c 0 1.839844 1.015625 3.066407 2.523438 3.066407 c 1.523437 0 2.554687 -1.246094 2.554687 -3.128907 c 0 -1.835937 -1.015625 -3.058593 -2.511719 -3.058593 z m -4.875 0.089843 l -2.070312 1.195313 l 0.5625 1.074218 l 0.902344 -0.46875 v 2.929688 h -1.160157 v 1.285156 h 3.726563 v -1.285156 h -1.066406 v -4.730469 z m 4.839844 1.195313 c 0.597656 0 1.003906 0.734375 1.003906 1.84375 c 0 1.066406 -0.398437 1.777344 -0.976562 1.777344 h -0.003906 c -0.613282 -0.003907 -1.011719 -0.738282 -1.011719 -1.835938 c 0 -1.074219 0.398437 -1.785156 0.988281 -1.785156 z m 0 0" fill="#222222"/> </svg>
-
-
-
@@ -1,8 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg"> <path d="m 7.003906 1 v 2 c -2.621094 0.003906 -4.933594 1.699219 -5.722656 4.199219 c -0.785156 2.496093 0.136719 5.214843 2.28125 6.71875 c 0.292969 0.207031 0.671875 0.246093 1 0.09375 c 0.324219 -0.152344 0.546875 -0.464844 0.574219 -0.824219 c 0.03125 -0.359375 -0.132813 -0.703125 -0.429688 -0.90625 c -1.429687 -1 -2.046875 -2.816406 -1.519531 -4.480469 c 0.519531 -1.667969 2.066406 -2.804687 3.816406 -2.800781 v 2 h 0.75 c 0.132813 -0.003906 0.261719 -0.046875 0.375 -0.117188 l 3.75 -2.25 c 0.207032 -0.140624 0.332032 -0.378906 0.328125 -0.632812 c 0.003907 -0.253906 -0.121093 -0.492188 -0.328125 -0.632812 l -3.75 -2.25 c -0.113281 -0.070313 -0.242187 -0.113282 -0.375 -0.117188 z m 6.15625 7.898438 c -1.535156 0 -2.566406 1.25 -2.566406 3.121093 c 0 1.835938 1.011719 3.066407 2.519531 3.066407 c 1.527344 0 2.558594 -1.246094 2.558594 -3.128907 c 0 -1.835937 -1.015625 -3.058593 -2.511719 -3.058593 z m -4.875 0.085937 l -2.070312 1.195313 l 0.5625 1.074218 l 0.902344 -0.46875 v 2.933594 h -1.160157 v 1.28125 h 3.726563 v -1.28125 h -1.066406 v -4.734375 z m 4.839844 1.195313 c 0.597656 0 1.003906 0.738281 1.003906 1.847656 c 0 1.066406 -0.398437 1.777344 -0.980468 1.777344 c -0.613282 0 -1.011719 -0.738282 -1.011719 -1.839844 c 0 -1.074219 0.398437 -1.785156 0.988281 -1.785156 z m 0 0" fill="#222222"/> </svg>
-
-
-
@@ -1,6 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 2.980469 3 s -0.90625 -0.003906 -0.90625 1.070312 v 7.859376 c 0 1.070312 0.90625 1.070312 0.90625 1.070312 h 0.09375 c 0.171875 0 0.347656 -0.039062 0.5 -0.125 l 7 -4 c 0.308593 -0.171875 0.46875 -0.523438 0.46875 -0.875 s -0.160157 -0.703125 -0.46875 -0.875 l -7 -4 c -0.152344 -0.085938 -0.328125 -0.125 -0.5 -0.125 z m 9.5625 0 c -0.277344 0 -0.5 0.222656 -0.5 0.5 v 9 c 0 0.277344 0.222656 0.5 0.5 0.5 h 1 c 0.273437 0 0.5 -0.222656 0.5 -0.5 v -9 c 0 -0.277344 -0.226563 -0.5 -0.5 -0.5 z m 0 0" fill="#222222"/></svg>
-
-
-
@@ -1,6 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#303030"><path d="m 2.996094 8 c 0 0.550781 -0.449219 1 -1 1 c -0.550782 0 -1 -0.449219 -1 -1 s 0.449218 -1 1 -1 c 0.550781 0 1 0.449219 1 1 z m 0 0"/><path d="m 4.996094 5 c 0.550781 0 1 0.449219 1 1 v 4 c 0 0.550781 -0.449219 1 -1 1 c -0.550782 0 -1 -0.449219 -1 -1 v -4 c 0 -0.550781 0.449218 -1 1 -1 z m 0 0"/><path d="m 7.996094 1 c 0.550781 0 1 0.480469 1 1.070312 v 11.859376 c 0 0.589843 -0.449219 1.070312 -1 1.070312 c -0.550782 0 -1 -0.480469 -1 -1.070312 v -11.859376 c 0 -0.589843 0.449218 -1.070312 1 -1.070312 z m 0 0"/><path d="m 10.90625 3 h 0.179688 c 0.503906 0 0.910156 0.476562 0.910156 1.066406 v 7.867188 c 0 0.589844 -0.40625 1.066406 -0.910156 1.066406 h -0.179688 c -0.503906 0 -0.910156 -0.476562 -0.910156 -1.066406 v -7.867188 c 0 -0.589844 0.40625 -1.066406 0.910156 -1.066406 z m 0 0"/><path d="m 13.996094 7 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 c -0.550782 0 -1 -0.449219 -1 -1 s 0.449218 -1 1 -1 z m 0 0"/></g></svg>
-
-
-
@@ -1,8 +0,0 @@<?xml version="1.0" encoding="UTF-8"?> <!-- SPDX-FileCopyrightText: Gnome Developers SPDX-License-Identifier: CC0-1.0 --> <svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg"> <path d="m 7.996094 0 c -1.105469 0 -2 0.894531 -2 2 s 0.894531 2 2 2 c 1.101562 0 2 -0.894531 2 -2 s -0.898438 -2 -2 -2 z m 0 6 c -1.105469 0 -2 0.894531 -2 2 s 0.894531 2 2 2 c 1.101562 0 2 -0.894531 2 -2 s -0.898438 -2 -2 -2 z m 0 6 c -1.105469 0 -2 0.894531 -2 2 s 0.894531 2 2 2 c 1.101562 0 2 -0.894531 2 -2 s -0.898438 -2 -2 -2 z m 0 0" fill="#2e3436"/> </svg>
-
-
-
@@ -1,52 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <schemalist> <schema path="/jp/pocka/plac/gtk-adwaita/" id="jp.pocka.plac.gtk-adwaita"> <key name="connected-server-id" type="s"> <default>""</default> <summary>Last Connected Server ID</summary> <description>Connects to the server with this ID on application startup.</description> </key> <key name="connected-server-token" type="s"> <default>""</default> <summary>Last Connected Server Token</summary> <description>Connects to the server with this token. Ignored when connected-server-id is empty.</description> </key> <key name="connected-server-addr" type="s"> <default>""</default> <summary>Last Connected Server IP Address</summary> <description>Connects to the server at this address. Ignored when connected-server-id is empty.</description> </key> <key name="connected-server-port" type="u"> <default>9330</default> <summary>Last Connected Server IP Address</summary> <description>TCP port number to use when connecting to server using connected-server-addr.</description> </key> <key name="label-parsing-enabled" type="b"> <default>true</default> <summary>Enable label parsing</summary> <description>Enable parsing of browse items' label. Some browse page contains "[[id|label]]" text and this option turns that into "label".</description> </key> <key name="show-seek-by-10secs" type="b"> <default>false</default> <summary>Show seek by 10secs buttons</summary> <description>Show buttons that seek forward/backwards by 10 seconds.</description> </key> </schema> </schemalist>
-
-
gtk-adwaita/data/metainfo.xml (deleted)
-
@@ -1,16 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- Copyright 2025 Shota FUJI SPDX-License-Identifier: CC0-1.0 --> <component type="desktop-application"> <id>jp.pocka.plac.gtk-adwaita</id> <metadata_license>CC0-1.0</metadata_license> <name>Plac</name> <summary>Control playback of Roon Server and browse library.</summary> <launchable type="desktop-id">jp.pocka.plac.gtk-adwaita</launchable> <url type="homepage">https://codeberg.org/pocka/plac</url> <url type="bugtracker">https://codeberg.org/pocka/plac/issues</url> <project_license>Apache-2.0</project_license> </component>
-
-
gtk-adwaita/data/plac.desktop (deleted)
-
@@ -1,24 +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 [Desktop Entry] Type=Application Name=Plac GenericName=Music Player Comment=Third-party Roon client Icon=jp.pocka.plac.gtk-adwaita Exec=jp.pocka.plac.gtk-adwaita Categories=AudioVideo;Audio;Player;GNOME
-
-
-
@@ -1,92 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <interface> <template class="PlacGtkAdwaitaBrowseItemNavigation" parent="GtkBox"> <property name="orientation">horizontal</property> <property name="spacing">8</property> <property name="margin-top">8</property> <binding name="margin-bottom"> <lookup name="margin-top" type="PlacGtkAdwaitaBrowseItemNavigation" /> </binding> <property name="margin-start">8</property> <binding name="margin-end"> <lookup name="margin-start" type="PlacGtkAdwaitaBrowseItemNavigation" /> </binding> <child> <object class="PlacGtkAdwaitaArtwork" id="artwork"> <property name="visible">false</property> <property name="width">40</property> <binding name="height"> <lookup name="width">artwork</lookup> </binding> </object> </child> <child> <object class="GtkBox"> <property name="orientation">vertical</property> <property name="spacing">2</property> <property name="hexpand">true</property> <property name="valign">center</property> <child> <object class="GtkLabel" id="title"> <property name="ellipsize">end</property> <property name="xalign">0</property> <style> <class name="heading" /> </style> </object> </child> <child> <object class="GtkLabel" id="subtitle"> <property name="visible">false</property> <property name="ellipsize">end</property> <property name="xalign">0</property> <style> <class name="body" /> </style> </object> </child> </object> </child> <child> <object class="GtkImage" id="icon"> <property name="accessible-role">presentation</property> <property name="icon-name">go-next-symbolic</property> </object> </child> <child> <object class="GtkMenuButton" id="action_menu"> <property name="visible">false</property> <property name="valign">center</property> <property name="icon-name">view-more-symbolic</property> <property name="popover"> <object class="GtkPopover" id="action_menu_popover"> <child> <object class="GtkListBox" id="action_menu_list"> <style> <class name="navigation-sidebar" /> </style> </object> </child> </object> </property> </object> </child> </template> </interface>
-
-
gtk-adwaita/data/ui/browse.ui (deleted)
-
@@ -1,139 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <interface> <template class="PlacGtkAdwaitaBrowse" parent="GtkBox"> <property name="orientation">vertical</property> <child> <object class="AdwToastOverlay" id="toasts"> <child> <object class="GtkOverlay"> <child> <object class="GtkBox"> <property name="orientation">vertical</property> <child> <object class="GtkBox" id="metadata"> <property name="margin-top">20</property> <property name="margin-bottom">4</property> <property name="margin-start">12</property> <binding name="margin-end"> <lookup name="margin-start">metadata</lookup> </binding> <property name="orientation">vertical</property> <child> <object class="GtkBox"> <property name="orientation">horizontal</property> <property name="spacing">4</property> <child> <object class="GtkButton" id="back"> <property name="visible">false</property> <property name="icon-name">go-previous-symbolic</property> <property name="valign">center</property> <accessibility> <property name="label">Go back to previous page</property> </accessibility> <style> <class name="flat" /> </style> </object> </child> <child> <object class="GtkBox" id="metadata_labels"> <property name="orientation">vertical</property> <property name="margin-start">4</property> <property name="margin-top">4</property> <binding name="margin-bottom"> <lookup name="margin-top">metadata_labels</lookup> </binding> <child> <object class="GtkLabel" id="title"> <property name="xalign">0</property> <property name="ellipsize">end</property> <style> <class name="title-2" /> </style> </object> </child> <child> <object class="GtkLabel" id="subtitle"> <property name="visible">false</property> <property name="xalign">0</property> <property name="ellipsize">end</property> </object> </child> </object> </child> </object> </child> </object> </child> <child> <object class="GtkScrolledWindow" id="scroller"> <property name="vexpand">true</property> <property name="child"> <object class="GtkListBox" id="items"> <property name="valign">start</property> <property name="margin-top">6</property> <property name="margin-bottom">12</property> <property name="margin-start">12</property> <binding name="margin-end"> <lookup name="margin-start">metadata</lookup> </binding> <style> <class name="boxed-list" /> </style> </object> </property> </object> </child> </object> </child> <child type="overlay"> <object class="GtkBox" id="loading"> <property name="visible">false</property> <property name="halign">end</property> <property name="valign">start</property> <property name="orientation">horizontal</property> <property name="spacing">4</property> <property name="margin-top">2</property> <property name="margin-end">2</property> <style> <class name="osd" /> </style> <child> <object class="AdwSpinner"> <property name="margin-start">4</property> </object> </child> <child> <object class="GtkLabel"> <property name="label">Loading...</property> <property name="margin-top">4</property> <property name="margin-bottom">4</property> <property name="margin-end">4</property> </object> </child> </object> </child> </object> </child> </object> </child> </template> </interface>
-
-
-
@@ -1,67 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <interface> <template class="PlacGtkAdwaitaGenericErrorDialog" parent="AdwDialog"> <property name="content-width">400</property> <child> <object class="AdwToolbarView"> <child type="top"> <object class="AdwHeaderBar" /> </child> <property name="content"> <object class="GtkBox"> <property name="orientation">vertical</property> <property name="spacing">4</property> <property name="margin-top">4</property> <property name="margin-bottom">16</property> <property name="margin-start">12</property> <property name="margin-end">12</property> <child> <object class="GtkLabel" id="description_label"> <property name="wrap">true</property> <property name="halign">start</property> <property name="natural-wrap-mode">none</property> <property name="xalign">0</property> </object> </child> <child> <object class="GtkLabel"> <property name="label">Details</property> <property name="margin-top">12</property> <property name="halign">start</property> <style> <class name="heading" /> </style> </object> </child> <child> <object class="GtkLabel" id="details_label"> <property name="wrap">true</property> <property name="halign">start</property> <property name="natural-wrap-mode">none</property> <property name="xalign">0</property> </object> </child> </object> </property> </object> </child> </template> </interface>
-
-
gtk-adwaita/data/ui/main-window.ui (deleted)
-
@@ -1,134 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <interface> <template class="PlacGtkAdwaitaMainWindow" parent="AdwApplicationWindow"> <property name="default-width">800</property> <property name="default-height">640</property> <property name="title">Plac</property> <child> <object class="AdwBreakpoint"> <condition>max-width: 800sp</condition> <setter object="split_view" property="collapsed">true</setter> </object> </child> <property name="content"> <object class="AdwToolbarView"> <property name="bottom-bar-style">raised</property> <property name="content"> <object class="GtkOverlay"> <child> <object class="GtkStack" id="root_stack"> <property name="vexpand">true</property> <child> <object class="GtkStackPage"> <property name="name">main</property> <property name="child"> <object class="AdwOverlaySplitView" id="split_view"> <property name="sidebar"> <object class="GtkListBox" id="browse_hierarchy"> <property name="selection-mode">browse</property> <style> <class name="navigation-sidebar" /> </style> </object> </property> <property name="content"> <object class="AdwToolbarView"> <child type="top"> <object class="AdwHeaderBar"> <child type="start"> <object class="GtkToggleButton"> <property name="icon-name">sidebar-show-symbolic</property> <property name="tooltip-text">Toggle Sidebar</property> <property name="active" bind-source="split_view" bind-property="show-sidebar" bind-flags="sync-create|bidirectional" /> </object> </child> <child type="end"> <object class="GtkMenuButton"> <property name="direction">none</property> <property name="menu-model">app_menu</property> </object> </child> </object> </child> <property name="content"> <object class="PlacGtkAdwaitaBrowse" id="browse" /> </property> </object> </property> </object> </property> </object> </child> <child> <object class="GtkStackPage"> <property name="name">loading</property> <property name="child"> <object class="PlacGtkAdwaitaServerConnecting" /> </property> </object> </child> </object> </child> <child type="overlay"> <object class="AdwBanner" id="error_banner"> <property name="valign">start</property> <property name="title">Connection error</property> <property name="button-label">Reconnect</property> <style> <class name="error" /> </style> </object> </child> </object> </property> <child type="bottom"> <object class="PlacGtkAdwaitaPlaybackToolbar" id="playback_toolbar"> <property name="halign">fill</property> <property name="visible">false</property> </object> </child> </object> </property> </template> <menu id="app_menu"> <section> <item> <attribute name="label">Preferences</attribute> <attribute name="action">app.preferences</attribute> </item> <item> <attribute name="label">About Plac</attribute> <attribute name="action">app.about</attribute> </item> </section> <section> <item> <attribute name="label">Quit</attribute> <attribute name="action">app.quit</attribute> </item> </section> </menu> </interface>
-
-
gtk-adwaita/data/ui/playback-toolbar.ui (deleted)
-
@@ -1,189 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <interface> <template class="PlacGtkAdwaitaPlaybackToolbar" parent="GtkBox"> <property name="orientation">horizontal</property> <property name="halign">center</property> <property name="valign">end</property> <property name="spacing">4</property> <property name="margin-top">6</property> <property name="margin-bottom">6</property> <property name="margin-start">12</property> <property name="margin-end">12</property> <child> <object class="PlacGtkAdwaitaArtwork" id="artwork"> <property name="width">80</property> <binding name="height"> <lookup name="width">artwork</lookup> </binding> </object> </child> <child> <object class="GtkBox"> <property name="orientation">vertical</property> <property name="spacing">4</property> <child> <object class="GtkBox"> <property name="orientation">horizontal</property> <property name="spacing">8</property> <property name="margin-start">10</property> <child> <object class="GtkBox"> <property name="orientation">vertical</property> <property name="valign">center</property> <property name="hexpand">true</property> <child> <object class="GtkLabel" id="playing_line1"> <property name="halign">start</property> <property name="ellipsize">end</property> <property name="label"></property> <property name="height-request">24</property> </object> </child> <child> <object class="GtkLabel" id="playing_line2"> <style> <class name="dimmed" /> <!-- ~libadwaita@1.7 --> <class name="dim-label" /> </style> <property name="halign">start</property> <property name="ellipsize">end</property> <property name="label"></property> <property name="height-request">24</property> </object> </child> </object> </child> <child> <object class="GtkMenuButton"> <style> <class name="flat" /> </style> <property name="direction">up</property> <property name="label">Zone</property> <property name="valign">start</property> <property name="popover"> <object class="GtkPopover" id="zone_list_popover"> <child> <object class="GtkBox"> <property name="orientation">vertical</property> <property name="spacing">8</property> <child> <object class="GtkDropDown" id="zone_list" /> </child> <child> <object class="GtkListBox" id="zone_outputs"> <property name="selection-mode">none</property> <style> <class name="boxed-list-separate" /> </style> </object> </child> </object> </child> </object> </property> </object> </child> </object> </child> <child> <object class="GtkBox"> <property name="orientation">horizontal</property> <property name="spacing">8</property> <child> <object class="GtkBox" id="controls"> <property name="orientation">horizontal</property> <property name="spacing">1</property> <child> <object class="GtkButton" id="prev"> <style> <class name="flat" /> </style> <property name="label">Previous</property> <property name="icon-name">skip-backward-large-symbolic</property> <property name="action-name">prev_current_zone</property> </object> </child> <child> <object class="GtkButton" id="seek_backwards_10"> <style> <class name="flat" /> </style> <property name="label">Seek 10 seconds backwards</property> <property name="icon-name">skip-backwards-10-symbolic</property> </object> </child> <child> <object class="GtkButton" id="play"> <style> <class name="flat" /> </style> <property name="label">Play</property> <property name="icon-name">play-large-symbolic</property> <property name="action-name">play_current_zone</property> </object> </child> <child> <object class="GtkButton" id="pause"> <style> <class name="flat" /> </style> <property name="label">Pause</property> <property name="icon-name">pause-large-symbolic</property> <property name="action-name">pause_current_zone</property> </object> </child> <child> <object class="GtkButton" id="seek_forward_10"> <style> <class name="flat" /> </style> <property name="label">Seek 10 seconds forward</property> <property name="icon-name">skip-forward-10-symbolic</property> </object> </child> <child> <object class="GtkButton" id="next"> <style> <class name="flat" /> </style> <property name="label">Skip Next</property> <property name="icon-name">skip-forward-large-symbolic</property> <property name="action-name">next_current_zone</property> </object> </child> </object> </child> <child> <object class="GtkScale" id="seek"> <property name="hexpand">true</property> <property name="orientation">horizontal</property> <accessibility> <property name="label">Seekbar</property> </accessibility> </object> </child> </object> </child> </object> </child> </template> </interface>
-
-
gtk-adwaita/data/ui/server-connecting.ui (deleted)
-
@@ -1,57 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <interface> <template class="PlacGtkAdwaitaServerConnecting" parent="GtkBox"> <property name="halign">center</property> <property name="valign">center</property> <property name="orientation">vertical</property> <property name="spacing">12</property> <child> <object class="GtkBox"> <property name="halign">center</property> <property name="valign">center</property> <property name="orientation">horizontal</property> <property name="spacing">8</property> <style> <class name="heading" /> </style> <child> <object class="AdwSpinner" /> </child> <child> <object class="GtkLabel"> <property name="label">Connecting to Roon Server...</property> </object> </child> </object> </child> <child> <object class="GtkLabel"> <style> <class name="dimmed" /> <!-- ~libadwaita@1.7 --> <class name="dim-label" /> </style> <property name="wrap">true</property> <property name="justify">center</property> <property name="label">If it takes too long, make sure you enabled Plac extension on Roon's settings page.</property> </object> </child> </template> </interface>
-
-
-
@@ -1,26 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <interface> <template class="PlacGtkAdwaitaServerListNetworkErrorDialog" parent="PlacGtkAdwaitaGenericErrorDialog"> <property name="title">Failed to scan Roon Servers</property> <property name="description">Unable to scan Roon Servers due to a network error. Check network connection and this software has permissions for network access.</property> </template> </interface>
-
-
-
@@ -1,24 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <interface> <template class="PlacGtkAdwaitaServerListUnexpectedErrorDialog" parent="PlacGtkAdwaitaGenericErrorDialog"> <property name="title">Failed to scan Roon Servers</property> <property name="description">Encountered an unexpected error during scan operation. Scan again to see if the problem resolves.</property> </template> </interface>
-
-
gtk-adwaita/data/ui/server-list.ui (deleted)
-
@@ -1,102 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <interface> <template class="PlacGtkAdwaitaServerSelectorWindow" parent="AdwApplicationWindow"> <property name="default-width">300</property> <property name="default-height">300</property> <property name="title">Select Roon Server</property> <property name="content"> <object class="AdwToolbarView"> <child type="top"> <object class="AdwHeaderBar"> <child type="start"> <object class="GtkButton" id="scan_button"> <property name="label">Scan</property> <property name="action-name">win.scan_servers</property> </object> </child> </object> </child> <property name="content"> <object class="GtkStack" id="stack"> <property name="transition-type">slide-up-down</property> <child> <object class="GtkStackPage"> <property name="name">idle</property> <property name="child"> <object class="GtkBox"> <property name="orientation">vertical</property> <property name="spacing">8</property> <child> <object class="AdwBanner" id="failure_banner"> <property name="title">Failed to scan Roon Servers</property> <property name="button-label">Show Details</property> <style> <class name="error" /> </style> </object> </child> <child> <object class="AdwClamp"> <child> <object class="GtkListBox" id="servers_list"> <property name="selection-mode">none</property> </object> </child> </object> </child> <child> <object class="AdwStatusPage" id="empty"> <property name="visible">false</property> <property name="title">No Servers Found</property> <property name="description">Scan again after setting up Roon Server.</property> <property name="icon-name">item-missing-symbolic</property> </object> </child> </object> </property> </object> </child> <child> <object class="GtkStackPage"> <property name="name">loading</property> <property name="child"> <object class="GtkBox"> <property name="halign">center</property> <property name="valign">center</property> <property name="orientation">horizontal</property> <property name="spacing">8</property> <child> <object class="AdwSpinner" /> </child> <child> <object class="GtkLabel"> <property name="label">Scanning Roon Servers...</property> </object> </child> </object> </property> </object> </child> </object> </property> </object> </property> </template> </interface>
-
-
gtk-adwaita/data/ui/zone-output-row.ui (deleted)
-
@@ -1,95 +0,0 @@<?xml version="1.0" encoding="utf-8"?> <!-- 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 --> <interface> <template class="PlacGtkAdwaitaZoneOutputRow" parent="GtkListBoxRow"> <property name="activatable">false</property> <property name="width-request">220</property> <child> <object class="GtkBox"> <property name="orientation">vertical</property> <property name="spacing">6</property> <property name="margin-top">4</property> <binding name="margin-bottom"> <lookup name="margin-top" type="GtkBox" /> </binding> <property name="margin-start">6</property> <binding name="margin-end"> <lookup name="margin-start" type="GtkBox" /> </binding> <child> <object class="GtkLabel" id="display_name"> <property name="halign">start</property> </object> </child> <child> <object class="GtkBox" id="incremental_control"> <property name="spacing">6</property> <child> <object class="GtkButton" id="volume_down_incr"> <property name="label">Volume down</property> <property name="icon-name">audio-volume-low-symbolic</property> <property name="hexpand">true</property> </object> </child> <child> <object class="GtkButton" id="volume_up_incr"> <property name="label">Volume up</property> <property name="icon-name">audio-volume-high-symbolic</property> <property name="hexpand">true</property> </object> </child> </object> </child> <child> <object class="GtkBox" id="value_control"> <child> <object class="GtkButton" id="volume_down"> <property name="label">Volume down</property> <property name="icon-name">audio-volume-low-symbolic</property> <style> <class name="flat" /> <class name="circular" /> </style> </object> </child> <child> <object class="GtkScale" id="volume_slider"> <property name="hexpand">true</property> <property name="orientation">horizontal</property> <accessibility> <property name="label">Seekbar</property> </accessibility> </object> </child> <child> <object class="GtkButton" id="volume_up"> <property name="label">Volume up</property> <property name="icon-name">audio-volume-high-symbolic</property> <style> <class name="flat" /> <class name="circular" /> </style> </object> </child> </object> </child> </object> </child> </template> </interface>
-
-
gtk-adwaita/nix/package.nix (deleted)
-
@@ -1,106 +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 { stdenvNoCC, callPackage, zig, gtk4, pkg-config, libjpeg, libadwaita, librsvg, libgee, vala, coreutils, wrapGAppsHook4, }: stdenvNoCC.mkDerivation { pname = "plac-gtk-adwaita"; version = "0.0.0"; src = ../.; meta = { mainProgram = "jp.pocka.plac.gtk-adwaita"; }; nativeBuildInputs = [ zig.hook # > Zig is a general-purpose programming language and toolchain for maintaining # > robust, optimal and reusable software. # https://ziglang.org/ zig # > pkg-config is a helper tool used when compiling applications and libraries. # https://www.freedesktop.org/wiki/Software/pkg-config/ pkg-config # > Vala is a programming language using modern high level abstractions without # > imposing additional runtime requirements and without using a different ABI # > compared to applications and libraries written in C. # https://vala.dev/ vala # Installed for "realpath" command. coreutils ]; buildInputs = [ # > The GTK toolkit # https://docs.gtk.org/gtk4/ gtk4 # > Faster (using SIMD) libjpeg implementation # https://libjpeg-turbo.org/ libjpeg # Provides styles and widgets following GNOME Human Interface Guideline. # (https://developer.gnome.org/hig/index.html) # > Building blocks for modern GNOME applications # https://gnome.pages.gitlab.gnome.org/libadwaita/ # https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1-latest/ # https://valadoc.org/libadwaita-1/index.htm libadwaita # Required library for GTK4/Adwaita, albeit undocumented. # > A library to render SVG images to Cairo surfaces. # https://gitlab.gnome.org/GNOME/librsvg librsvg # Necessary library to use collection types such as HashMap and ArrayList in Vala. # https://wiki.gnome.org/Projects/Libgee # https://valadoc.org/gee-0.8/index.htm libgee # This helper takes care of GLib/GTK's messy runtime things. # https://nixos.org/manual/nixpkgs/stable/#sec-language-gnome wrapGAppsHook4 ]; zigBuildFlags = [ "--system" (callPackage ../../core/nix/deps.nix { }) "-Dcompile-gschema" ]; # zon2nix does not support path dependency, and Zig seems not to have a way to set multiple # dependencies directory nor specify directory name via flags. postPatch = '' substituteInPlace build.zig.zon \ --replace "../core" $(realpath --relative-to=. ${callPackage ../../core/nix/package.nix { }}) ''; }
-
-
gtk-adwaita/src/Application.vala (deleted)
-
@@ -1,137 +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 PlacGtkAdwaita { public class App : Adw.Application { private static GLib.ActionEntry[] app_entries = { GLib.ActionEntry() { name = "preferences", activate = on_preferences, }, GLib.ActionEntry() { name = "about", activate = on_about, }, GLib.ActionEntry() { name = "quit", activate = on_quit, }, }; private Settings settings = new Settings(); public App(){ Object( application_id: "jp.pocka.plac.gtk-adwaita", flags : ApplicationFlags.DEFAULT_FLAGS ); } protected override void activate() { if (settings.connected_server_id == "") { var selector = new ServerSelector.Window(this); selector.start(); } else { var main_window = new MainWindow.from_server_id(this, settings.connected_server_id); if (settings.connected_server_addr != "" && settings.connected_server_port > 0) { main_window.start_with_addr( settings.connected_server_addr, settings.connected_server_port ); } else { main_window.start(); } } } public static int main(string[] args) { return new App().run(args); } private void on_preferences() { var label_parsing_enabled = new Adw.SwitchRow(); settings.settings.bind(Settings.LABEL_PARSING_ENABLED, label_parsing_enabled, "active", DEFAULT); label_parsing_enabled.title = "Parse labels"; label_parsing_enabled.subtitle = "Enable parsing of \"[[id|text]]\" labels in browse section."; var browse = new Adw.PreferencesGroup(); browse.add(label_parsing_enabled); browse.title = "Browse"; var show_seek_by_10secs = new Adw.SwitchRow(); settings.settings.bind(Settings.SHOW_SEEK_BY_10SECS, show_seek_by_10secs, "active", DEFAULT); show_seek_by_10secs.title = "Show seek by 10secs buttons"; show_seek_by_10secs.subtitle = "Show buttons that seek forward/backwards by 10 seconds."; var playback = new Adw.PreferencesGroup(); playback.add(show_seek_by_10secs); playback.title = "Playback"; var page = new Adw.PreferencesPage(); page.add(browse); page.add(playback); var dialog = new Adw.PreferencesDialog(); dialog.add(page); dialog.present(this.active_window); } private void on_about() { var dialog = new Adw.AboutDialog.from_appdata("/jp/pocka/plac/gtk-adwaita/metainfo.xml", null); // TODO: Inject these via build parameter (JSON or REUSE or whatever) dialog.copyright = "Copyright 2025 Shota FUJI"; dialog.version = "0.0.0"; dialog.add_legal_section("libsood", "Copyright 2025 Shota FUJI", APACHE_2_0, null); dialog.add_legal_section("libmoo", "Copyright 2025 Shota FUJI", APACHE_2_0, null); dialog.add_legal_section("websocket.zig", "Copyright (c) 2024 Karl Seguin.", MIT_X11, null); dialog.present(this.active_window); } private void on_quit() { this.quit(); } public override void startup() { base.startup(); this.add_action_entries(app_entries, this); } } [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/server-list-unexpected-error-dialog.ui")] class ServerListUnexpectedErrorDialog : GenericErrorDialog { public ServerListUnexpectedErrorDialog(string details) { Object(details: details); } } [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/server-list-network-error-dialog.ui")] class ServerListNetworkErrorDialog : GenericErrorDialog { public ServerListNetworkErrorDialog(string details) { Object(details: details); } } [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/server-connecting.ui")] class ServerConnecting : Gtk.Box { public ServerConnecting() { Object(); } } }
-
-
gtk-adwaita/src/Plac.vala (deleted)
-
@@ -1,254 +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 { namespace Discovery { public async Plac.Discovery.ScanResult? scan_async() { GLib.SourceFunc callback = scan_async.callback; Plac.Discovery.ScanResult? result = null; new GLib.Thread<void>("server-scanner", () => { result = Plac.Discovery.scan(); GLib.Idle.add((owned) callback); }); yield; return (owned) result; } public async Plac.Discovery.ScanResult? find_async(string server_id) { GLib.SourceFunc callback = find_async.callback; Plac.Discovery.ScanResult? result = null; new GLib.Thread<void>("server-find", () => { result = Plac.Discovery.find(server_id); GLib.Idle.add((owned) callback); }); yield; return (owned) result; } public async Plac.Discovery.ScanResult? resolve_async(string server_id, string ip_addr, uint16 http_port) { GLib.SourceFunc callback = resolve_async.callback; Plac.Discovery.ScanResult? result = null; new GLib.Thread<void>("server-resolve", () => { result = Plac.Discovery.resolve(server_id, ip_addr, http_port); GLib.Idle.add((owned) callback); }); yield; return (owned) result; } } 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 async Image.GetResult? get_image(string image_key, Image.GetOptions options) { GLib.SourceFunc callback = get_image.callback; Image.GetResult? result = null; new GLib.Thread<void>("get_image", () => { result = conn.get_image(image_key, options); GLib.Idle.add((owned) callback); }); yield; return (owned) result; } } }
-
-
gtk-adwaita/src/Settings.vala (deleted)
-
@@ -1,58 +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 PlacGtkAdwaita { private const string CONNECTED_SERVER_ID = "connected-server-id"; private const string CONNECTED_SERVER_TOKEN = "connected-server-token"; private const string CONNECTED_SERVER_ADDR = "connected-server-addr"; private const string CONNECTED_SERVER_PORT = "connected-server-port"; public class Settings { public const string LABEL_PARSING_ENABLED = "label-parsing-enabled"; public const string SHOW_SEEK_BY_10SECS = "show-seek-by-10secs"; public GLib.Settings settings; public Settings() { settings = new GLib.Settings("jp.pocka.plac.gtk-adwaita"); } public string connected_server_id { owned get { return settings.get_string(CONNECTED_SERVER_ID); } set { settings.set_string(CONNECTED_SERVER_ID, value); } } public string connected_server_token { owned get { return settings.get_string(CONNECTED_SERVER_TOKEN); } set { settings.set_string(CONNECTED_SERVER_TOKEN, value); } } public string connected_server_addr { owned get { return settings.get_string(CONNECTED_SERVER_ADDR); } set { settings.set_string(CONNECTED_SERVER_ADDR, value); } } public uint16 connected_server_port { owned get { return (uint16) settings.get_uint(CONNECTED_SERVER_PORT); } set { settings.set_uint(CONNECTED_SERVER_PORT, value); } } public bool label_parsing_enabled { owned get { return settings.get_boolean(LABEL_PARSING_ENABLED); } set { settings.set_boolean(LABEL_PARSING_ENABLED, value); } } } }
-
-
gtk-adwaita/src/Widgets/Artwork.vala (deleted)
-
@@ -1,151 +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 PlacGtkAdwaita { class Artwork : Gtk.Box { private uint16 _width = 100; public uint16 width { get { return _width; } construct set { _width = value; width_request = (int) value; } } private uint16 _height = 100; public uint16 height { get { return _height; } construct set { _height = value; height_request = (int) value; } } private Plac.AsyncConnection? _conn = null; public Plac.AsyncConnection? conn { get { return _conn; } set { if (_conn != value) { _conn = value; render(); } } } 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; } private Gtk.Picture picture = new Gtk.Picture(); private Adw.Spinner spinner = new Adw.Spinner(); private Gtk.Image error_icon = new Gtk.Image.from_icon_name("image-missing-symbolic"); public Artwork() { Object(); } public Artwork.new_with_size(uint16 width, uint16 height) { Object(width: width, height: height); } construct { picture.set_parent(this); picture.accessible_role = PRESENTATION; picture.halign = CENTER; picture.valign = CENTER; picture.width_request = _width; picture.height_request = _height; picture.content_fit = SCALE_DOWN; picture.add_css_class("plac-playback-artwork"); picture.visible = false; spinner.set_parent(this); spinner.halign = CENTER; spinner.valign = CENTER; spinner.width_request = _width; spinner.height_request = _height; spinner.visible = true; error_icon.set_parent(this); error_icon.halign = CENTER; error_icon.valign = CENTER; error_icon.width_request = _width; error_icon.height_request = _height; error_icon.pixel_size = _width / 2; error_icon.visible = false; render(); } public void render() { picture.paintable = null; if (_conn == null || _image_key == null) { return; } 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); _conn.get_image.begin(_image_key, opts, (obj, res) => { var result = _conn.get_image.end(res); if (result == null) { GLib.log("Plac", LEVEL_WARNING, "Failed to download image: Out of memory"); error_icon.visible = true; spinner.visible = false; return; } if (result.code != OK) { GLib.log("Plac", LEVEL_WARNING, "Failed to download image: %s", result.code.to_string()); error_icon.visible = true; spinner.visible = false; return; } if (result.image == null) { GLib.log("Plac", LEVEL_WARNING, "Failed to download image: image_field_missing"); error_icon.visible = true; spinner.visible = false; return; } var bytes = GLib.Bytes.new_with_owner(result.image.data, result.image); try { var texture = Gdk.Texture.from_bytes(bytes); picture.paintable = texture; spinner.visible = false; picture.visible = true; } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Failed to generate artwork texture: %s", error.message); } }); } } }
-
-
gtk-adwaita/src/Widgets/Browse.vala (deleted)
-
@@ -1,220 +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 PlacGtkAdwaita { [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/browse.ui")] class Browse : Gtk.Box { public signal void loading_start(); public signal void loading_end(); [GtkChild] private unowned Gtk.Label title; [GtkChild] private unowned Gtk.Label subtitle; [GtkChild] private unowned Gtk.Box loading; [GtkChild] private unowned Gtk.Button back; [GtkChild] private unowned Gtk.ListBox items; [GtkChild] private unowned Gtk.ScrolledWindow scroller; [GtkChild] private unowned Adw.ToastOverlay toasts; 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.Browse.Item? item = null; private bool is_loading = false; private Settings settings = new Settings(); public Browse() { Object(); } construct { items.row_activated.connect((row) => { var item_row = (BrowseItem.Row) row; item = item_row.item; switch (item.hint) { case ACTION_LIST: var nav = (BrowseItem.Navigation) item_row.child; nav.open_actions(); break; default: load(); break; } }); back.clicked.connect(() => { pop(); }); } public void start(Plac.AsyncConnection? conn) { this.conn = conn; this.load(); } private void start_loading() { is_loading = true; loading_start(); loading.visible = true; this.sensitive = false; } private void end_loading() { is_loading = false; loading_end(); loading.visible = false; this.sensitive = true; } private void pop(bool scroll_to_top = true) { this.item = null; this.load(true, scroll_to_top); } private void load(bool pop = false, bool scroll_to_top = true) { if (conn == null || _hierarchy == null || is_loading) { return; } 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()); 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; } var action = result.get_list_action(); 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; } else { subtitle.visible = false; } items.remove_all(); if (scroll_to_top) { scroller.vadjustment.value = scroller.vadjustment.lower; } foreach (var item in action.items) { var row = new BrowseItem.Row(item, conn); var nav = (BrowseItem.Navigation) row.child; nav.action_selected.connect((selected_item) => { this.item = selected_item; load(false, false); }); nav.actions_open.connect(() => { load_actions(nav, item); }); nav.actions_close.connect(() => { this.pop(false); }); items.append(row); } back.visible = action.level > 0; end_loading(); }); } private void load_actions(BrowseItem.Navigation target, Plac.Browse.Item item) { if (conn == null || _hierarchy == null || is_loading) { return; } conn.browse.begin(_hierarchy, _zone, item, false, (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()); loading.visible = false; return; } if (result.action != LIST) { GLib.log("Plac", LEVEL_WARNING, "Unexpected result action: %s", result.action.to_string()); loading.visible = false; return; } var action = result.get_list_action(); target.set_action_items(action); }); } } }
-
-
-
@@ -1,32 +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 PlacGtkAdwaita { class BrowseHierarchyRow : Gtk.ListBoxRow { public Plac.Browse.Hierarchy hierarchy { get; construct set; } public string label { get; construct set; } public BrowseHierarchyRow(Plac.Browse.Hierarchy hierarchy, string label) { Object(hierarchy: hierarchy, label: label); } construct { var text = new Gtk.Label(label); text.halign = START; this.child = text; } } }
-
-
-
@@ -1,148 +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 PlacGtkAdwaita { namespace BrowseItem { [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/browse-item-navigation.ui")] class Navigation : Gtk.Box { public signal void action_selected(Plac.Browse.Item item); public signal void actions_open(); public signal void actions_close(); [GtkChild] private unowned Gtk.Label title; [GtkChild] private unowned Gtk.Label subtitle; [GtkChild] private unowned Artwork artwork; [GtkChild] private unowned Gtk.Image icon; [GtkChild] private unowned Gtk.MenuButton action_menu; [GtkChild] private unowned Gtk.Popover action_menu_popover; [GtkChild] private unowned Gtk.ListBox action_menu_list; private bool is_popover_closed_by_submission = false; public Plac.Browse.Item item { get; construct; } private Settings settings = new Settings(); public Plac.AsyncConnection? conn { get { return artwork.conn; } set construct { artwork.conn = value; } } public Navigation(Plac.Browse.Item item, Plac.AsyncConnection? conn = null) { Object(item: item, conn: conn); } construct { if (settings.label_parsing_enabled) { title.label = new Plac.Browse.Label(item.title).plain_text; } else { title.label = item.title; } if (item.subtitle != null) { if (settings.label_parsing_enabled) { subtitle.label = new Plac.Browse.Label(item.subtitle).plain_text; } else { subtitle.label = item.subtitle; } subtitle.visible = true; } if (item.image_key != null) { artwork.conn = conn; artwork.image_key = item.image_key; artwork.visible = true; } switch (item.hint) { case LIST: icon.icon_name = "go-next-symbolic"; break; case ACTION_LIST: icon.visible = false; action_menu.visible = true; break; default: icon.visible = false; break; } action_menu_list.row_activated.connect((row) => { is_popover_closed_by_submission = true; var action_row = (ActionRow) row; item = action_row.item; action_selected(item); }); action_menu_popover.show.connect(() => { is_popover_closed_by_submission = false; actions_open(); }); action_menu_popover.closed.connect(() => { if (!is_popover_closed_by_submission) { actions_close(); } }); } public void open_actions() { action_menu.popup(); } public void set_action_items(Plac.Browse.ListAction list) { action_menu_list.remove_all(); bool set_focus = false; foreach (var item in list.items) { var row = new ActionRow(item); action_menu_list.append(row); if (!set_focus) { row.grab_focus(); set_focus = true; } } } } class ActionRow : Gtk.ListBoxRow { private Gtk.Label label; public Plac.Browse.Item item { get; construct set; } public ActionRow(Plac.Browse.Item item) { Object(item: item); } construct { label = new Gtk.Label(item.title); this.child = label; this.activatable = true; } } } }
-
-
-
@@ -1,48 +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 PlacGtkAdwaita { namespace BrowseItem { class Row : Gtk.ListBoxRow { public Plac.Browse.Item item { get; construct; } public Plac.AsyncConnection? conn { get; construct; } public Row(Plac.Browse.Item item, Plac.AsyncConnection? conn = null) { Object(item: item, conn: conn); } construct { if (item.item_key != null) { switch (item.hint) { case LIST: case ACTION: case ACTION_LIST: this.activatable = true; break; default: this.activatable = false; break; } } this.child = new Navigation(item, conn); } } } }
-
-
-
@@ -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 namespace PlacGtkAdwaita { [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/generic-error-dialog.ui")] class GenericErrorDialog : Adw.Dialog { [GtkChild] private unowned Gtk.Label description_label; [GtkChild] private unowned Gtk.Label details_label; public string description { get { return description_label.label; } set { description_label.label = value; } } public string details { get { return details_label.label; } set { details_label.label = value; } } public GenericErrorDialog() { Object(); } } }
-
-
-
@@ -1,468 +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 PlacGtkAdwaita { [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/playback-toolbar.ui")] class PlaybackToolbar : Gtk.Box { public signal void zone_selected(Plac.Transport.Zone? zone); [GtkChild] private unowned Gtk.Box controls; [GtkChild] private unowned Gtk.Button play; [GtkChild] private unowned Gtk.Button pause; [GtkChild] private unowned Gtk.Button prev; [GtkChild] private unowned Gtk.Button next; [GtkChild] private unowned Gtk.Button seek_backwards_10; [GtkChild] private unowned Gtk.Button seek_forward_10; [GtkChild] private unowned Gtk.Scale seek; [GtkChild] private unowned Gtk.Label playing_line1; [GtkChild] private unowned Gtk.Label playing_line2; [GtkChild] private unowned Gtk.Popover zone_list_popover; [GtkChild] private unowned Gtk.DropDown zone_list; [GtkChild] private unowned Gtk.ListBox zone_outputs; [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.Transport.Zone? zone { get { if (selected != null) { return selected.zone; } return null; } } private string? zone_id { get { if (zone != null) { return zone.id; } return null; } } public Plac.AsyncConnection? conn { get { return artwork.conn; } set { if (artwork.conn != null) { artwork.conn.zones_changed.disconnect(on_zone_change); } artwork.conn = value; value.zones_changed.connect(on_zone_change); } } private void on_zone_change(Plac.Transport.ZoneListEvent event) { bool should_set_zone = this.selected == null; 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); } foreach (Plac.Transport.Zone zone in event.added) { upsert_zone(zone); } foreach (Plac.Transport.Zone zone in event.changed) { upsert_zone(zone); } 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 (should_set_zone) { var first = zones.get_item(0); if (first != null) { this.selected = (ZoneWrapper) first; } else { this.selected = null; } } 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; } 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) => { var label = new Gtk.Label(""); ((Gtk.ListItem) item).child = label; }); 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; }); 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(); }); play.clicked.connect(() => { if (conn == null || zone == null) { return; } this.disable_actions(); conn.control.begin(zone, Plac.Transport.ACTION_PLAY, (obj, res) => { conn.control.end(res); this.enable_actions(); }); }); pause.clicked.connect(() => { if (conn == null || zone == null) { return; } this.disable_actions(); conn.control.begin(zone, Plac.Transport.ACTION_PAUSE, (obj, res) => { conn.control.end(res); this.enable_actions(); }); }); prev.clicked.connect(() => { if (conn == null || zone == null) { return; } this.disable_actions(); conn.control.begin(zone, Plac.Transport.ACTION_PREV, (obj, res) => { conn.control.end(res); this.enable_actions(); }); }); next.clicked.connect(() => { if (conn == null || zone == null) { return; } this.disable_actions(); conn.control.begin(zone, Plac.Transport.ACTION_NEXT, (obj, res) => { conn.control.end(res); this.enable_actions(); }); }); seek.change_value.connect((scroll, value) => { schedule_seek((int64) value); return false; }); seek_backwards_10.clicked.connect(() => { var next_value = seek.get_value() - 10; seek.set_value(next_value); schedule_seek((int64) next_value); }); seek_forward_10.clicked.connect(() => { var next_value = seek.get_value() + 10; 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); settings.settings.bind(Settings.SHOW_SEEK_BY_10SECS, seek_forward_10, "visible", GET); this.render(); } private void dequeue_seek() { if (conn == null || next_seek == null || zone == null) { is_seeking = false; return; } 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()); } dequeue_seek(); }); } private void schedule_seek(int64 position) { next_seek = position; if (!is_seeking) { is_seeking = true; dequeue_seek(); } } private string format_seconds(uint64 secs) { var s = secs % 60; var m = (secs / 60) % 60; var h = secs / 360; if (h == 0) { return "%02u:%02u".printf((uint) m, (uint) s); } // I don't care 32 bit platforms. Also I doubt Roon can handle a song // of a duration longer than max(uint32) return "%02u:%02u:%02u".printf((uint) h, (uint) m, (uint) s); } private void render() { var zone = this.zone; if (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; 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 : ""; artwork.image_key = zone.now_playing.image_key; } else { playing_line1.label = ""; playing_line2.label = ""; artwork.image_key = null; } if ( zone.now_playing != null && zone.now_playing.has_length && zone.now_playing.has_seek_position ) { seek.sensitive = true; seek.set_range(0, zone.now_playing.length); seek.set_value(zone.now_playing.seek_position); seek.tooltip_text = format_seconds(zone.now_playing.seek_position); } else { seek.sensitive = false; seek.set_range(0, 1); seek.set_value(0); seek.tooltip_text = "Not playing"; } switch (zone.playback) { case LOADING: case STOPPED: case PAUSED: play.visible = true; pause.visible = false; break; case PLAYING: play.visible = false; pause.visible = true; break; } stale_outputs(); foreach (var output in zone.outputs) { update_output(output); } remove_stale_outputs(); } private void stale_outputs() { int i = 0; while (true) { var row = (ZoneOutputRow?) zone_outputs.get_row_at_index(i++); if (row == null) { return; } row.stale = true; } } private void remove_stale_outputs() { var rows_to_remove = new Gee.ArrayList<ZoneOutputRow>(); int i = 0; while (true) { var row = (ZoneOutputRow?) zone_outputs.get_row_at_index(i++); if (row == null) { break; } if (row.stale) { rows_to_remove.add(row); } } foreach (var row in rows_to_remove) { zone_outputs.remove(row); } } private void update_output(Plac.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); zone_outputs.append(new_row); return; } if (row.output_id != output.id) { continue; } row.output = output; row.stale = false; return; } } private void disable_actions() { zone_list_popover.sensitive = false; controls.sensitive = false; seek.sensitive = false; } private void enable_actions() { 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; } public ZoneWrapper(Plac.Transport.Zone zone) { Object(zone: zone); } public static bool is_equal(ZoneWrapper a, ZoneWrapper b) { return a.zone.id == b.zone.id; } } }
-
-
-
@@ -1,183 +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 PlacGtkAdwaita { [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/zone-output-row.ui")] class ZoneOutputRow : Gtk.ListBoxRow { [GtkChild] private unowned Gtk.Label display_name; [GtkChild] private unowned Gtk.Box incremental_control; [GtkChild] private unowned Gtk.Box value_control; [GtkChild] private unowned Gtk.Scale volume_slider; [GtkChild] private unowned Gtk.Button volume_up_incr; [GtkChild] private unowned Gtk.Button volume_up; [GtkChild] private unowned Gtk.Button volume_down_incr; [GtkChild] private unowned Gtk.Button volume_down; public bool stale = false; public string output_id { get { return _output.id; } } public Plac.AsyncConnection? conn { get; set construct; } private Plac.Transport.Output _output; public Plac.Transport.Output output { get { return _output; } set construct { _output = value; display_name.label = value.display_name; if (value.is_incremental_volume) { 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.upper != output.volume.max) { volume_slider.adjustment.upper = output.volume.max; } if (!is_moving_volume && volume_slider.adjustment.value != output.volume.value) { volume_slider.adjustment.value = output.volume.value; } if (volume_slider.adjustment.step_increment != output.volume.step) { volume_slider.adjustment.step_increment = output.volume.step; } } } } private bool is_incrementing_volume = false; // true = increase, false = decrease private Gee.ArrayQueue<bool>volume_increment_queue = new Gee.ArrayQueue<bool>(); private bool is_moving_volume = false; private double? next_volume_move_to = null; public ZoneOutputRow(Plac.Transport.Output output) { Object(output: output); } construct { volume_up_incr.clicked.connect(() => { queue_incremental_volume_change(true); }); 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); }); volume_down_incr.clicked.connect(() => { queue_incremental_volume_change(false); }); 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); }); volume_slider.change_value.connect((scroll, value) => { schedule_volume_change(value); return false; }); } private void queue_incremental_volume_change(bool is_increase) { volume_increment_queue.offer(is_increase); if (!is_incrementing_volume) { is_incrementing_volume = true; dequeue_incremental_volume_change(); } } private void dequeue_incremental_volume_change() { if (conn == null || volume_increment_queue.is_empty) { is_incrementing_volume = false; return; } 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()); } dequeue_incremental_volume_change(); }); } } private void schedule_volume_change(double value) { next_volume_move_to = value; if (!is_moving_volume) { is_moving_volume = true; take_volume_change(); } } private void take_volume_change() { if (conn == null || next_volume_move_to == null) { is_moving_volume = false; return; } 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()); } take_volume_change(); }); } } }
-
-
gtk-adwaita/src/Windows/MainWindow.vala (deleted)
-
@@ -1,272 +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 PlacGtkAdwaita { private errordomain ResolveError { CONNECTION_ERROR, SERVER_NOT_FOUND } [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/main-window.ui")] class MainWindow : Adw.ApplicationWindow { [GtkChild] private unowned Gtk.Stack root_stack; [GtkChild] private unowned PlaybackToolbar playback_toolbar; [GtkChild] private unowned Adw.Banner error_banner; [GtkChild] private unowned Browse browse; [GtkChild] private unowned Gtk.ListBox browse_hierarchy; private Settings settings = new Settings(); private Plac.Discovery.Server? server = null; private Plac.AsyncConnection? conn = null; private string server_id; public MainWindow(Gtk.Application app, Plac.Discovery.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; } public MainWindow.from_server_id(Gtk.Application app, string server_id) { (typeof (Artwork)).ensure(); (typeof (ServerConnecting)).ensure(); (typeof (PlaybackToolbar)).ensure(); Object(application: app); this.server_id = server_id; } construct { var provider = new Gtk.CssProvider(); provider.load_from_resource("/jp/pocka/plac/gtk-adwaita/css/main-window.css"); Gtk.StyleContext.add_provider_for_display( this.display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ); error_banner.button_clicked.connect(() => { try_listen(); }); playback_toolbar.zone_selected.connect((zone) => { browse.zone = zone; }); var explore_row = new BrowseHierarchyRow(BROWSE, "Explore"); browse_hierarchy.append(explore_row); browse_hierarchy.select_row(explore_row); browse_hierarchy.append(new BrowseHierarchyRow(ALBUMS, "Albums")); browse_hierarchy.append(new BrowseHierarchyRow(PLAYLISTS, "Playlists")); browse_hierarchy.append(new BrowseHierarchyRow(ARTISTS, "Artists")); browse_hierarchy.append(new BrowseHierarchyRow(GENRES, "Genres")); browse_hierarchy.append(new BrowseHierarchyRow(COMPOSERS, "Composers")); browse_hierarchy.append(new BrowseHierarchyRow(INTERNET_RADIO, "Internet Radio")); browse_hierarchy.append(new BrowseHierarchyRow(SETTINGS, "Settings")); browse_hierarchy.row_selected.connect((row) => { if (row == null) { return; } browse.hierarchy = ((BrowseHierarchyRow) row).hierarchy; }); browse.loading_start.connect(() => { browse_hierarchy.sensitive = false; }); browse.loading_end.connect(() => { browse_hierarchy.sensitive = true; }); } public void start() { root_stack.visible_child_name = "loading"; try_listen(); this.present(); } public void start_with_addr(string ip_addr, uint16 port) { root_stack.visible_child_name = "loading"; try_listen_with_addr(ip_addr, port); this.present(); } private void try_listen() { error_banner.revealed = false; if (conn != null) { listen_events(); } else { resolve_server.begin((obj, res) => { try { resolve_server.end(res); } catch (ResolveError e) { error_banner.title = e.message; error_banner.revealed = true; return; } listen_events(); }); } } private void try_listen_with_addr(string ip_addr, uint16 port) { if (conn != null) { listen_events(); return; } resolve_server_with_addr.begin(ip_addr, port, (obj, res) => { try { resolve_server_with_addr.end(res); } catch (ResolveError e) { GLib.log("Plac", LEVEL_INFO, "Failed to restore connection, scanning server"); try_listen(); return; } listen_events(); }); } private void listen_events() { this.title = "Plac - %s".printf(server.name); conn.connection_started.connect(() => { root_stack.visible_child_name = "loading"; playback_toolbar.visible = false; }); conn.connected.connect((event) => { root_stack.visible_child_name = "main"; playback_toolbar.visible = true; 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; browse.start(conn); }); 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; }); 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; conn.activate(); } private async void resolve_server() throws ResolveError { GLib.SourceFunc callback = resolve_server.callback; ResolveError? error = null; Plac.Discovery.find_async.begin(server_id, (obj, res) => { var result = Plac.Discovery.find_async.end(res); if (result.code != OK) { GLib.log("Plac", LEVEL_CRITICAL, "Failed to find server: %s", result.code.to_string()); Idle.add((owned) callback); error = new ResolveError.CONNECTION_ERROR("Failed to connect: %s".printf(result.code.to_string())); return; } if (result.servers.length < 1) { GLib.log("Plac", LEVEL_CRITICAL, "Server not found: ID=%s", server_id); Idle.add((owned) callback); error = new ResolveError.SERVER_NOT_FOUND("Server not found"); return; } server = result.servers[0]; conn = new Plac.AsyncConnection.with_token(server, settings.connected_server_token); Idle.add((owned) callback); }); yield; if (error != null) { throw error; } } private async void resolve_server_with_addr(string ip_addr, uint16 http_port) throws ResolveError { GLib.SourceFunc callback = resolve_server_with_addr.callback; ResolveError? error = null; Plac.Discovery.resolve_async.begin(server_id, ip_addr, http_port, (obj, res) => { var result = Plac.Discovery.resolve_async.end(res); if (result.code != OK) { GLib.log("Plac", LEVEL_INFO, "Failed to connect to server at %s:%u: %s", ip_addr, http_port, result.code.to_string()); Idle.add((owned) callback); error = new ResolveError.CONNECTION_ERROR("Failed to connect: %s".printf(result.code.to_string())); return; } if (result.servers.length < 1) { GLib.log("Plac", LEVEL_CRITICAL, "Server not found at %s:%u: ID=%s", ip_addr, http_port, server_id); Idle.add((owned) callback); error = new ResolveError.SERVER_NOT_FOUND("Server not found"); return; } server = result.servers[0]; conn = new Plac.AsyncConnection.with_token(server, settings.connected_server_token); Idle.add((owned) callback); }); yield; if (error != null) { throw error; } } } }
-
-
-
@@ -1,150 +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 PlacGtkAdwaita { namespace ServerSelector { [GtkTemplate(ui = "/jp/pocka/plac/gtk-adwaita/ui/server-list.ui")] class Window : Adw.ApplicationWindow { private enum ScanErrorKind { UNEXPECTED_ERROR, NETWORK_ERROR, } [GtkChild] private unowned Gtk.ListBox servers_list; [GtkChild] private unowned Adw.Banner failure_banner; [GtkChild] private unowned Gtk.Stack stack; [GtkChild] private unowned Gtk.Button scan_button; [GtkChild] private unowned Adw.StatusPage empty; private ulong error_detail_hid; public Window(Gtk.Application app) { Object(application: app); } public void start() { var scan_action = new SimpleAction("scan_servers", null); scan_action.activate.connect(this.scan); this.add_action(scan_action); this.present(); this.scan(); } private void scan() { failure_banner.revealed = false; if (error_detail_hid > 0) { failure_banner.disconnect(error_detail_hid); error_detail_hid = 0; } stack.visible_child_name = "loading"; scan_button.sensitive = false; Plac.Discovery.scan_async.begin((obj, res) => { var result = Plac.Discovery.scan_async.end(res); servers_list.remove_all(); switch (result.code) { case OK: break; case UNKNOWN: show_error(UNEXPECTED_ERROR, "Unexpected error"); return; case NETWORK_UNAVAILABLE: show_error(NETWORK_ERROR, "Network unavailable"); return; case SOCKET_PERMISSION_DENIED: show_error(NETWORK_ERROR, "No permission to create UDP socket"); return; case SOCKET_ERROR: show_error(NETWORK_ERROR, "Failed to operate on UDP socket"); return; case OUT_OF_MEMORY: show_error(UNEXPECTED_ERROR, "Out of memory"); return; } if (result.servers.length == 0) { servers_list.visible = false; empty.visible = true; scan_button.add_css_class("suggested-action"); stack.visible_child_name = "idle"; scan_button.sensitive = true; return; } servers_list.visible = true; empty.visible = false; foreach (Plac.Discovery.Server server in result.servers) { var row = new Adw.ActionRow(); row.title = server.name; row.subtitle = server.version; // AdwActionRow needs static widget to be activatable. However, if we link // an instance of `MainWindow` here, the application won't close due to there // is a still unclosed window. This workarounds that design flaw. var box = new Gtk.Box(HORIZONTAL, 0); row.activatable_widget = box; row.activated.connect(() => { var window = new MainWindow(application, server); window.start(); this.close(); }); servers_list.append(row); } stack.visible_child_name = "idle"; scan_button.sensitive = true; }); } private void show_error(ScanErrorKind kind, string message) { scan_button.add_css_class("suggested-action"); error_detail_hid = failure_banner.button_clicked.connect(() => { switch (kind) { case NETWORK_ERROR: var dialog = new ServerListNetworkErrorDialog(message); dialog.present(this); break; case UNEXPECTED_ERROR: var dialog = new ServerListUnexpectedErrorDialog(message); dialog.present(this); break; } }); failure_banner.revealed = true; stack.visible_child_name = "idle"; scan_button.sensitive = true; } } } }
-
-
uncrustify.cfg (deleted)
-
@@ -1,175 +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 # # === # # Config file for Uncrustify. # "Source Code Beautifier for C, C++, C#, ObjectiveC, D, Java, Pawn and VALA" # https://uncrustify.sourceforge.net/ # The type of line endings. newlines = lf # The size of tabs in the output (only used if align_with_tabs=true). output_tab_size = 8 # Add or remove the UTF-8 BOM (recommend 'remove'). utf8_bom = remove # If the file contains bytes with values between 128 and 255, but is not # UTF-8, then output as UTF-8. utf8_byte = true # Force the output encoding to UTF-8. utf8_force = true # Add or remove space around non-assignment symbolic operators ('+', '/', '%', # '<<', and so forth). sp_arith = add # Add or remove space around assignment operator '=', '+=', etc. sp_assign = add # Add or remove space around assignment operator '=' in a prototype. # # If set to ignore, use sp_assign. sp_assign_default = add # Add or remove space before assignment operator '=', '+=', etc. # # Overrides sp_assign. sp_before_assign = add # Add or remove space after assignment operator '=', '+=', etc. # # Overrides sp_assign. sp_after_assign = add # Add or remove space around boolean operators '&&' and '||'. sp_bool = add # Add or remove space around compare operator '<', '>', '==', etc. sp_compare = add # Add or remove space inside '(' and ')'. sp_inside_paren = remove # Add or remove space inside function '(' and ')'. sp_inside_fparen = remove # Add or remove space between nested parentheses, i.e. '((' vs. ') )'. sp_paren_paren = remove # Add or remove space between back-to-back parentheses, i.e. ')(' vs. ') ('. sp_cparen_oparen = remove # Add or remove space between ')' and '{'. sp_paren_brace = add # Add or remove space between nested braces, i.e. '{{' vs. '{ {'. sp_brace_brace = remove # Add or remove space between '>' and '(' as found in 'new List<byte>(foo);'. sp_angle_paren = remove # Add or remove space between '>' and '()' as found in 'new List<byte>();'. sp_angle_paren_empty = remove # Add or remove space between '>' and a word as in 'List<byte> m;' or # 'template <typename T> static ...'. sp_angle_word = remove # Add or remove space before '(' of control statements ('if', 'for', 'switch', # 'while', etc.). sp_before_sparen = add # Add or remove space inside '(' and ')' of control statements other than # 'for'. sp_inside_sparen = remove # Add or remove space between ')' and '{' of control statements. sp_sparen_brace = add # Add or remove space after ',', i.e. 'a,b' vs. 'a, b'. sp_after_comma = add # (C#, Vala) Add or remove space between ',' and ']' in multidimensional array type # like 'int[,,]'. sp_after_mdatype_commas = remove # (C#, Vala) Add or remove space between '[' and ',' in multidimensional array type # like 'int[,,]'. sp_before_mdatype_commas = remove # (C#, Vala) Add or remove space between ',' in multidimensional array type # like 'int[,,]'. sp_between_mdatype_commas = remove # Add or remove space between a type and ':'. sp_type_colon = add # Add or remove space inside struct/union '{' and '}'. sp_inside_braces_struct = add # Add or remove space inside '{' and '}'. sp_inside_braces = add # Add or remove space inside '{}'. # if empty. sp_inside_braces_empty = remove # Add or remove space between type and open brace of an unnamed temporary # direct-list-initialization. sp_type_brace_init_lst = add # Add or remove space between function name and '(' on function declaration. sp_func_proto_paren = remove # Add or remove space between function name and '(' with a typedef specifier. sp_func_type_paren = remove # Add or remove space between alias name and '(' of a non-pointer function type typedef. sp_func_def_paren = remove # Add or remove space between ')' and '{' of a function call in object # initialization. # # Overrides sp_fparen_brace. sp_fparen_brace_initializer = add # Add or remove space between function name and '(' on function calls. sp_func_call_paren = remove # Add or remove space between a constructor/destructor and the open # parenthesis. sp_func_class_paren = remove # Add or remove space between 'else' and '{' if on the same line. sp_else_brace = force # Add or remove space between '}' and 'else' if on the same line. sp_brace_else = force # Whether the 'class' body is indented. indent_class = true # How to indent a close parenthesis after a newline. # # 0: Indent to body level (default) # 1: Align under the open parenthesis # 2: Indent to the brace level # -1: Preserve original indentation indent_paren_close = 2
-