mirror of
https://mzte.de/git/LordMZTE/dotfiles.git
synced 2024-06-14 17:29:00 +02:00
port playtwitch to imgui
This commit is contained in:
parent
3a5991531b
commit
c8c4aaadd8
|
@ -1,5 +1,4 @@
|
|||
const std = @import("std");
|
||||
const pkgs = @import("deps.zig").pkgs;
|
||||
|
||||
pub fn build(b: *std.build.Builder) void {
|
||||
// Standard target options allows the person running `zig build` to choose
|
||||
|
@ -17,8 +16,9 @@ pub fn build(b: *std.build.Builder) void {
|
|||
exe.setBuildMode(mode);
|
||||
|
||||
exe.linkLibC();
|
||||
exe.linkSystemLibrary("gtk4");
|
||||
pkgs.addAllTo(exe);
|
||||
exe.linkSystemLibrary("cimgui");
|
||||
exe.linkSystemLibrary("glfw3");
|
||||
exe.linkSystemLibrary("glew");
|
||||
|
||||
exe.strip = mode != .Debug;
|
||||
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
deps:
|
||||
glib-log:
|
||||
git:
|
||||
url: "https://mzte.de/git/LordMZTE/zig-glib-log.git"
|
||||
ref: master
|
||||
root: src/main.zig
|
79
scripts/playtwitch/src/State.zig
Normal file
79
scripts/playtwitch/src/State.zig
Normal file
|
@ -0,0 +1,79 @@
|
|||
const std = @import("std");
|
||||
const c = @import("ffi.zig").c;
|
||||
const config = @import("config.zig");
|
||||
|
||||
mutex: std.Thread.Mutex,
|
||||
win: *c.GLFWwindow,
|
||||
|
||||
/// start chatty if true
|
||||
chatty: bool,
|
||||
chatty_alive: bool,
|
||||
|
||||
/// an array of channels, composed of slices into `channels_file_data`
|
||||
channels: ?[][]const u8,
|
||||
|
||||
/// the data of the channels configuration file
|
||||
channels_file_data: ?[]u8,
|
||||
|
||||
quality_buf: [64]u8,
|
||||
channel_name_buf: [64]u8,
|
||||
|
||||
streamlink_memfd: ?std.fs.File,
|
||||
streamlink_out: ?[]align(std.mem.page_size) u8,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn init(win: *c.GLFWwindow) !*Self {
|
||||
// on the heap so this thing doesn't move.
|
||||
const self = try std.heap.c_allocator.create(Self);
|
||||
self.* = .{
|
||||
.mutex = .{},
|
||||
.win = win,
|
||||
|
||||
.chatty = true,
|
||||
.chatty_alive = false,
|
||||
|
||||
// initialized by config loader thread
|
||||
.channels = null,
|
||||
.channels_file_data = null,
|
||||
|
||||
.quality_buf = std.mem.zeroes([64]u8),
|
||||
.channel_name_buf = std.mem.zeroes([64]u8),
|
||||
|
||||
.streamlink_memfd = null,
|
||||
.streamlink_out = null,
|
||||
};
|
||||
|
||||
std.mem.copy(u8, &self.quality_buf, "best");
|
||||
|
||||
const thread = try std.Thread.spawn(.{}, config.configLoaderThread, .{self});
|
||||
thread.detach();
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn freeStreamlinkMemfd(self: *Self) void {
|
||||
if (self.streamlink_out) |mem| {
|
||||
std.os.munmap(mem);
|
||||
self.streamlink_out = null;
|
||||
}
|
||||
|
||||
if (self.streamlink_memfd) |fd| {
|
||||
fd.close();
|
||||
self.streamlink_memfd = null;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.freeStreamlinkMemfd();
|
||||
|
||||
if (self.channels) |ch| {
|
||||
std.heap.c_allocator.free(ch);
|
||||
}
|
||||
|
||||
if (self.channels_file_data) |d| {
|
||||
std.heap.c_allocator.free(d);
|
||||
}
|
||||
|
||||
self.* = undefined;
|
||||
}
|
39
scripts/playtwitch/src/config.zig
Normal file
39
scripts/playtwitch/src/config.zig
Normal file
|
@ -0,0 +1,39 @@
|
|||
const std = @import("std");
|
||||
const c = @import("ffi.zig").c;
|
||||
const State = @import("State.zig");
|
||||
|
||||
pub fn configLoaderThread(state: *State) !void {
|
||||
const home = std.os.getenv("HOME") orelse return error.HomeNotSet;
|
||||
const channels_path = try std.fs.path.join(
|
||||
std.heap.c_allocator,
|
||||
&.{ home, ".config", "playtwitch", "channels" },
|
||||
);
|
||||
defer std.heap.c_allocator.free(channels_path);
|
||||
|
||||
const file = std.fs.cwd().openFile(channels_path, .{}) catch |e| {
|
||||
switch (e) {
|
||||
error.FileNotFound => {
|
||||
std.log.warn("Channels config file not found at {s}, skipping.", .{channels_path});
|
||||
return;
|
||||
},
|
||||
else => return e,
|
||||
}
|
||||
};
|
||||
defer file.close();
|
||||
|
||||
const channels_data = try file.readToEndAlloc(std.heap.c_allocator, std.math.maxInt(usize));
|
||||
var channels = std.ArrayList([]const u8).init(std.heap.c_allocator);
|
||||
|
||||
var channels_iter = std.mem.split(u8, channels_data, "\n");
|
||||
while (channels_iter.next()) |channel| {
|
||||
const trimmed = std.mem.trim(u8, channel, " \n\r");
|
||||
if (trimmed.len > 0)
|
||||
try channels.append(trimmed);
|
||||
}
|
||||
|
||||
state.mutex.lock();
|
||||
defer state.mutex.unlock();
|
||||
|
||||
state.channels_file_data = channels_data;
|
||||
state.channels = channels.toOwnedSlice();
|
||||
}
|
|
@ -1,28 +1,11 @@
|
|||
// partially yoinked from https://github.com/Swoogan/ziggtk
|
||||
const std = @import("std");
|
||||
pub const c = @cImport({
|
||||
// fix for a strange behavior in stage2 with self-referential definitions in C
|
||||
@cDefine("sched_priority", {});
|
||||
@cInclude("gtk/gtk.h");
|
||||
@cInclude("GL/glew.h");
|
||||
@cInclude("GLFW/glfw3.h");
|
||||
|
||||
@cDefine("CIMGUI_DEFINE_ENUMS_AND_STRUCTS", "");
|
||||
@cInclude("cimgui.h");
|
||||
|
||||
@cDefine("CIMGUI_USE_GLFW", "");
|
||||
@cDefine("CIMGUI_USE_OPENGL3", "");
|
||||
@cInclude("cimgui_impl.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.*);
|
||||
}
|
||||
|
||||
pub fn handleGError(err: *?*c.GError) !void {
|
||||
if (err.*) |e| {
|
||||
std.log.err("glib error: {s}", .{e.message});
|
||||
c.g_error_free(e);
|
||||
err.* = null;
|
||||
return error.GError;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,30 +1,53 @@
|
|||
const std = @import("std");
|
||||
const c_allocator = std.heap.c_allocator;
|
||||
const ffi = @import("ffi.zig");
|
||||
const c = ffi.c;
|
||||
const c = @import("ffi.zig").c;
|
||||
const igu = @import("ig_util.zig");
|
||||
const launch = @import("launch.zig");
|
||||
const State = @import("State.zig");
|
||||
|
||||
var chatty_alive = false;
|
||||
|
||||
pub const GuiState = struct {
|
||||
/// An arena allocator used to store userdata for widgets of the UI
|
||||
udata_arena: std.mem.Allocator,
|
||||
const StartType = union(enum) {
|
||||
none,
|
||||
channel_bar,
|
||||
channels_idx: usize,
|
||||
};
|
||||
|
||||
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!");
|
||||
c.gtk_window_set_icon_name(@ptrCast(*c.GtkWindow, win), "playtwitch");
|
||||
pub fn winContent(state: *State) !void {
|
||||
state.mutex.lock();
|
||||
defer state.mutex.unlock();
|
||||
|
||||
const titlebar = c.gtk_header_bar_new();
|
||||
c.gtk_window_set_titlebar(@ptrCast(*c.GtkWindow, win), titlebar);
|
||||
var start: StartType = .none;
|
||||
|
||||
const left_titlebar = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 5);
|
||||
c.gtk_header_bar_pack_start(@ptrCast(*c.GtkHeaderBar, titlebar), left_titlebar);
|
||||
// Chatty checkbox
|
||||
_ = c.igCheckbox("Start Chatty", &state.chatty);
|
||||
|
||||
c.gtk_box_append(@ptrCast(*c.GtkBox, left_titlebar), c.gtk_label_new("Quality"));
|
||||
// Quality input
|
||||
igu.sliceText("Quality ");
|
||||
c.igSameLine(0.0, 0.0);
|
||||
|
||||
const quality_box = c.gtk_combo_box_text_new_with_entry();
|
||||
c.gtk_box_append(@ptrCast(*c.GtkBox, left_titlebar), quality_box);
|
||||
if (c.igInputText(
|
||||
"##quality_input",
|
||||
&state.quality_buf,
|
||||
state.quality_buf.len,
|
||||
c.ImGuiInputTextFlags_EnterReturnsTrue,
|
||||
null,
|
||||
null,
|
||||
)) {
|
||||
start = .channel_bar;
|
||||
}
|
||||
|
||||
var quality_popup_pos: c.ImVec2 = undefined;
|
||||
c.igGetItemRectMin(&quality_popup_pos);
|
||||
var quality_popup_size: c.ImVec2 = undefined;
|
||||
c.igGetItemRectSize(&quality_popup_size);
|
||||
|
||||
c.igSameLine(0.0, 0.0);
|
||||
if (c.igArrowButton("##open_quality_popup", c.ImGuiDir_Down)) {
|
||||
c.igOpenPopup_Str("quality_popup", 0);
|
||||
}
|
||||
// open popup on arrow button click
|
||||
c.igOpenPopupOnItemClick("quality_popup", 0);
|
||||
|
||||
var btn_size: c.ImVec2 = undefined;
|
||||
c.igGetItemRectSize(&btn_size);
|
||||
|
||||
const preset_qualities = [_][:0]const u8{
|
||||
"best",
|
||||
|
@ -35,384 +58,116 @@ pub fn activate(app: *c.GtkApplication, state: *GuiState) void {
|
|||
"worst",
|
||||
"audio_only",
|
||||
};
|
||||
for (&preset_qualities) |quality| {
|
||||
c.gtk_combo_box_text_append(
|
||||
@ptrCast(*c.GtkComboBoxText, quality_box),
|
||||
quality.ptr, // ID
|
||||
quality.ptr, // Text
|
||||
);
|
||||
}
|
||||
_ = c.gtk_combo_box_set_active_id(@ptrCast(*c.GtkComboBox, quality_box), "best");
|
||||
|
||||
const right_titlebar = c.gtk_box_new(c.GTK_ORIENTATION_HORIZONTAL, 5);
|
||||
c.gtk_header_bar_pack_end(@ptrCast(*c.GtkHeaderBar, titlebar), right_titlebar);
|
||||
quality_popup_pos.y += quality_popup_size.y;
|
||||
quality_popup_size.x += btn_size.x;
|
||||
quality_popup_size.y += 5 + (quality_popup_size.y * @intToFloat(
|
||||
f32,
|
||||
preset_qualities.len,
|
||||
));
|
||||
|
||||
const chatty_switch = c.gtk_switch_new();
|
||||
c.gtk_box_append(@ptrCast(*c.GtkBox, right_titlebar), chatty_switch);
|
||||
c.igSetNextWindowPos(quality_popup_pos, c.ImGuiCond_Always, .{ .x = 0.0, .y = 0.0 });
|
||||
c.igSetNextWindowSize(quality_popup_size, c.ImGuiCond_Always);
|
||||
|
||||
c.gtk_switch_set_active(@ptrCast(*c.GtkSwitch, chatty_switch), 1);
|
||||
if (c.igBeginPopup("quality_popup", c.ImGuiWindowFlags_NoMove)) {
|
||||
defer c.igEndPopup();
|
||||
|
||||
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);
|
||||
|
||||
const dialog_buf = c.gtk_text_buffer_new(null);
|
||||
const dialog = streamlinkErrorDialog(@ptrCast(*c.GtkWindow, win), dialog_buf);
|
||||
|
||||
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),
|
||||
.quality_box = @ptrCast(*c.GtkComboBoxText, quality_box),
|
||||
.dialog = dialog,
|
||||
.text_buf = dialog_buf,
|
||||
};
|
||||
|
||||
ffi.connectSignal(
|
||||
other_stream_entry,
|
||||
"activate",
|
||||
@ptrCast(c.GCallback, &onOtherStreamActivate),
|
||||
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),
|
||||
.quality_box = @ptrCast(*c.GtkComboBoxText, quality_box),
|
||||
.dialog = dialog,
|
||||
.text_buf = dialog_buf,
|
||||
};
|
||||
|
||||
ffi.connectSignal(list, "row-activated", @ptrCast(c.GCallback, &onRowActivate), act_data);
|
||||
|
||||
channels: {
|
||||
const channels_data = readChannels() catch |e| {
|
||||
std.log.err("Failed to read channels: {!}", .{e});
|
||||
break :channels;
|
||||
};
|
||||
defer c_allocator.free(channels_data);
|
||||
|
||||
var name_buf: [64]u8 = undefined;
|
||||
|
||||
var channels_iter = std.mem.split(u8, channels_data, "\n");
|
||||
while (channels_iter.next()) |s| {
|
||||
const chan = std.mem.trim(u8, s, " \n\r\t");
|
||||
if (chan.len == 0) {
|
||||
continue;
|
||||
for (preset_qualities) |quality| {
|
||||
if (c.igSelectable_Bool(quality.ptr, false, 0, .{ .x = 0.0, .y = 0.0 })) {
|
||||
std.mem.set(u8, &state.quality_buf, 0);
|
||||
std.mem.copy(u8, &state.quality_buf, quality);
|
||||
}
|
||||
if (chan.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 readChannels() ![]u8 {
|
||||
const home = try (std.os.getenv("HOME") orelse error.HomeNotSet);
|
||||
const fname = try std.fmt.allocPrint(c_allocator, "{s}/.config/playtwitch/channels", .{home});
|
||||
defer c_allocator.free(fname);
|
||||
std.log.info("Reading channels from {s}", .{fname});
|
||||
const file = try std.fs.cwd().openFile(fname, .{});
|
||||
return try file.readToEndAlloc(c_allocator, 1024 * 1024 * 5);
|
||||
}
|
||||
|
||||
const RowActivateData = struct {
|
||||
state: *GuiState,
|
||||
win: *c.GtkWindow,
|
||||
chatty_switch: *c.GtkSwitch,
|
||||
quality_box: *c.GtkComboBoxText,
|
||||
dialog: *c.GtkWidget,
|
||||
text_buf: *c.GtkTextBuffer,
|
||||
};
|
||||
|
||||
fn onRowActivate(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));
|
||||
const quality = c.gtk_combo_box_text_get_active_text(data.quality_box);
|
||||
defer c.g_free(quality);
|
||||
|
||||
start(.{
|
||||
.chatty = c.gtk_switch_get_active(data.chatty_switch) != 0,
|
||||
.channel = std.mem.span(channel_name),
|
||||
.quality = std.mem.span(quality),
|
||||
.crash_dialog = data.dialog,
|
||||
.error_text_buf = data.text_buf,
|
||||
.window = data.win,
|
||||
}) catch |err| std.log.err("Failed to start children: {!}", .{err});
|
||||
|
||||
c.gtk_widget_hide(@ptrCast(*c.GtkWidget, data.win));
|
||||
}
|
||||
|
||||
const OtherStreamActivateData = struct {
|
||||
state: *GuiState,
|
||||
buf: *c.GtkEntryBuffer,
|
||||
win: *c.GtkWindow,
|
||||
chatty_switch: *c.GtkSwitch,
|
||||
quality_box: *c.GtkComboBoxText,
|
||||
dialog: *c.GtkWidget,
|
||||
text_buf: *c.GtkTextBuffer,
|
||||
};
|
||||
|
||||
fn onOtherStreamActivate(entry: *c.GtkEntry, data: *OtherStreamActivateData) void {
|
||||
_ = entry;
|
||||
const quality = c.gtk_combo_box_text_get_active_text(data.quality_box);
|
||||
defer c.g_free(quality);
|
||||
|
||||
start(.{
|
||||
.chatty = c.gtk_switch_get_active(data.chatty_switch) != 0,
|
||||
.channel = c.gtk_entry_buffer_get_text(
|
||||
data.buf,
|
||||
)[0..c.gtk_entry_buffer_get_length(data.buf)],
|
||||
.quality = std.mem.span(quality),
|
||||
.crash_dialog = data.dialog,
|
||||
.error_text_buf = data.text_buf,
|
||||
.window = data.win,
|
||||
}) catch |err| std.log.err("Failed to start children: {!}", .{err});
|
||||
|
||||
c.gtk_widget_hide(@ptrCast(*c.GtkWidget, data.win));
|
||||
}
|
||||
|
||||
pub fn streamlinkErrorDialog(parent_window: *c.GtkWindow, output: *c.GtkTextBuffer) *c.GtkWidget {
|
||||
const dialog = c.gtk_dialog_new_with_buttons(
|
||||
"Streamlink Crashed!",
|
||||
parent_window,
|
||||
c.GTK_DIALOG_MODAL,
|
||||
"_Close",
|
||||
c.GTK_RESPONSE_CLOSE,
|
||||
"_Cancel",
|
||||
c.GTK_RESPONSE_REJECT,
|
||||
@as(?*anyopaque, null),
|
||||
);
|
||||
|
||||
ffi.connectSignal(
|
||||
dialog,
|
||||
"response",
|
||||
@ptrCast(c.GCallback, &onErrorDialogResponse),
|
||||
parent_window,
|
||||
);
|
||||
|
||||
const content = c.gtk_dialog_get_content_area(@ptrCast(*c.GtkDialog, dialog));
|
||||
c.gtk_box_set_spacing(@ptrCast(*c.GtkBox, content), 5);
|
||||
c.gtk_widget_set_margin_top(content, 5);
|
||||
c.gtk_widget_set_margin_bottom(content, 5);
|
||||
c.gtk_widget_set_margin_start(content, 5);
|
||||
c.gtk_widget_set_margin_end(content, 5);
|
||||
c.gtk_box_append(
|
||||
@ptrCast(*c.GtkBox, content),
|
||||
c.gtk_label_new("Streamlink Crashed! This is the output."),
|
||||
);
|
||||
|
||||
const output_view = c.gtk_text_view_new_with_buffer(output);
|
||||
c.gtk_widget_set_hexpand(output_view, 1);
|
||||
c.gtk_text_view_set_editable(@ptrCast(*c.GtkTextView, output_view), 0);
|
||||
c.gtk_box_append(@ptrCast(*c.GtkBox, content), output_view);
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
fn onErrorDialogResponse(dialog: *c.GtkDialog, response_id: c_int, window: *c.GtkWindow) void {
|
||||
switch (response_id) {
|
||||
c.GTK_RESPONSE_DELETE_EVENT, c.GTK_RESPONSE_REJECT => {
|
||||
c.gtk_window_close(window);
|
||||
},
|
||||
c.GTK_RESPONSE_CLOSE => {
|
||||
c.gtk_widget_hide(@ptrCast(*c.GtkWidget, dialog));
|
||||
c.gtk_widget_show(@ptrCast(*c.GtkWidget, window));
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
const StartOptions = struct {
|
||||
/// if true, start chatty
|
||||
chatty: bool,
|
||||
/// name of the channel to launch
|
||||
channel: []const u8,
|
||||
/// quality parameter for streamlink
|
||||
quality: []const u8,
|
||||
/// a pointer to a GTK widget that'll be shown if streamlink crashes
|
||||
crash_dialog: *c.GtkWidget,
|
||||
/// GtkTextBuffer to save streamlink's output in in the case of a crash
|
||||
/// so it can be displayed
|
||||
error_text_buf: *c.GtkTextBuffer,
|
||||
/// the main GTK window
|
||||
window: *c.GtkWindow,
|
||||
};
|
||||
|
||||
fn start(options: StartOptions) !void {
|
||||
if (options.channel.len == 0) {
|
||||
std.log.warn("Exiting due to attempt to start empty channel", .{});
|
||||
c.gtk_window_close(options.window);
|
||||
return;
|
||||
}
|
||||
|
||||
var err: ?*c.GError = null;
|
||||
|
||||
std.log.info(
|
||||
"Starting for channel {s} with quality {s} (chatty: {})",
|
||||
.{ options.channel, options.quality, options.chatty },
|
||||
);
|
||||
const url = try std.fmt.allocPrintZ(c_allocator, "https://twitch.tv/{s}", .{options.channel});
|
||||
defer c_allocator.free(url);
|
||||
const quality_z = try std.cstr.addNullByte(c_allocator, options.quality);
|
||||
defer c_allocator.free(quality_z);
|
||||
const streamlink_argv = [_][*c]const u8{ "streamlink", url.ptr, quality_z.ptr, null };
|
||||
const streamlink_subproc = c.g_subprocess_newv(
|
||||
&streamlink_argv,
|
||||
c.G_SUBPROCESS_FLAGS_STDOUT_PIPE,
|
||||
&err,
|
||||
);
|
||||
try ffi.handleGError(&err);
|
||||
|
||||
const communicate_data = try c_allocator.create(StreamlinkCommunicateData);
|
||||
communicate_data.* = StreamlinkCommunicateData{
|
||||
.dialog = options.crash_dialog,
|
||||
.text_buf = options.error_text_buf,
|
||||
.window = options.window,
|
||||
};
|
||||
|
||||
c.g_subprocess_communicate_async(
|
||||
streamlink_subproc,
|
||||
igu.sliceText("Play Channel ");
|
||||
c.igSameLine(0.0, 0.0);
|
||||
if (c.igInputText(
|
||||
"##play_channel_input",
|
||||
&state.channel_name_buf,
|
||||
state.channel_name_buf.len,
|
||||
c.ImGuiInputTextFlags_EnterReturnsTrue,
|
||||
null,
|
||||
null,
|
||||
@ptrCast(c.GAsyncReadyCallback, &streamlinkCommunicateCb),
|
||||
communicate_data,
|
||||
);
|
||||
)) {
|
||||
start = .channel_bar;
|
||||
}
|
||||
c.igSameLine(0.0, 0.0);
|
||||
if (c.igButton("Play!", .{ .x = 0.0, .y = 0.0 })) {
|
||||
start = .channel_bar;
|
||||
}
|
||||
|
||||
if (options.chatty) {
|
||||
if (@atomicLoad(bool, &chatty_alive, .Unordered)) {
|
||||
std.log.warn("Chatty is already running, not starting again.", .{});
|
||||
return;
|
||||
if (state.channels != null and c.igBeginChild_Str(
|
||||
"Quick Pick",
|
||||
.{ .x = 0.0, .y = 0.0 },
|
||||
true,
|
||||
0,
|
||||
)) {
|
||||
for (state.channels.?) |ch, i| {
|
||||
if (ch.len >= 128) {
|
||||
std.log.err("name '{s}' too long!", .{ch});
|
||||
return error.ChannelNameTooLong;
|
||||
}
|
||||
|
||||
// add null byte
|
||||
var ch_buf: [128]u8 = undefined;
|
||||
std.mem.copy(u8, &ch_buf, ch);
|
||||
ch_buf[ch.len] = 0;
|
||||
|
||||
c.igPushID_Int(@intCast(c_int, i));
|
||||
defer c.igPopID();
|
||||
if (c.igSelectable_Bool(&ch_buf, false, 0, .{ .x = 0.0, .y = 0.0 })) {
|
||||
start = .{ .channels_idx = i };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.channels != null)
|
||||
c.igEndChild(); // END THE CHILD MWAAHAHA
|
||||
|
||||
if (state.streamlink_out) |out| {
|
||||
c.igSetNextWindowSize(.{ .x = 400.0, .y = 150.0 }, c.ImGuiCond_Appearing);
|
||||
var open = true;
|
||||
if (c.igBeginPopupModal(
|
||||
"Streamlink Crashed!",
|
||||
&open,
|
||||
c.ImGuiWindowFlags_Modal,
|
||||
)) {
|
||||
defer c.igEndPopup();
|
||||
if (c.igBeginChild_Str(
|
||||
"##output",
|
||||
.{ .x = 0.0, .y = 0.0 },
|
||||
true,
|
||||
c.ImGuiWindowFlags_HorizontalScrollbar,
|
||||
))
|
||||
igu.sliceText(out);
|
||||
c.igEndChild();
|
||||
} else {
|
||||
c.igOpenPopup_Str("Streamlink Crashed!", 0);
|
||||
}
|
||||
|
||||
var chatty_arena = std.heap.ArenaAllocator.init(c_allocator);
|
||||
const channel_d = try chatty_arena.allocator().dupe(u8, options.channel);
|
||||
const chatty_argv = [_][]const u8{ "chatty", "-connect", "-channel", channel_d };
|
||||
const chatty_argv_dup = try chatty_arena.allocator().dupe([]const u8, &chatty_argv);
|
||||
var chatty_child = std.ChildProcess.init(
|
||||
chatty_argv_dup,
|
||||
c_allocator,
|
||||
);
|
||||
|
||||
const thread = try std.Thread.spawn(
|
||||
.{},
|
||||
chattyThread,
|
||||
.{ chatty_child, chatty_arena },
|
||||
);
|
||||
thread.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn chattyThread(child: std.ChildProcess, arena: std.heap.ArenaAllocator) !void {
|
||||
@atomicStore(bool, &chatty_alive, true, .Unordered);
|
||||
defer @atomicStore(bool, &chatty_alive, false, .Unordered);
|
||||
var ch = child;
|
||||
defer arena.deinit();
|
||||
_ = ch.spawnAndWait() catch |e| {
|
||||
std.log.err("spawning chatty: {!}", .{e});
|
||||
};
|
||||
}
|
||||
|
||||
const StreamlinkCommunicateData = struct {
|
||||
dialog: *c.GtkWidget,
|
||||
text_buf: *c.GtkTextBuffer,
|
||||
window: *c.GtkWindow,
|
||||
};
|
||||
|
||||
fn streamlinkCommunicateCb(
|
||||
source_object: *c.GObject,
|
||||
res: *c.GAsyncResult,
|
||||
data: *StreamlinkCommunicateData,
|
||||
) void {
|
||||
defer c_allocator.destroy(data);
|
||||
|
||||
var err: ?*c.GError = null;
|
||||
var stdout: ?*c.GBytes = null;
|
||||
_ = c.g_subprocess_communicate_finish(
|
||||
@ptrCast(*c.GSubprocess, source_object),
|
||||
res,
|
||||
&stdout,
|
||||
null,
|
||||
&err,
|
||||
);
|
||||
ffi.handleGError(&err) catch {
|
||||
std.log.err("Failed to communicate to streamlink child!", .{});
|
||||
c.gtk_window_close(data.window);
|
||||
return;
|
||||
};
|
||||
defer c.g_bytes_unref(stdout);
|
||||
|
||||
const exit_code = c.g_subprocess_get_exit_status(@ptrCast(*c.GSubprocess, source_object));
|
||||
|
||||
if (exit_code == 0) {
|
||||
std.log.info("Streamlink exited with code 0.", .{});
|
||||
c.gtk_window_close(data.window);
|
||||
return;
|
||||
if (!open) {
|
||||
state.freeStreamlinkMemfd();
|
||||
}
|
||||
}
|
||||
|
||||
var len: usize = 0;
|
||||
const stdout_raw = @ptrCast([*c]const u8, c.g_bytes_get_data(stdout, &len));
|
||||
const stdout_data = std.mem.trimRight(u8, stdout_raw[0..len], " \n\r\t");
|
||||
|
||||
// Streamlink exits with a nonzero code if the stream ends, but we don't
|
||||
// want to count this as a crash.
|
||||
if (std.mem.containsAtLeast(u8, stdout_data, 1, "Stream ended")) {
|
||||
std.log.warn(
|
||||
\\Streamlink exited with code {d}, but output contained
|
||||
\\"Stream ended", not showing popup. Full output:
|
||||
\\{s}
|
||||
,
|
||||
.{ exit_code, stdout_data },
|
||||
);
|
||||
c.gtk_window_close(data.window);
|
||||
return;
|
||||
if (start == .channel_bar and state.channel_name_buf[0] == 0) {
|
||||
std.log.warn("Tried to start an empty stream!", .{});
|
||||
start = .none;
|
||||
}
|
||||
|
||||
c.gtk_text_buffer_set_text(
|
||||
data.text_buf,
|
||||
stdout_data.ptr,
|
||||
@intCast(c_int, stdout_data.len),
|
||||
);
|
||||
c.gtk_widget_show(data.dialog);
|
||||
switch (start) {
|
||||
.none => {},
|
||||
.channel_bar => {
|
||||
c.glfwHideWindow(state.win);
|
||||
try launch.launchChildren(
|
||||
state,
|
||||
std.mem.sliceTo(&state.channel_name_buf, 0),
|
||||
);
|
||||
},
|
||||
.channels_idx => |idx| {
|
||||
c.glfwHideWindow(state.win);
|
||||
try launch.launchChildren(state, state.channels.?[idx]);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
6
scripts/playtwitch/src/ig_util.zig
Normal file
6
scripts/playtwitch/src/ig_util.zig
Normal file
|
@ -0,0 +1,6 @@
|
|||
const std = @import("std");
|
||||
const c = @import("ffi.zig").c;
|
||||
|
||||
pub fn sliceText(text: []const u8) void {
|
||||
c.igTextUnformatted(text.ptr, text.ptr + text.len);
|
||||
}
|
124
scripts/playtwitch/src/launch.zig
Normal file
124
scripts/playtwitch/src/launch.zig
Normal file
|
@ -0,0 +1,124 @@
|
|||
const std = @import("std");
|
||||
const c = @import("ffi.zig").c;
|
||||
const State = @import("State.zig");
|
||||
|
||||
pub fn launchChildren(state: *State, channel: []const u8) !void {
|
||||
std.log.info(
|
||||
"Starting for channel {s} with quality {s} (chatty: {})",
|
||||
.{ channel, std.mem.sliceTo(&state.quality_buf, 0), state.chatty },
|
||||
);
|
||||
|
||||
// just to be safe...
|
||||
state.freeStreamlinkMemfd();
|
||||
|
||||
if (state.chatty and !state.chatty_alive) {
|
||||
var chatty_arena = std.heap.ArenaAllocator.init(std.heap.c_allocator);
|
||||
const channel_d = try chatty_arena.allocator().dupe(u8, channel);
|
||||
const chatty_argv = try chatty_arena.allocator().dupe(
|
||||
[]const u8,
|
||||
&.{ "chatty", "-connect", "-channel", channel_d },
|
||||
);
|
||||
var chatty_child = std.ChildProcess.init(chatty_argv, std.heap.c_allocator);
|
||||
|
||||
const chatty_thread = try std.Thread.spawn(
|
||||
.{},
|
||||
chattyThread,
|
||||
.{ state, chatty_child, chatty_arena },
|
||||
);
|
||||
chatty_thread.detach();
|
||||
}
|
||||
|
||||
const channel_d = try std.heap.c_allocator.dupe(u8, channel);
|
||||
const streamlink_thread = try std.Thread.spawn(
|
||||
.{},
|
||||
streamlinkThread,
|
||||
.{ state, channel_d },
|
||||
);
|
||||
streamlink_thread.detach();
|
||||
}
|
||||
|
||||
fn streamlinkThread(state: *State, channel: []const u8) !void {
|
||||
defer std.heap.c_allocator.free(channel);
|
||||
errdefer {
|
||||
state.mutex.lock();
|
||||
defer state.mutex.unlock();
|
||||
|
||||
c.glfwShowWindow(state.win);
|
||||
}
|
||||
|
||||
const memfd = try std.os.memfd_create("streamlink_out", 0);
|
||||
errdefer std.os.close(memfd);
|
||||
const memfile = std.fs.File{ .handle = memfd };
|
||||
|
||||
var arg_arena = std.heap.ArenaAllocator.init(std.heap.c_allocator);
|
||||
defer arg_arena.deinit();
|
||||
const pid = spawn: {
|
||||
state.mutex.lock();
|
||||
defer state.mutex.unlock();
|
||||
|
||||
const url = try std.fmt.allocPrintZ(arg_arena.allocator(), "https://twitch.tv/{s}", .{channel});
|
||||
const quality = try std.cstr.addNullByte(arg_arena.allocator(), std.mem.sliceTo(&state.quality_buf, 0));
|
||||
|
||||
const streamlink_argv = try arg_arena.allocator().allocSentinel(
|
||||
?[*:0]const u8,
|
||||
3,
|
||||
null,
|
||||
);
|
||||
|
||||
streamlink_argv[0] = "streamlink";
|
||||
streamlink_argv[1] = url;
|
||||
streamlink_argv[2] = quality;
|
||||
|
||||
// Doing it the C way because zig's ChildProcess ain't got this
|
||||
const pid = try std.os.fork();
|
||||
if (pid == 0) {
|
||||
try std.os.dup2(memfd, 1);
|
||||
try std.os.dup2(memfd, 2);
|
||||
return std.os.execvpeZ(streamlink_argv[0].?, streamlink_argv, std.c.environ);
|
||||
}
|
||||
|
||||
break :spawn pid;
|
||||
};
|
||||
|
||||
const success = std.os.waitpid(pid, 0).status == 0;
|
||||
|
||||
state.mutex.lock();
|
||||
defer state.mutex.unlock();
|
||||
|
||||
if (success) {
|
||||
std.log.info("Streamlink exited successfully, closing.", .{});
|
||||
c.glfwSetWindowShouldClose(state.win, 1);
|
||||
} else {
|
||||
var size = (try memfile.stat()).size;
|
||||
|
||||
if (size == 0) {
|
||||
try memfile.writeAll("<no output>");
|
||||
size = (try memfile.stat()).size;
|
||||
}
|
||||
|
||||
const mem = try std.os.mmap(
|
||||
null,
|
||||
size,
|
||||
std.os.PROT.READ,
|
||||
std.os.MAP.PRIVATE,
|
||||
memfd,
|
||||
0,
|
||||
);
|
||||
|
||||
state.streamlink_memfd = memfile;
|
||||
state.streamlink_out = mem;
|
||||
c.glfwShowWindow(state.win);
|
||||
}
|
||||
}
|
||||
|
||||
fn chattyThread(state: *State, child: std.ChildProcess, arena: std.heap.ArenaAllocator) !void {
|
||||
// no need to get the mutex here, chatty_alive is atomic
|
||||
@atomicStore(bool, &state.chatty_alive, true, .Unordered);
|
||||
defer @atomicStore(bool, &state.chatty_alive, false, .Unordered);
|
||||
|
||||
var ch = child;
|
||||
defer arena.deinit();
|
||||
_ = ch.spawnAndWait() catch |e| {
|
||||
std.log.err("Spawning Chatty: {!}", .{e});
|
||||
};
|
||||
}
|
|
@ -1,30 +1,98 @@
|
|||
const std = @import("std");
|
||||
const ffi = @import("ffi.zig");
|
||||
const c = ffi.c;
|
||||
const c = @import("ffi.zig").c;
|
||||
const gui = @import("gui.zig");
|
||||
const State = @import("State.zig");
|
||||
|
||||
pub const log = @import("glib-log").log(c, "playtwitch", 1024);
|
||||
// glib handles level filtering
|
||||
pub const log_level = .debug;
|
||||
pub fn main() !void {
|
||||
_ = c.glfwSetErrorCallback(&glfwErrorCb);
|
||||
if (c.glfwInit() == 0) {
|
||||
return error.GlfwInit;
|
||||
}
|
||||
|
||||
pub fn main() !u8 {
|
||||
var udata_arena = std.heap.ArenaAllocator.init(std.heap.c_allocator);
|
||||
defer udata_arena.deinit();
|
||||
c.glfwWindowHint(c.GLFW_CONTEXT_VERSION_MAJOR, 3);
|
||||
c.glfwWindowHint(c.GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||
c.glfwWindowHint(c.GLFW_TRANSPARENT_FRAMEBUFFER, c.GLFW_TRUE);
|
||||
|
||||
var state = gui.GuiState{
|
||||
.udata_arena = udata_arena.allocator(),
|
||||
};
|
||||
const win = c.glfwCreateWindow(500, 500, "playtwitch", null, null);
|
||||
defer c.glfwTerminate();
|
||||
|
||||
const app = c.gtk_application_new("de.mzte.playtwitch", c.G_APPLICATION_FLAGS_NONE);
|
||||
defer c.g_object_unref(app);
|
||||
c.glfwMakeContextCurrent(win);
|
||||
c.glfwSwapInterval(1);
|
||||
|
||||
ffi.connectSignal(app, "activate", @ptrCast(c.GCallback, &gui.activate), &state);
|
||||
const glew_err = c.glewInit();
|
||||
if (glew_err != c.GLEW_OK) {
|
||||
std.log.err("GLEW init error: {s}", .{c.glewGetErrorString(glew_err)});
|
||||
return error.GlewInit;
|
||||
}
|
||||
|
||||
const status = c.g_application_run(
|
||||
@ptrCast(*c.GApplication, app),
|
||||
@intCast(i32, std.os.argv.len),
|
||||
@ptrCast([*c][*c]u8, std.os.argv.ptr),
|
||||
);
|
||||
const ctx = c.igCreateContext(null);
|
||||
defer c.igDestroyContext(ctx);
|
||||
|
||||
return @intCast(u8, status);
|
||||
const io = c.igGetIO();
|
||||
io.*.ConfigFlags |= c.ImGuiConfigFlags_NavEnableKeyboard;
|
||||
io.*.IniFilename = null;
|
||||
io.*.LogFilename = null;
|
||||
|
||||
_ = c.ImGui_ImplGlfw_InitForOpenGL(win, true);
|
||||
defer c.ImGui_ImplGlfw_Shutdown();
|
||||
|
||||
_ = c.ImGui_ImplOpenGL3_Init("#version 330 core");
|
||||
defer c.ImGui_ImplOpenGL3_Shutdown();
|
||||
|
||||
c.igStyleColorsDark(null);
|
||||
@import("theme.zig").loadTheme(&c.igGetStyle().*.Colors);
|
||||
|
||||
const state = try State.init(win.?);
|
||||
defer state.deinit();
|
||||
|
||||
while (c.glfwWindowShouldClose(win) == 0) {
|
||||
c.glfwPollEvents();
|
||||
|
||||
var win_width: c_int = 0;
|
||||
var win_height: c_int = 0;
|
||||
c.glfwGetWindowSize(win, &win_width, &win_height);
|
||||
|
||||
c.ImGui_ImplOpenGL3_NewFrame();
|
||||
c.ImGui_ImplGlfw_NewFrame();
|
||||
c.igNewFrame();
|
||||
|
||||
const win_visible = c.igBegin(
|
||||
"##",
|
||||
null,
|
||||
c.ImGuiWindowFlags_NoMove |
|
||||
c.ImGuiWindowFlags_NoResize |
|
||||
c.ImGuiWindowFlags_NoDecoration |
|
||||
c.ImGuiWindowFlags_NoBringToFrontOnFocus |
|
||||
c.ImGuiWindowFlags_NoNavFocus,
|
||||
);
|
||||
|
||||
c.igSetWindowPos_Vec2(
|
||||
.{ .x = 0.0, .y = 0.0 },
|
||||
c.ImGuiCond_Always,
|
||||
);
|
||||
c.igSetWindowSize_Vec2(
|
||||
.{ .x = @intToFloat(f32, win_width), .y = @intToFloat(f32, win_height) },
|
||||
c.ImGuiCond_Always,
|
||||
);
|
||||
|
||||
if (win_visible and c.glfwGetWindowAttrib(win, c.GLFW_VISIBLE) != 0) {
|
||||
try gui.winContent(state);
|
||||
}
|
||||
|
||||
c.igEnd();
|
||||
|
||||
c.igEndFrame();
|
||||
|
||||
c.glViewport(0, 0, win_width, win_height);
|
||||
c.glClear(c.GL_COLOR_BUFFER_BIT);
|
||||
c.glClearColor(0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
c.igRender();
|
||||
c.ImGui_ImplOpenGL3_RenderDrawData(c.igGetDrawData());
|
||||
c.glfwSwapBuffers(win);
|
||||
}
|
||||
}
|
||||
|
||||
fn glfwErrorCb(e: c_int, d: [*c]const u8) callconv(.C) void {
|
||||
std.log.err("GLFW error {d}: {s}", .{ e, d });
|
||||
}
|
||||
|
|
12
scripts/playtwitch/src/theme.zig
Normal file
12
scripts/playtwitch/src/theme.zig
Normal file
|
@ -0,0 +1,12 @@
|
|||
const c = @import("ffi.zig").c;
|
||||
|
||||
pub fn loadTheme(colors: [*]c.ImVec4) void {
|
||||
colors[c.ImGuiCol_WindowBg] = c.ImVec4{ .x = 0.12, .y = 0.0, .z = 0.23, .w = 0.8 };
|
||||
colors[c.ImGuiCol_ChildBg] = c.ImVec4{ .x = 0.1, .y = 0.0, .z = 0.2, .w = 0.85 };
|
||||
colors[c.ImGuiCol_FrameBg] = c.ImVec4{ .x = 0.45, .y = 0.2, .z = 0.69, .w = 1.0 };
|
||||
colors[c.ImGuiCol_Button] = c.ImVec4{ .x = 0.33, .y = 0.14, .z = 0.51, .w = 1.0 };
|
||||
colors[c.ImGuiCol_ButtonHovered] = c.ImVec4{ .x = 0.7, .y = 0.49, .z = 0.9, .w = 1.0 };
|
||||
colors[c.ImGuiCol_TitleBgActive] = c.ImVec4{ .x = 0.33, .y = 0.14, .z = 0.51, .w = 1.0 };
|
||||
colors[c.ImGuiCol_Header] = c.ImVec4{ .x = 0.26, .y = 0.1, .z = 0.43, .w = 1.0 };
|
||||
colors[c.ImGuiCol_HeaderHovered] = c.ImVec4{ .x = 0.45, .y = 0.2, .z = 0.69, .w = 1.0 };
|
||||
}
|
Loading…
Reference in a new issue