mcap/src/app/screen_record_win32.jai
Musa Mahmood 3f5d1ea124 [0.1.9] Recording toolbar: a Mic button, to turn the microphone on and off mid-recording
- The toolbar's Mic: on / off (red while on) turns the microphone on and off while recording;
  its state is saved as microphone in [video] for the next recording. A microphone that cannot
  be opened turns it off again, with a toast saying why.
- modules/Screen_Record: record_set_microphone, record_microphone, record_microphone_failed. The
  audio thread opens the microphone only while it is on (so Windows shows it in use only then)
  and closes it when turned off. Record_Options.audio_track gives the MP4 a sound track whatever
  is on at the start, silent while nothing is, as one cannot be added once the header is
  written; the app always asks for it. The sources heard and the failures are kept as they
  happen, not only at the start.
- record-probe -mic-toggle: the microphone on at 3 s and off at 7 s. With system sound and with
  nothing else on, the track runs the whole recording without a break.
2026-10-10 08:16:12 +01:00

332 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// While recording (app/screen_record.jai), on Windows: a thin red border just outside the region, and a
// toolbar beside it with the time, the size, frames dropped, and Mic, Pause, Stop and Discard. Both are
// plain GDI windows, as the overlay is, so they work while mcap is hidden in the tray, and both are
// kept out of every capture (WDA_EXCLUDEFROMCAPTURE), the recording's own included. The border is
// outside the region anyway, so it could never be in the video.
//
// Neither takes the focus: the window being recorded keeps it. The border lets clicks through. A
// toolbar button only asks (record_ui.asked); screen_record_frame does it, outside the window's own
// message, as Stop and Discard close the toolbar. Discard asks first, in the toolbar itself: a
// message box would be in the recording.
Record_UI_Ask :: enum u8 { NONE; MICROPHONE; PAUSE; STOP; DISCARD; }
record_ui: struct {
border, toolbar: HWND;
classes_registered: bool;
scale: float;
font: HFONT;
asked: Record_UI_Ask; // a button pressed, for screen_record_frame
confirming: bool; // Discard pressed once: "Discard it? Yes No"
hovered: s32 = -1; // the button under the mouse
buttons: [4] Record_UI_Button;
button_count: s32;
started: Apollo_Time;
size_text: string; // "1920 × 1080"; heap
}
Record_UI_Button :: struct { rect: RECT; label: string; ask: Record_UI_Ask; confirm: bool; }
// Shows the border around region (on the virtual desktop) and the toolbar beside it.
record_ui_open :: (region: RECT, whole_screen: bool, width: s32, height: s32) {
record_ui_close();
instance := GetModuleHandleW(null);
if !record_ui.classes_registered {
wc: WNDCLASSEXW;
wc.cbSize = size_of(WNDCLASSEXW);
wc.lpfnWndProc = xx DefWindowProcW;
wc.hInstance = instance;
wc.hbrBackground = CreateSolidBrush(RECORD_UI_RED);
wc.lpszClassName = utf8_to_wide(RECORD_BORDER_CLASS,, context.default_allocator);
RegisterClassExW(*wc);
wc.lpfnWndProc = xx record_toolbar_proc;
wc.hbrBackground = null;
wc.hCursor = LoadCursorW(null, IDC_ARROW);
wc.lpszClassName = utf8_to_wide(RECORD_TOOLBAR_CLASS,, context.default_allocator);
RegisterClassExW(*wc);
record_ui.classes_registered = true;
}
record_ui.started = current_time_monotonic();
record_ui.asked = .NONE;
record_ui.confirming = false;
record_ui.hovered = -1;
free(record_ui.size_text,, context.default_allocator);
record_ui.size_text = sprint("% × %", width, height,, context.default_allocator);
monitor := MonitorFromPoint(.{region.left, region.top}, MONITOR_DEFAULTTONEAREST);
info: MONITORINFO;
info.cbSize = size_of(MONITORINFO);
GetMonitorInfoW(monitor, *info);
screen := info.rcMonitor;
// The toolbar first, to know the screen's DPI.
ex_style :: WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
record_ui.toolbar = CreateWindowExW(ex_style, utf8_to_wide(RECORD_TOOLBAR_CLASS,, temp), utf8_to_wide("Recording",, temp),
WS_POPUP, screen.left, screen.top, 10, 10, null, null, instance, null);
if !record_ui.toolbar return;
dpi := GetDpiForWindow(record_ui.toolbar);
record_ui.scale = ifx dpi then dpi / 96.0 else 1.0;
font_height := cast(s32) (14 * record_ui.scale + 0.5);
record_ui.font = CreateFontW(-font_height, 0, 0, 0, 600, 0, 0, 0, 1, 0, 0, CLEARTYPE_QUALITY, 0, utf8_to_wide("Segoe UI",, temp));
// Below the region, at its right; else above; else inside its top right (kept out of the recording).
toolbar_width, toolbar_height := record_toolbar_size();
border := record_ui_px(3);
gap := record_ui_px(6);
x := region.right - toolbar_width;
y := region.bottom + border + gap;
if y + toolbar_height > screen.bottom y = region.top - border - gap - toolbar_height;
if y < screen.top || whole_screen {
y = region.top + gap;
x = region.right - toolbar_width - gap;
}
x = clamp(x, screen.left, screen.right - toolbar_width);
SetWindowPos(record_ui.toolbar, HWND_TOPMOST, x, y, toolbar_width, toolbar_height, SWP_NOACTIVATE);
visible := array_find(app.args, "-record-ui-visible"); // to look at them in a screenshot
if !visible SetWindowDisplayAffinity(record_ui.toolbar, WDA_EXCLUDEFROMCAPTURE);
ShowWindow(record_ui.toolbar, SW_SHOWNOACTIVATE);
SetTimer(record_ui.toolbar, RECORD_UI_TIMER, 250, null);
// The border: a frame border pixels wide just outside the region, which lets clicks through. Not
// for a whole screen, where it would be on the screen beside it.
if !whole_screen {
outer := RECT.{region.left - border, region.top - border, region.right + border, region.bottom + border};
record_ui.border = CreateWindowExW(ex_style | WS_EX_LAYERED | WS_EX_TRANSPARENT, utf8_to_wide(RECORD_BORDER_CLASS,, temp), utf8_to_wide("Recording",, temp),
WS_POPUP, outer.left, outer.top, outer.right - outer.left, outer.bottom - outer.top, null, null, instance, null);
if record_ui.border {
SetLayeredWindowAttributes(record_ui.border, 0, 255, LWA_ALPHA);
frame := CreateRectRgn(0, 0, outer.right - outer.left, outer.bottom - outer.top);
inside := CreateRectRgn(border, border, outer.right - outer.left - border, outer.bottom - outer.top - border);
CombineRgn(frame, frame, inside, RGN_DIFF);
DeleteObject(inside);
SetWindowRgn(record_ui.border, frame, .TRUE); // the window owns the region from here
if !visible SetWindowDisplayAffinity(record_ui.border, WDA_EXCLUDEFROMCAPTURE);
ShowWindow(record_ui.border, SW_SHOWNOACTIVATE);
}
}
}
record_ui_close :: () {
if record_ui.toolbar { KillTimer(record_ui.toolbar, RECORD_UI_TIMER); DestroyWindow(record_ui.toolbar); record_ui.toolbar = null; }
if record_ui.border { DestroyWindow(record_ui.border); record_ui.border = null; }
if record_ui.font { DeleteObject(record_ui.font); record_ui.font = null; }
}
// From another thread (the shutdown's): hidden, not destroyed, which only the main thread may do.
record_ui_hide :: () {
if record_ui.toolbar ShowWindowAsync(record_ui.toolbar, SW_HIDE);
if record_ui.border ShowWindowAsync(record_ui.border, SW_HIDE);
}
// The button pressed since the last call, once.
record_ui_take_ask :: () -> Record_UI_Ask {
ask := record_ui.asked;
record_ui.asked = .NONE;
return ask;
}
record_ui_refresh :: () {
if record_ui.toolbar InvalidateRect(record_ui.toolbar, null, .FALSE);
}
#scope_file
RECORD_BORDER_CLASS :: "mcapRecordBorder";
RECORD_TOOLBAR_CLASS :: "mcapRecordToolbar";
RECORD_UI_TIMER :: 1;
RECORD_UI_RED :: 0x002828E8; // COLORREF 0x00BBGGRR, as the overlay's video mode
RECORD_UI_GREY :: 0x00909090;
RECORD_UI_AMBER :: 0x0000B4FF;
RECORD_UI_BACK :: 0x00202020;
RECORD_UI_BUTTON :: 0x00383838;
RECORD_UI_HOVER :: 0x00505050;
RECORD_UI_TEXT :: 0x00F0F0F0;
record_ui_px :: (pixels: float) -> s32 {
return max(cast(s32) (pixels * record_ui.scale + 0.5), 1);
}
// What the toolbar shows now, left to right: the status, then the buttons.
record_toolbar_texts :: () -> status: string, dropped: string {
stats := record_stats(*screen_record.recorder);
paused := record_paused(*screen_record.recorder);
seconds := max(to_float64_seconds(current_time_monotonic() - record_ui.started) - stats.paused_seconds, 0.0);
whole := cast(s64) seconds;
two :: (n: s64) -> string { return tprint("%", formatInt(n, minimum_digits = 2)); }
time := ifx whole >= 3600 then tprint("%:%:%", whole / 3600, two(whole / 60 % 60), two(whole % 60)) else tprint("%:%", two(whole / 60), two(whole % 60));
status := tprint("% % %", ifx paused then "PAUSED" else "REC", time, record_ui.size_text);
dropped := ifx stats.dropped then tprint("% dropped", stats.dropped) else "";
if stats.lost {
lost := tprint("screen lost % time%", stats.lost, ifx stats.lost == 1 then "" else "s");
dropped = ifx dropped then tprint("%, %", dropped, lost) else lost;
}
return status, dropped;
}
// The buttons for the state the toolbar is in.
record_toolbar_buttons :: () -> [] Record_UI_Button {
if record_ui.confirming {
record_ui.buttons[0] = .{label = "Yes, discard", ask = .DISCARD};
record_ui.buttons[1] = .{label = "No", confirm = true};
record_ui.button_count = 2;
} else {
record_ui.buttons[0] = .{label = ifx record_microphone(*screen_record.recorder) then "Mic: on" else "Mic: off", ask = .MICROPHONE};
record_ui.buttons[1] = .{label = ifx record_paused(*screen_record.recorder) then "Resume" else "Pause", ask = .PAUSE};
record_ui.buttons[2] = .{label = "Stop", ask = .STOP};
record_ui.buttons[3] = .{label = "Discard", confirm = true};
record_ui.button_count = 4;
}
return array_view(record_ui.buttons, 0, record_ui.button_count);
}
text_width :: (dc: HDC, text: string) -> s32 {
wide := utf8_to_wide(text,, temp);
count: s32;
while wide[count] count += 1;
size: SIZE;
GetTextExtentPoint32W(dc, wide, count, *size);
return size.cx;
}
// The toolbar's size, and where its buttons are, for the longest it is likely to be.
record_toolbar_size :: () -> width: s32, height: s32 {
dc := GetDC(record_ui.toolbar);
defer ReleaseDC(record_ui.toolbar, dc);
old := SelectObject(dc, record_ui.font);
defer SelectObject(dc, old);
padding, gap := record_ui_px(10), record_ui_px(8);
status_width := text_width(dc, tprint("PAUSED 00:00:00 %", record_ui.size_text));
dropped_width := text_width(dc, "999 dropped, screen lost 9 times");
buttons_width := text_width(dc, "Mic: off") + text_width(dc, "Resume") + text_width(dc, "Stop") + text_width(dc, "Yes, discard") + 4 * 2 * padding + 3 * gap;
height := record_ui_px(34);
width := padding + record_ui_px(12) + gap + status_width + gap + dropped_width + gap + buttons_width + padding;
return width, height;
}
record_toolbar_proc :: (hwnd: HWND, message: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT #c_call {
if !platform.main_context return DefWindowProcW(hwnd, message, wParam, lParam);
push_context platform.main_context.* {
if message == {
case WM_MOUSEACTIVATE;
return MA_NOACTIVATE; // the window being recorded keeps the focus
case WM_ERASEBKGND;
return 1;
case WM_TIMER;
// The time moves on, and a mouse that left the toolbar no longer lights a button.
cursor: POINT;
GetCursorPos(*cursor);
window: RECT;
GetWindowRect(hwnd, *window);
if cursor.x < window.left || cursor.x >= window.right || cursor.y < window.top || cursor.y >= window.bottom record_ui.hovered = -1;
InvalidateRect(hwnd, null, .FALSE);
return 0;
case WM_MOUSEMOVE;
hovered := record_toolbar_hit(.{cast(s32) LOWORD(lParam), cast(s32) HIWORD(lParam)});
if hovered != record_ui.hovered { record_ui.hovered = hovered; InvalidateRect(hwnd, null, .FALSE); }
return 0;
case WM_LBUTTONDOWN;
hit := record_toolbar_hit(.{cast(s32) LOWORD(lParam), cast(s32) HIWORD(lParam)});
if hit >= 0 {
button := record_ui.buttons[hit];
if button.confirm record_ui.confirming = !record_ui.confirming;
else if button.ask != .NONE { record_ui.asked = button.ask; record_ui.confirming = false; }
InvalidateRect(hwnd, null, .FALSE);
}
return 0;
case WM_PAINT;
record_toolbar_paint(hwnd);
return 0;
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
}
record_toolbar_hit :: (p: POINT) -> s32 {
for array_view(record_ui.buttons, 0, record_ui.button_count) {
r := it.rect;
if p.x >= r.left && p.x < r.right && p.y >= r.top && p.y < r.bottom return cast(s32) it_index;
}
return -1;
}
record_toolbar_paint :: (hwnd: HWND) {
paint: PAINTSTRUCT;
window_dc := BeginPaint(hwnd, *paint);
defer EndPaint(hwnd, *paint);
client: RECT;
GetClientRect(hwnd, *client);
width, height := client.right, client.bottom;
// Drawn whole into a bitmap, then put on screen: no flicker as the time changes.
dc := CreateCompatibleDC(window_dc);
defer DeleteDC(dc);
bitmap := CreateCompatibleBitmap(window_dc, width, height);
defer DeleteObject(bitmap);
old_bitmap := SelectObject(dc, bitmap);
defer SelectObject(dc, old_bitmap);
old_font := SelectObject(dc, record_ui.font);
defer SelectObject(dc, old_font);
fill :: (r: RECT, colour: u32) #expand {
brush := CreateSolidBrush(colour);
rect := r;
FillRect(`dc, *rect, brush);
DeleteObject(brush);
}
text_at :: (x: s32, text: string, colour: u32) #expand {
SetTextColor(`dc, colour);
wide := utf8_to_wide(text,, temp);
count: s32;
while wide[count] count += 1;
size: SIZE;
GetTextExtentPoint32W(`dc, wide, count, *size);
TextOutW(`dc, x, (`height - size.cy) / 2, wide, count);
}
fill(.{0, 0, width, height}, RECORD_UI_BACK);
SetBkMode(dc, TRANSPARENT);
padding, gap := record_ui_px(10), record_ui_px(8);
paused := record_paused(*screen_record.recorder);
// The dot: red while recording, blinking each half second; grey while paused.
dot := record_ui_px(12);
blink := (cast(s64) (to_float64_seconds(current_time_monotonic() - record_ui.started) * 2)) % 2 == 0;
dot_colour: u32 = ifx paused then cast(u32) RECORD_UI_GREY else ifx blink then cast(u32) RECORD_UI_RED else cast(u32) 0x00602020;
brush := CreateSolidBrush(dot_colour);
old_brush := SelectObject(dc, brush);
old_pen := SelectObject(dc, GetStockObject(NULL_PEN));
Ellipse(dc, padding, (height - dot) / 2, padding + dot, (height - dot) / 2 + dot);
SelectObject(dc, old_pen);
SelectObject(dc, old_brush);
DeleteObject(brush);
status, dropped := record_toolbar_texts();
x := padding + dot + gap;
text_at(x, status, RECORD_UI_TEXT);
x += text_width(dc, status) + gap;
if dropped text_at(x, dropped, RECORD_UI_AMBER);
// The buttons, from the right.
buttons := record_toolbar_buttons();
button_height := height - 2 * record_ui_px(5);
right := width - padding;
for < buttons {
label_width := text_width(dc, it.label);
r := RECT.{right - label_width - 2 * padding, (height - button_height) / 2, right, (height - button_height) / 2 + button_height};
record_ui.buttons[it_index].rect = r;
lit := it.ask == .MICROPHONE && record_microphone(*screen_record.recorder); // on: in the record red
fill(r, ifx lit then cast(u32) RECORD_UI_RED else ifx it_index == record_ui.hovered then cast(u32) RECORD_UI_HOVER else cast(u32) RECORD_UI_BUTTON);
SetTextColor(dc, ifx it.ask == .DISCARD then cast(u32) RECORD_UI_AMBER else cast(u32) RECORD_UI_TEXT);
wide := utf8_to_wide(it.label,, temp);
count: s32;
while wide[count] count += 1;
size: SIZE;
GetTextExtentPoint32W(dc, wide, count, *size);
TextOutW(dc, r.left + padding, r.top + (button_height - size.cy) / 2, wide, count);
right = r.left - gap;
}
if record_ui.confirming text_at(right - text_width(dc, "Discard it?"), "Discard it?", RECORD_UI_AMBER);
BitBlt(window_dc, 0, 0, width, height, dc, 0, 0, .SRCCOPY);
}