From 6c0a78bbb48e28e625519f7f11bc0eb238ccfa5d Mon Sep 17 00:00:00 2001 From: LordMZTE Date: Tue, 17 May 2022 17:08:53 +0200 Subject: [PATCH] rewrite playtwitch in zirewrite playtwitch in zigg --- justfile | 4 +- scripts/playtwitch/.gitignore | 4 +- scripts/playtwitch/Cargo.toml | 13 --- scripts/playtwitch/build.zig | 38 +++++++ scripts/playtwitch/src/ffi.zig | 16 +++ scripts/playtwitch/src/gui.rs | 130 ----------------------- scripts/playtwitch/src/gui.zig | 179 ++++++++++++++++++++++++++++++++ scripts/playtwitch/src/main.rs | 78 -------------- scripts/playtwitch/src/main.zig | 77 ++++++++++++++ 9 files changed, 314 insertions(+), 225 deletions(-) delete mode 100644 scripts/playtwitch/Cargo.toml create mode 100644 scripts/playtwitch/build.zig create mode 100644 scripts/playtwitch/src/ffi.zig delete mode 100644 scripts/playtwitch/src/gui.rs create mode 100644 scripts/playtwitch/src/gui.zig delete mode 100644 scripts/playtwitch/src/main.rs create mode 100644 scripts/playtwitch/src/main.zig diff --git a/justfile b/justfile index e733bf6..bd131ac 100644 --- a/justfile +++ b/justfile @@ -11,7 +11,7 @@ yaml-language-server install-scripts target=(`echo $HOME` + "/.local/bin"): build-scripts cp scripts/randomwallpaper/target/release/randomwallpaper {{target}}/randomwallpaper - cp scripts/playtwitch/target/release/playtwitch {{target}}/playtwitch + cp scripts/playtwitch/zig-out/bin/playtwitch {{target}}/playtwitch ln -sf \ `pwd`/scripts/{start-joshuto,withjava} \ @@ -20,7 +20,7 @@ install-scripts target=(`echo $HOME` + "/.local/bin"): build-scripts build-scripts: cargo build --release --manifest-path scripts/randomwallpaper/Cargo.toml - cargo build --release --manifest-path scripts/playtwitch/Cargo.toml + cd scripts/playtwitch && zig build -Drelease-fast install-lsps-paru: #!/bin/sh diff --git a/scripts/playtwitch/.gitignore b/scripts/playtwitch/.gitignore index 2c96eb1..e73c965 100644 --- a/scripts/playtwitch/.gitignore +++ b/scripts/playtwitch/.gitignore @@ -1,2 +1,2 @@ -target/ -Cargo.lock +zig-cache/ +zig-out/ diff --git a/scripts/playtwitch/Cargo.toml b/scripts/playtwitch/Cargo.toml deleted file mode 100644 index 3e7d253..0000000 --- a/scripts/playtwitch/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "playtwitch" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -anyhow = "1.0.56" -clap = { version = "3.1.6", features = ["derive"] } -dirs = "4.0.0" -gtk4 = "0.4.7" - diff --git a/scripts/playtwitch/build.zig b/scripts/playtwitch/build.zig new file mode 100644 index 0000000..f0a2922 --- /dev/null +++ b/scripts/playtwitch/build.zig @@ -0,0 +1,38 @@ +const std = @import("std"); + +pub fn build(b: *std.build.Builder) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard release options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. + const mode = b.standardReleaseOptions(); + + const exe = b.addExecutable("playtwitch", "src/main.zig"); + exe.setTarget(target); + exe.setBuildMode(mode); + + exe.linkLibC(); + exe.linkSystemLibrary("gtk4"); + + exe.install(); + + const run_cmd = exe.run(); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_tests = b.addTest("src/main.zig"); + exe_tests.setTarget(target); + exe_tests.setBuildMode(mode); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&exe_tests.step); +} diff --git a/scripts/playtwitch/src/ffi.zig b/scripts/playtwitch/src/ffi.zig new file mode 100644 index 0000000..a56680e --- /dev/null +++ b/scripts/playtwitch/src/ffi.zig @@ -0,0 +1,16 @@ +// partially yoinked from https://github.com/Swoogan/ziggtk +pub const c = @cImport({ + @cInclude("gtk/gtk.h"); +}); + +/// Could not get `g_signal_connect` to work. Zig says "use of undeclared identifier". Reimplemented here +pub fn connectSignal( + instance: c.gpointer, + detailed_signal: [*c]const c.gchar, + c_handler: c.GCallback, + data: c.gpointer, +) void { + var zero: u32 = 0; + const flags: *c.GConnectFlags = @ptrCast(*c.GConnectFlags, &zero); + _ = c.g_signal_connect_data(instance, detailed_signal, c_handler, data, null, flags.*); +} diff --git a/scripts/playtwitch/src/gui.rs b/scripts/playtwitch/src/gui.rs deleted file mode 100644 index fe3bb85..0000000 --- a/scripts/playtwitch/src/gui.rs +++ /dev/null @@ -1,130 +0,0 @@ -use std::{cell::Cell, rc::Rc, thread::JoinHandle}; - -use gtk4::prelude::*; - -use crate::start_streamlink; - -#[derive(Clone)] -pub struct GuiInitData { - pub quality: String, - pub chatty: bool, - pub channels: Vec, -} - -pub fn run_gui(init: GuiInitData) { - let streamlink_handle = Rc::new(Cell::new(None)); - let app = gtk4::Application::new(Some("de.mzte.playtwitch"), Default::default()); - let streamlink_handle_ = streamlink_handle.clone(); - app.connect_activate(move |app| build_ui(app, &init, streamlink_handle_.clone())); - app.run(); - - if let Some(handle) = streamlink_handle.take() { - handle.join().unwrap(); - } -} - -fn build_ui( - app: >k4::Application, - init: &GuiInitData, - streamlink_handle_out: Rc>>>, -) { - let win = gtk4::ApplicationWindow::builder() - .application(app) - .title("Pick a stream!") - .build(); - - let vbox = gtk4::Box::new(gtk4::Orientation::Vertical, 5); - win.set_child(Some(&vbox)); - - let quality_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 5); - let chatty_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 5); - let titlebar = gtk4::HeaderBar::new(); - titlebar.pack_start(&quality_box); - titlebar.pack_end(&chatty_box); - win.set_titlebar(Some(&titlebar)); - quality_box.append(>k4::Label::new(Some("Quality"))); - - let quality_entry = gtk4::Entry::new(); - quality_box.append(&quality_entry); - quality_entry.set_hexpand(true); - quality_entry.set_text(&init.quality); - - let chatty_switch = gtk4::Switch::builder() - .state(init.chatty) - .tooltip_text("Start Chatty with the given channel") - .build(); - chatty_box.append(&chatty_switch); - chatty_box.append(>k4::Label::new(Some("Start Chatty"))); - - let other_channel = gtk4::Entry::builder() - .placeholder_text("Other Channel...") - .hexpand(true) - .build(); - vbox.append(&other_channel); - - // focus other channel initially - vbox.set_focus_child(Some(&other_channel)); - - let win_ = win.clone(); - let quality_entry_ = quality_entry.clone(); - let streamlink_handle_out_ = streamlink_handle_out.clone(); - let chatty_switch_ = chatty_switch.clone(); - other_channel.connect_activate(move |this| { - let channel = this.text().to_string(); - let quality = quality_entry_.text().to_string(); - let chatty = chatty_switch_.state(); - - streamlink_handle_out_.set(Some(std::thread::spawn(move || { - if let Err(e) = start_streamlink(&channel, &quality, chatty) { - eprintln!("Streamlink Error: {:?}", e); - } - }))); - - win_.close(); - }); - - let list = gtk4::ListBox::new(); - vbox.append( - >k4::Frame::builder() - .child( - >k4::ScrolledWindow::builder() - .child(&list) - .vexpand(true) - .vscrollbar_policy(gtk4::PolicyType::Always) - .hscrollbar_policy(gtk4::PolicyType::Automatic) - .build(), - ) - .label("Quick Channels") - .build(), - ); - - for channel in init.channels.iter() { - let entry = gtk4::ListBoxRow::new(); - entry.set_child(Some( - >k4::Label::builder() - .label(channel) - .halign(gtk4::Align::Start) - .build(), - )); - - list.append(&entry); - } - - let win_ = win.clone(); - list.connect_row_activated(move |_, row| { - let label = row.child().unwrap().downcast::().unwrap(); - let quality = quality_entry.text().to_string(); - let channel = label.text().to_string(); - let chatty = chatty_switch.state(); - - streamlink_handle_out.set(Some(std::thread::spawn(move || { - if let Err(e) = start_streamlink(&channel, &quality, chatty) { - eprintln!("Streamlink Error: {:?}", e); - } - }))); - - win_.close(); - }); - - win.show(); -} diff --git a/scripts/playtwitch/src/gui.zig b/scripts/playtwitch/src/gui.zig new file mode 100644 index 0000000..5879c05 --- /dev/null +++ b/scripts/playtwitch/src/gui.zig @@ -0,0 +1,179 @@ +const std = @import("std"); +const ffi = @import("ffi.zig"); +const c = ffi.c; + +pub const GuiState = struct { + alloc: std.mem.Allocator, + /// An arena allocator used to store userdata for widgets of the UI + udata_arena: std.mem.Allocator, + + streamlink_child: ?*std.ChildProcess = null, + chatty_child: ?*std.ChildProcess = null, +}; + +pub fn activate(app: *c.GtkApplication, state: *GuiState) void { + const win = c.gtk_application_window_new(app); + c.gtk_window_set_title(@ptrCast(*c.GtkWindow, win), "Pick a stream!"); + + const titlebar = c.gtk_header_bar_new(); + c.gtk_window_set_titlebar(@ptrCast(*c.GtkWindow, win), titlebar); + + const left_titlebar = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 5); + c.gtk_header_bar_pack_start(@ptrCast(*c.GtkHeaderBar, titlebar), left_titlebar); + + c.gtk_box_append(@ptrCast(*c.GtkBox, left_titlebar), c.gtk_label_new("Quality")); + + const quality_buffer = c.gtk_entry_buffer_new("best", -1); + const quality_entry = c.gtk_entry_new_with_buffer(quality_buffer); + c.gtk_box_append(@ptrCast(*c.GtkBox, left_titlebar), quality_entry); + + const right_titlebar = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 5); + c.gtk_header_bar_pack_end(@ptrCast(*c.GtkHeaderBar, titlebar), right_titlebar); + + const chatty_switch = c.gtk_switch_new(); + c.gtk_box_append(@ptrCast(*c.GtkBox, right_titlebar), chatty_switch); + + c.gtk_switch_set_active(@ptrCast(*c.GtkSwitch, chatty_switch), 1); + + c.gtk_box_append(@ptrCast(*c.GtkBox, right_titlebar), c.gtk_label_new("Start Chatty")); + + const content = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 5); + c.gtk_window_set_child(@ptrCast(*c.GtkWindow, win), content); + + const other_stream_buffer = c.gtk_entry_buffer_new(null, -1); + const other_stream_entry = c.gtk_entry_new_with_buffer(other_stream_buffer); + c.gtk_box_append(@ptrCast(*c.GtkBox, content), other_stream_entry); + + c.gtk_entry_set_placeholder_text(@ptrCast(*c.GtkEntry, other_stream_entry), "Other Channel..."); + const other_act_data = state.udata_arena.create(OtherStreamActivateData) catch return; + other_act_data.* = OtherStreamActivateData{ + .state = state, + .buf = other_stream_buffer, + .win = @ptrCast(*c.GtkWindow, win), + .chatty_switch = @ptrCast(*c.GtkSwitch, chatty_switch), + }; + + ffi.connectSignal( + other_stream_entry, + "activate", + @ptrCast(c.GCallback, on_other_stream_activate), + other_act_data, + ); + + const frame = c.gtk_frame_new("Quick Pick"); + c.gtk_box_append(@ptrCast(*c.GtkBox, content), frame); + + const scroll = c.gtk_scrolled_window_new(); + c.gtk_frame_set_child(@ptrCast(*c.GtkFrame, frame), scroll); + + c.gtk_widget_set_hexpand(scroll, 1); + c.gtk_widget_set_vexpand(scroll, 1); + c.gtk_scrolled_window_set_policy( + @ptrCast(*c.GtkScrolledWindow, scroll), + c.GTK_POLICY_AUTOMATIC, + c.GTK_POLICY_ALWAYS, + ); + + const list = c.gtk_list_box_new(); + c.gtk_scrolled_window_set_child(@ptrCast(*c.GtkScrolledWindow, scroll), list); + + const act_data = state.udata_arena.create(RowActivateData) catch return; + + act_data.* = RowActivateData{ + .state = state, + .win = @ptrCast(*c.GtkWindow, win), + .chatty_switch = @ptrCast(*c.GtkSwitch, chatty_switch), + }; + + ffi.connectSignal(list, "row-activated", @ptrCast(c.GCallback, on_row_activate), act_data); + + channels: { + const channels_data = read_channels(state.alloc) catch |e| { + std.log.err("Failed to read channels: {}", .{e}); + break :channels; + }; + defer state.alloc.free(channels_data); + + var name_buf: [64]u8 = undefined; + + var channels_iter = std.mem.split(u8, channels_data, "\n"); + while (channels_iter.next()) |s| { + if (s.len > 63) { + @panic("Can't have channel name >63 chars!"); + } + std.mem.copy(u8, &name_buf, s); + name_buf[s.len] = 0; + + const label = c.gtk_label_new(&name_buf); + c.gtk_list_box_append(@ptrCast(*c.GtkListBox, list), label); + c.gtk_widget_set_halign(label, c.GTK_ALIGN_START); + } + } + + c.gtk_widget_show(win); +} + +fn read_channels(alloc: std.mem.Allocator) ![]u8 { + const home = try std.os.getenv("HOME") orelse error.HomeNotSet; + const fname = try std.fmt.allocPrint(alloc, "{s}/.config/playtwitch/channels", .{home}); + defer alloc.free(fname); + const file = try std.fs.cwd().openFile(fname, .{}); + return try file.readToEndAlloc(alloc, 1024 * 1024 * 5); +} + +const RowActivateData = struct { + state: *GuiState, + win: *c.GtkWindow, + chatty_switch: *c.GtkSwitch, +}; + +fn on_row_activate(list: *c.GtkListBox, row: *c.GtkListBoxRow, data: *RowActivateData) void { + _ = list; + const label = c.gtk_list_box_row_get_child(row); + const channel_name = c.gtk_label_get_text(@ptrCast(*c.GtkLabel, label)); + + start( + data.state, + if (c.gtk_switch_get_active(data.chatty_switch) == 0) false else true, + std.mem.sliceTo(channel_name, 0), + ) catch |err| std.log.err("Failed to start children: {}", .{err}); + + c.gtk_window_close(data.win); +} + +const OtherStreamActivateData = struct { + state: *GuiState, + buf: *c.GtkEntryBuffer, + win: *c.GtkWindow, + chatty_switch: *c.GtkSwitch, +}; + +fn on_other_stream_activate(entry: *c.GtkEntry, data: *OtherStreamActivateData) void { + _ = entry; + start( + data.state, + if (c.gtk_switch_get_active(data.chatty_switch) == 0) false else true, + c.gtk_entry_buffer_get_text(data.buf)[0..c.gtk_entry_buffer_get_length(data.buf)], + ) catch |err| std.log.err("Failed to start children: {}", .{err}); + + c.gtk_window_close(data.win); +} + +fn start(state: *GuiState, chatty: bool, channel: []const u8) !void { + if (channel.len == 0) { + return; + } + + const url = try std.fmt.allocPrint(state.alloc, "https://twitch.tv/{s}", .{channel}); + const streamlink_argv = [_][]const u8{ "streamlink", url }; + const streamlink_child = try std.ChildProcess.init(&streamlink_argv, state.alloc); + try streamlink_child.spawn(); + state.streamlink_child = streamlink_child; + + if (chatty) { + const chatty_argv = [_][]const u8{ "chatty", "-connect", "-channel", channel }; + const chatty_child = try std.ChildProcess.init(&chatty_argv, state.alloc); + try chatty_child.spawn(); + state.chatty_child = chatty_child; + } +} diff --git a/scripts/playtwitch/src/main.rs b/scripts/playtwitch/src/main.rs deleted file mode 100644 index 73ac0c4..0000000 --- a/scripts/playtwitch/src/main.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::{ - fs::File, - io::{BufRead, BufReader}, - process::Command, -}; - -use anyhow::Context; -use clap::Parser; -use gui::GuiInitData; - -mod gui; - -#[derive(Parser)] -struct Opt { - /// Name of the channel to play. If omitted, a GUI selector is opened. - channel: Option, - - /// Quality of the stream. See streamlink docs. - #[clap(default_value = "best")] - quality: String, - - /// Start chatty with the given channel - #[clap(short, long)] - chatty: bool, -} - -fn main() -> anyhow::Result<()> { - let opt = Opt::parse(); - - if let Some(channel) = opt.channel { - start_streamlink(&channel, &opt.quality, opt.chatty)?; - } else { - let channels_path = dirs::config_dir() - .context("Couldn't get config path")? - .join("playtwitch/channels"); - - let channels = BufReader::new(File::open(channels_path)?) - .lines() - .collect::, _>>()?; - - gui::run_gui(GuiInitData { - quality: opt.quality, - chatty: opt.chatty, - channels, - }); - } - - Ok(()) -} - -fn start_streamlink(channel: &str, quality: &str, chatty: bool) -> anyhow::Result<()> { - println!( - "Starting streamlink with channel {} and quality {}", - channel, quality - ); - - let mut streamlink = Command::new("streamlink") - .arg(format!("https://twitch.tv/{}", channel)) - .arg(quality) - .spawn()?; - - let chatty = if chatty { - Some( - Command::new("chatty") - .args(["-connect", "-channel", channel]) - .spawn()?, - ) - } else { - None - }; - - streamlink.wait()?; - if let Some(mut chatty) = chatty { - chatty.wait()?; - } - - Ok(()) -} diff --git a/scripts/playtwitch/src/main.zig b/scripts/playtwitch/src/main.zig new file mode 100644 index 0000000..303088c --- /dev/null +++ b/scripts/playtwitch/src/main.zig @@ -0,0 +1,77 @@ +const std = @import("std"); +const ffi = @import("ffi.zig"); +const c = ffi.c; +const gui = @import("gui.zig"); + +pub fn log( + comptime level: std.log.Level, + comptime scope: @TypeOf(.EnumLiteral), + comptime format: []const u8, + args: anytype, +) void { + const g_level = switch (level) { + .err => c.G_LOG_LEVEL_ERROR, + .warn => c.G_LOG_LEVEL_WARNING, + .info => c.G_LOG_LEVEL_INFO, + .debug => c.G_LOG_LEVEL_DEBUG, + }; + + const s = std.fmt.allocPrintZ( + std.heap.c_allocator, + format, + args, + ) catch return; + defer std.heap.c_allocator.free(s); + + var fields = [_]c.GLogField{ + c.GLogField{ + .key = "GLIB_DOMAIN", + .value = "playtwitch-" ++ @tagName(scope), + .length = -1, + }, + c.GLogField{ + .key = "MESSAGE", + .value = @ptrCast(*const anyopaque, s), + .length = -1, + }, + }; + + c.g_log_structured_array( + g_level, + &fields, + fields.len, + ); +} + +pub fn main() !u8 { + var udata_arena = std.heap.ArenaAllocator.init(std.heap.c_allocator); + defer udata_arena.deinit(); + + var state = gui.GuiState { + .alloc = std.heap.c_allocator, + .udata_arena = udata_arena.allocator(), + }; + + const app = c.gtk_application_new("de.mzte.playtwitch", c.G_APPLICATION_FLAGS_NONE); + defer c.g_object_unref(app); + + ffi.connectSignal(app, "activate", @ptrCast(c.GCallback, gui.activate), &state); + + const status = c.g_application_run( + @ptrCast(*c.GApplication, app), + @intCast(i32, std.os.argv.len), + @ptrCast([*c][*c]u8, std.os.argv.ptr), + ); + + if (state.streamlink_child) |ch| { + defer ch.deinit(); + _ = try ch.wait(); + } + + if (state.chatty_child) |ch| { + defer ch.deinit(); + _ = try ch.wait(); + } + + return @intCast(u8, status); +}