port playtwitch to imgui

This commit is contained in:
LordMZTE 2022-10-29 21:49:44 +02:00
parent 3a5991531b
commit c8c4aaadd8
Signed by: LordMZTE
GPG key ID: B64802DC33A64FF6
10 changed files with 497 additions and 437 deletions

View file

@ -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;

View file

@ -1,6 +0,0 @@
deps:
glib-log:
git:
url: "https://mzte.de/git/LordMZTE/zig-glib-log.git"
ref: master
root: src/main.zig

View 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;
}

View 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();
}

View file

@ -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;
}
}

View file

@ -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]);
},
}
}

View 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);
}

View 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});
};
}

View file

@ -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 });
}

View 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 };
}