Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ jobs:

- name: Test
run: nix develop -c zig build test -Dnix

- name: Build AppImage
run: nix develop -c zig build -Dappimage -Doptimize=ReleaseFast
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Use `spacecap -h` to see available commands.

### Windows

Windows is not yet supported. This application was architected in such a way
Windows is not yet supported. Spacecap is architected in such a way
that it can be cross platform. For Windows support, the audio/video capture
interfaces need to be implemented. It's on the roadmap, but is not currently
a priority.
Expand Down
18 changes: 9 additions & 9 deletions build.zig
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
const util = @import("src/util.zig");
const std = @import("std");
const ffmpeg_build_util = @import("build/ffmpeg_build.zig");
const version = @import("build/version.zig");
Expand Down Expand Up @@ -40,6 +39,12 @@ fn add_shared_dependencies(
try compile_shader(allocator, b, exe, "random.vert", "random_vert_shader");
try compile_shader(allocator, b, exe, "bgr-ycbcr-shader-2plane.comp", "bgr-ycbcr-shader-2plane");

inline for (.{ "logo_blue.png", "logo_red.png", "logo_green.png" }) |logo_file| {
exe.root_module.addAnonymousImport(logo_file, .{
.root_source_file = b.path("packaging/" ++ logo_file),
});
}

// vulkan
const vulkan_headers = b.dependency("vulkan_headers", .{});
const vulkan = b.dependency(
Expand All @@ -52,16 +57,11 @@ fn add_shared_dependencies(
exe.root_module.addImport("vulkan", vulkan);
exe.addIncludePath(vulkan_headers.path(""));

// SDL3
const sdl = b.dependency("sdl", .{
// NOTE: SDL3 is statically linked by imguiz.
const imguiz = b.dependency("imguiz", .{
.target = target,
.optimize = optimize,
.linkage = .static,
});
exe.linkLibrary(sdl.artifact("SDL3"));

// imguiz
const imguiz = b.dependency("imguiz", .{}).module("imguiz");
}).module("imguiz");
exe.root_module.addImport("imguiz", imguiz);

// zigrc
Expand Down
8 changes: 2 additions & 6 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@
.hash = "vulkan-0.0.0-r7YtxztIAwBc30xIMx4tzfUcEmc7goWiFJkR13yeLfi8",
},
.imguiz = .{
.url = "git+https://github.com/mgerb/imguiz#7d6e1a4dfb2f31da24c9f76e0c4ba8285e89d289",
.hash = "imguiz-0.0.0-sI63zx-_DwQbtJX5KbwrOK_VnEkxIft96REu9HVBZ3W8",
.url = "git+https://github.com/mgerb/imguiz#3b56163b6f87e681856e019141f034e9966d8aa0",
.hash = "imguiz-0.0.0-sI63z3FwbgSB_xv3TSKRjoMhwSfup8tmCPMq1ZJyDqUs",
},
.ffmpeg = .{
.url = "https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n8.0.1.tar.gz",
Expand All @@ -94,10 +94,6 @@
.url = "git+https://github.com/Aandreba/zigrc#b1e98f1cc506e975bdb27341c27d920021e7b4d8",
.hash = "zigrc-1.0.0-lENlWzvQAACulrbkL9PVhWjFsWSkYhi7AmfSbCM-2Xlh",
},
.sdl = .{
.url = "git+https://github.com/allyourcodebase/SDL3#467a4baad2acdda1bcf717065793fbbdf2569f8d",
.hash = "sdl-0.0.0-i4QD0WN-qQB1RKdRC9-6iZyTco2jIIr3y2DFrAz-D9CH",
},
.pipewire = .{
.url = "https://github.com/mgerb/pipewire/archive/refs/heads/dev.tar.gz",
.hash = "pipewire-1.6.2-tKslQ3LyEgC050NwrrKyjJjEXbO-M_CvmyVMu3FLLoJd",
Expand Down
10 changes: 8 additions & 2 deletions build_app_image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" linuxdeploy \
--appdir AppDir \
--executable zig-out/linux/spacecap \
--desktop-file packaging/linux/spacecap.desktop \
--icon-file packaging/linux/spacecap.svg \
--exclude-library libvulkan.so.1
--icon-file packaging/logo_blue.png \
--exclude-library libvulkan.so.1 \
--library "$GTK3_LIB" \
--library "$APPINDICATOR_LIB"

# NOTE: These extra libs are required for the tray icon functionality.
# Unfortunately we have to pull in gtk, which increases app size by 50% :(


env -u SOURCE_DATE_EPOCH APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 appimagetool AppDir zig-out/linux/spacecap-linux-x86_64.AppImage
rm -rf AppDir
12 changes: 12 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@
libportal
zlib
glib

# Required for linux tray icon.
gtk3
libayatana-appindicator

appimage-run

# For configuring ffmpeg headers
Expand All @@ -118,11 +123,18 @@

VK_LAYER_PATH = "${pkgs.vulkan-validation-layers}/share/vulkan/explicit_layer.d";
VULKAN_SDK_PATH_WINDOWS = "${pkgs.pkgsCross.mingwW64.vulkan-loader}/bin";
GTK3_LIB = "${pkgs.gtk3}/lib/libgtk-3.so.0";
APPINDICATOR_LIB = "${pkgs.libayatana-appindicator}/lib/libayatana-appindicator3.so.1";

# Required for Github actions or non-NixOS machines.
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
pkgs.vulkan-loader
pkgs.glib

# Required for linux tray icon.
pkgs.gtk3
pkgs.libayatana-appindicator

pkgs.libportal
# SDL runtime backends (don't rely on ffmpeg closure for these).
pkgs.wayland
Expand Down
2 changes: 1 addition & 1 deletion packaging/linux/spacecap.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ Type=Application
Name=Spacecap
Comment=Hardware accelerated replay capture tool
Exec=spacecap
Icon=spacecap
Icon=logo_blue
Categories=AudioVideo;Video;
Terminal=false
10 changes: 0 additions & 10 deletions packaging/linux/spacecap.svg

This file was deleted.

Binary file added packaging/logo.aseprite
Binary file not shown.
Binary file added packaging/logo_blue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packaging/logo_green.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packaging/logo_red.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packaging/palette.aseprite
Binary file not shown.
9 changes: 4 additions & 5 deletions src/state/actor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -587,9 +587,7 @@ pub const Actor = struct {

self.ui_mutex.lock();
defer self.ui_mutex.unlock();
if (!self.state.is_recording_video) {
self.state.is_recording_video = true;
}
self.state.is_recording_video = true;
}

fn stop_record(self: *Self) !void {
Expand All @@ -614,11 +612,12 @@ pub const Actor = struct {
}
}

/// Dispatch an action to the actor.
/// Dispatch an action to the actor. This is thread safe.
///
/// WARN: The actor uses a buffered channel,
/// and it will block the caller if it fills up.
/// Be careful when using this from the UI thread.
/// Be careful when using this from the UI thread, although
/// if the buffer fills up then something is seriously wrong.
pub fn dispatch(self: *Self, action: Actions) !void {
log.debug("[dispatch] dispatching action: {}", .{action});
try self.action_chan.send(action);
Expand Down
37 changes: 37 additions & 0 deletions src/ui/app_icon.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const imguiz = @import("imguiz").imguiz;

const APP_ICON_BLUE = @embedFile("logo_blue.png");
const APP_ICON_RED = @embedFile("logo_red.png");
const APP_ICON_GREEN = @embedFile("logo_green.png");

/// A utility for interacting app icons with SDL3.
pub const AppIcon = struct {
const Self = @This();

app_icon_surface_blue: *imguiz.SDL_Surface,
app_icon_surface_red: *imguiz.SDL_Surface,
app_icon_surface_green: *imguiz.SDL_Surface,

pub fn init() Self {
return .{
.app_icon_surface_blue = imguiz.SDL_LoadPNG_IO(
imguiz.SDL_IOFromConstMem(APP_ICON_BLUE.ptr, APP_ICON_BLUE.len).?,
true,
).?,
.app_icon_surface_red = imguiz.SDL_LoadPNG_IO(
imguiz.SDL_IOFromConstMem(APP_ICON_RED.ptr, APP_ICON_RED.len).?,
true,
).?,
.app_icon_surface_green = imguiz.SDL_LoadPNG_IO(
imguiz.SDL_IOFromConstMem(APP_ICON_GREEN.ptr, APP_ICON_GREEN.len).?,
true,
).?,
};
}

pub fn deinit(self: *Self) void {
imguiz.SDL_DestroySurface(self.app_icon_surface_blue);
imguiz.SDL_DestroySurface(self.app_icon_surface_red);
imguiz.SDL_DestroySurface(self.app_icon_surface_green);
}
};
3 changes: 3 additions & 0 deletions src/ui/sdl.zig
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ pub fn get_sdl_vulkan_extensions(allocator: std.mem.Allocator) !SDLVulkanExtensi

/// If Linux, try Wayland, fallback to x11.
pub fn init() !void {
_ = imguiz.SDL_SetHint(imguiz.SDL_HINT_APP_NAME, "Spacecap");
_ = imguiz.SDL_SetHint(imguiz.SDL_HINT_APP_ID, "spacecap");

if (util.is_linux()) {
if (try try_sdl_init_with_hint("wayland")) {
log.info("[sdl_init] using wayland", .{});
Expand Down
139 changes: 139 additions & 0 deletions src/ui/tray.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
const std = @import("std");
const assert = std.debug.assert;

const imguiz = @import("imguiz").imguiz;
const Actor = @import("../state/actor.zig").Actor;
const AppIcon = @import("./app_icon.zig").AppIcon;

const APP_ICON_TOOLTIP = "Spacecap";
const APP_ICON_RECORDING_TOOLTIP = "Spacecap - Replay Buffer Recording";

/// Use the SDL3 API to interact with the system tray.
/// NOTE: Interactions must be on the UI thread.
pub const Tray = struct {
const log = std.log.scoped(.tray);
const Self = @This();

pub const State = struct {
is_recording: bool,
is_capturing: bool,
};

actor: *Actor,
app_icon: *AppIcon,
tray: *imguiz.SDL_Tray,
start_replay_buffer_entry: *imguiz.SDL_TrayEntry,
stop_replay_buffer_entry: *imguiz.SDL_TrayEntry,
save_replay_entry: *imguiz.SDL_TrayEntry,
state: State = .{
.is_recording = false,
.is_capturing = false,
},

pub fn init(actor: *Actor, app_icon: *AppIcon) !Self {
const tray = imguiz.SDL_CreateTray(app_icon.app_icon_surface_blue, "Spacecap") orelse return error.TrayInitCreateTray;
errdefer {
imguiz.SDL_DestroyTray(tray);
}
const menu = imguiz.SDL_CreateTrayMenu(tray) orelse {
log.warn("[init_sdl_tray] failed to create tray menu: {s}", .{imguiz.SDL_GetError()});
return error.TrayInitMenu;
};

const start_record_entry = try insert_tray_entry(menu, "Start Replay Buffer");
imguiz.SDL_SetTrayEntryCallback(start_record_entry, start_record_callback, actor);

const stop_record_entry = try insert_tray_entry(menu, "Stop Replay Buffer");
imguiz.SDL_SetTrayEntryCallback(stop_record_entry, stop_record_callback, actor);

const save_replay_entry = try insert_tray_entry(menu, "Save Replay");
imguiz.SDL_SetTrayEntryCallback(save_replay_entry, save_replay_callback, actor);

const quit_entry = imguiz.SDL_InsertTrayEntryAt(menu, -1, "Quit", imguiz.SDL_TRAYENTRY_BUTTON) orelse {
log.warn("[init_sdl_tray] failed to create tray quit entry", .{});
return error.TrayInitInsertQuitEntry;
};
imguiz.SDL_SetTrayEntryCallback(quit_entry, quit_callback, null);

return .{
.actor = actor,
.app_icon = app_icon,
.tray = tray,
.start_replay_buffer_entry = start_record_entry,
.stop_replay_buffer_entry = stop_record_entry,
.save_replay_entry = save_replay_entry,
};
}

pub fn deinit(self: *Self) void {
imguiz.SDL_DestroyTray(self.tray);
}

// Update the state of the tray menu entries.
// WARNING: Not thread safe. Must be called from the UI thread.
pub fn set_state(self: *Self, state: State) void {
if (std.meta.eql(self.state, state)) {
return;
}

if (self.state.is_recording != state.is_recording) {
imguiz.SDL_SetTrayIcon(self.tray, self.get_icon_surface_for_state(state.is_recording));
imguiz.SDL_SetTrayTooltip(self.tray, get_tooltip_for_state(state.is_recording));
}

imguiz.SDL_SetTrayEntryEnabled(self.start_replay_buffer_entry, !state.is_recording and state.is_capturing);
imguiz.SDL_SetTrayEntryEnabled(self.stop_replay_buffer_entry, state.is_recording);
imguiz.SDL_SetTrayEntryEnabled(self.save_replay_entry, state.is_recording);

self.state = state;
}

fn insert_tray_entry(menu: *imguiz.SDL_TrayMenu, comptime name: [:0]const u8) !*imguiz.SDL_TrayEntry {
const tray_entry = imguiz.SDL_InsertTrayEntryAt(menu, -1, name, imguiz.SDL_TRAYENTRY_BUTTON) orelse {
log.warn("[init_sdl_tray] failed to create tray entry: " ++ name, .{});
return error.TrayInitInsertStartRecordEntry;
};
imguiz.SDL_SetTrayEntryEnabled(tray_entry, false);
return tray_entry;
}

fn get_icon_surface_for_state(self: *Self, is_recording: bool) ?*imguiz.SDL_Surface {
return if (is_recording)
self.app_icon.app_icon_surface_red
else
self.app_icon.app_icon_surface_blue;
}

fn get_tooltip_for_state(is_recording: bool) [*c]const u8 {
return if (is_recording)
APP_ICON_RECORDING_TOOLTIP
else
APP_ICON_TOOLTIP;
}

fn start_record_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void {
assert(userdata != null);
const actor: *Actor = @ptrCast(@alignCast(userdata));
actor.dispatch(.start_record) catch unreachable;
}

fn stop_record_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void {
assert(userdata != null);
const actor: *Actor = @ptrCast(@alignCast(userdata));
actor.dispatch(.stop_record) catch unreachable;
}

fn save_replay_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void {
assert(userdata != null);
const actor: *Actor = @ptrCast(@alignCast(userdata));
actor.dispatch(.save_replay) catch unreachable;
}

fn quit_callback(_: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void {
var event: imguiz.SDL_Event = std.mem.zeroes(imguiz.SDL_Event);
event.type = imguiz.SDL_EVENT_QUIT;
if (!imguiz.SDL_PushEvent(&event)) {
log.warn("[quit_callback] failed to push quit event: {s}", .{imguiz.SDL_GetError()});
}
}
};
Loading
Loading