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
558 changes: 300 additions & 258 deletions src/ui/footer.zig

Large diffs are not rendered by default.

86 changes: 73 additions & 13 deletions src/ui/footer_pure.zig
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ pub fn transportBusy(t: Transport) bool {
/// fixed layout simply overflowed and clipped the close button off the end.
/// Groups are shed widest-first so the essentials (play/pause, clock, mute,
/// subtitles, close) always fit.
pub const CONTROL_CONTENT_HEIGHT: f32 = 36;
pub const CONTROL_VERTICAL_PADDING: f32 = 2;
pub const BACKDROP_SLICE_HEIGHT: f32 = 11;

pub const BarLayout = struct {
/// The 120px volume track (the mute button always stays).
volume_slider: bool,
Expand All @@ -253,15 +257,14 @@ pub const BarLayout = struct {
skip_buttons: bool,
};

// Thresholds are MEASURED against the real bar, not guessed: at 1200pt the
// full set fits with slack and the close button sits at the right edge; at
// 700pt with every group enabled the row overflowed and clipped the close
// button off the end — the exact failure this collapse exists to prevent. Each
// constant is the width below which the next group must go, with margin.
// Conservative width budgets for the single strip. Narrow cells move optional
// groups into More rather than wrapping or clipping the close button. The
// combined TV/playlist navigation and picture preset need extra room for the
// measured filename, so their inline threshold is deliberately higher.
pub const BREAK_VOLUME: f32 = 900; // 120pt track + its gap + hover readout
pub const BREAK_CHIPS: f32 = 800; // aspect, audio-device, sub-lang, uni-lang
pub const BREAK_BADGES: f32 = 520; // chapters, find-subtitles, torrent files
pub const BREAK_SKIP: f32 = 320; // playlist/episode skip, rewind, fullscreen
pub const BREAK_SKIP: f32 = 1100; // narrow cells move skips, picture and fullscreen into More

pub fn barLayout(width: f32) BarLayout {
return .{
Expand All @@ -272,6 +275,34 @@ pub fn barLayout(width: f32) BarLayout {
};
}

/// Selected media fallback without exposing URL credentials/query parameters.
pub fn mediaFilename(url: []const u8) []const u8 {
const end = std.mem.indexOfAny(u8, url, "?#") orelse url.len;
var path = url[0..end];
if (std.mem.indexOf(u8, path, "://")) |scheme| {
const authority_end = std.mem.indexOfScalarPos(u8, path, scheme + 3, '/') orelse return "";
path = path[authority_end..];
}
const start = if (std.mem.lastIndexOfAny(u8, path, "/\\")) |i| i + 1 else 0;
return path[start..];
}

/// The active torrent already has details in the compact playback overlay.
pub fn showTorrentActivity(on_player_route: bool, active_tid: i32, tid: i32) bool {
return tid >= 0 and (!on_player_route or active_tid < 0 or tid != active_tid);
}

/// Measured text prefix, preserving UTF-8 boundaries and explicit ellipsis.
pub fn compactTitle(out: []u8, title: []const u8, measured_end: usize) []const u8 {
if (measured_end == 0 or out.len < 3) return "";
var end = @min(@min(measured_end, title.len), out.len - 3);
while (end > 0 and end < title.len and (title[end] & 0xc0) == 0x80) end -= 1;
@memcpy(out[0..end], title[0..end]);
if (end == title.len) return out[0..end];
@memcpy(out[end..][0..3], "…");
return out[0 .. end + 3];
}

// ══════════════════════════════════════════════════════════════════
// Tests
// ══════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -418,21 +449,21 @@ test "barLayout: sheds groups widest-first as the bar narrows" {
const wide = barLayout(1400);
try expect(wide.volume_slider and wide.secondary_chips and wide.status_badges and wide.skip_buttons);

// 700pt: the measured width at which the full set overflowed and clipped
// 700pt: the historical full set overflowed and clipped
// the close button. Both the volume track and the picker chips must be
// gone by here — with the chips still on, the row still ran off the end.
const seven = barLayout(700);
try expect(!seven.volume_slider and !seven.secondary_chips);
try expect(seven.status_badges and seven.skip_buttons);
try expect(seven.status_badges and !seven.skip_buttons);

// Only the volume track goes at first — the chips still fit.
// Volume remains compact at850pt; secondary actions use More.
const eight_fifty = barLayout(850);
try expect(!eight_fifty.volume_slider);
try expect(eight_fifty.secondary_chips and eight_fifty.status_badges and eight_fifty.skip_buttons);
try expect(eight_fifty.secondary_chips and eight_fifty.status_badges and !eight_fifty.skip_buttons);

const narrow = barLayout(460);
try expect(!narrow.volume_slider and !narrow.secondary_chips and !narrow.status_badges);
try expect(narrow.skip_buttons);
try expect(!narrow.skip_buttons);

const tiny = barLayout(300);
try expect(!tiny.volume_slider and !tiny.secondary_chips and !tiny.status_badges and !tiny.skip_buttons);
Expand All @@ -441,9 +472,9 @@ test "barLayout: sheds groups widest-first as the bar narrows" {
// collapse. Everything optional is gone; the essentials still render.
const cell = barLayout(400);
try expect(!cell.volume_slider and !cell.secondary_chips and !cell.status_badges);
try expect(cell.skip_buttons);
try expect(!cell.skip_buttons);

// Monotonic: nothing ever comes BACK as the bar gets narrower.
// Monotonic: optional inline groups never return as the bar narrows.
var w: f32 = 1400;
var prev = barLayout(w);
while (w > 100) : (w -= 10) {
Expand Down Expand Up @@ -611,3 +642,32 @@ test "fillSegment spans start..end and never inverts" {
const inverted = fillSegment(100, 50, 400, 26, 4, 0.9, 0.1);
try expect(inverted.w == 0);
}

test "compact title respects measured width and UTF8 boundaries" {
var out: [80]u8 = undefined;
try expectEqualStrings("", compactTitle(&out, "Runner", 0));
try expectEqualStrings("Runner", compactTitle(&out, "Runner", 6));
try expectEqualStrings("Run…", compactTitle(&out, "Runner", 3));
try expectEqualStrings("東…", compactTitle(&out, "東京 episode", 4));
try expectEqualStrings("episode.mkv", mediaFilename("https://example.test/files/episode.mkv?token=secret#x"));
try expectEqualStrings("film.mkv", mediaFilename("film.mkv"));
}

test "single compact control strip stays forty logical points tall" {
try expect(CONTROL_CONTENT_HEIGHT + 2 * CONTROL_VERTICAL_PADDING == 40);
// Decorative scrim must not impose the old three-row panel height.
try expect(6 * BACKDROP_SLICE_HEIGHT <= 26 + CONTROL_CONTENT_HEIGHT + 2 * CONTROL_VERTICAL_PADDING);
for ([_]f32{ 320, 400, 460 }) |width| {
const fit = barLayout(width);
try expect(!fit.volume_slider and !fit.skip_buttons);
}
}

test "filename fallback excludes URI authority credentials and handles Windows" {
try expectEqualStrings("", mediaFilename("https://user:password@example.test"));
try expectEqualStrings("film.mkv", mediaFilename("C:\\media\\film.mkv"));
try expectEqualStrings("film.mkv", mediaFilename("https://user:password@example.test/film.mkv"));
try expect(!showTorrentActivity(true, 7, 7));
try expect(showTorrentActivity(true, 7, 8));
try expect(showTorrentActivity(false, 7, 7));
}
22 changes: 19 additions & 3 deletions src/ui/pickers.zig
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ const dropup = @import("dropup_pure.zig");
/// Per-picker rect, kept across frames because dvui's FloatingWindowWidget wants a
/// mutable pointer. Recomputed from the chip anchor every frame, so a panel tracks
/// its chip if the control bar moves (e.g. entering fullscreen).
var dropup_rects: [10]dvui.Rect = [_]dvui.Rect{.{}} ** 10;
var dropup_rects: [11]dvui.Rect = [_]dvui.Rect{.{}} ** 11;
/// Physical bounds from the last rendered frame. Mouse events are physical,
/// while FloatingWindowWidget placement uses logical coordinates.
var dropup_screen_rects: [10]dvui.Rect.Physical = [_]dvui.Rect.Physical{.{}} ** 10;
var dropup_screen_rects: [11]dvui.Rect.Physical = [_]dvui.Rect.Physical{.{}} ** 11;

pub fn renderQualityPickerPopover(active_p: *player.MediaPlayer) void {
if (footer.open_picker != .quality) return;
Expand All @@ -63,7 +63,11 @@ pub fn renderQualityPickerPopover(active_p: *player.MediaPlayer) void {
const selected = if (i == 3)
active_p.youtube_active_height == 0
else
active_p.youtube_active_height == @as(u16, switch (i) { 0 => 720, 1 => 1080, else => 2160 });
active_p.youtube_active_height == @as(u16, switch (i) {
0 => 720,
1 => 1080,
else => 2160,
});
if (pickerOption(@src(), 9400 + i, icons.tvg.lucide.monitor, label, selected)) {
state.app.ytdl_format_idx = i;
state.markConfigDirty();
Expand Down Expand Up @@ -484,6 +488,17 @@ pub fn renderTrackPickerPopover(active_p: *player.MediaPlayer, track_type: []con
// Bridge to the online finder — subtitles rarely end at the embedded list.
if (kind == .sub) {
components.divider();
const lang = if (state.app.sub_lang_len > 0) state.app.sub_lang_buf[0..state.app.sub_lang_len] else "eng";
var lang_label: [80]u8 = undefined;
const label = std.fmt.bufPrint(&lang_label, "Search language: {s}", .{lang}) catch "Search language";
if (pickerOption(@src(), 9991, icons.tvg.lucide.globe, label, false)) {
footer.closePickers();
footer.picker_anchor[4] = footer.anchorFor(.sub);
footer.open_picker = .lang;
}
if (pickerOption(@src(), 9992, icons.tvg.lucide.languages, "Use this language for audio and subtitles", false)) {
footer.applyUniversalLanguage(active_p.mpv_ctx, lang);
}
if (dvui.button(@src(), "Find subtitles online…", .{}, .{
.id_extra = 990,
.expand = .horizontal,
Expand All @@ -496,6 +511,7 @@ pub fn renderTrackPickerPopover(active_p: *player.MediaPlayer, track_type: []con
})) {
footer.closePickers();
state.app.sub_picker_open = true;
footer.picker_anchor[7] = footer.anchorFor(.sub);
@import("../player/subtitles.zig").searchFromActivePlayer(&state.app.sub_engine);
if (state.app.opensub_api_key_len > 0) {
const subs = @import("../services/subtitles.zig");
Expand Down
16 changes: 13 additions & 3 deletions tests/features/test_page_shell2.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,9 +460,19 @@ def test_sub_picker_keyless_results():
return "fail", "keyed opensubtitles.com section is no longer gated on the key"
if "for more results" not in picker:
return "fail", "no-key hint one-liner missing from the picker"
# Footer chip kicks the keyless search when opening the picker.
if "searchFromActivePlayer(&state.app.sub_engine)" not in footer:
return "fail", "footer Subs chip no longer kicks the keyless search"
# One subtitle trigger opens track/language/search options; its online
# action must open the same result picker and start both real engines.
track_popup = _between(_src("src/ui/pickers.zig"), "pub fn renderTrackPickerPopover", "pub fn renderAudioDevicePickerPopover")
if not ('703, icons.tvg.lucide.captions' in footer
and 'togglePicker(.sub)' in footer
and 'Find subtitles online…' in track_popup
and 'state.app.sub_picker_open = true' in track_popup
and 'searchFromActivePlayer(&state.app.sub_engine)' in track_popup
and 'opensub_api_key_len > 0' in track_popup
and 'subs.autoSearchFromPlayer(false)' in track_popup
and '"sub-visibility"' in track_popup
and 'Search language:' in track_popup):
return "fail", "unified subtitle popup lost tracks/language or actual keyless/keyed search launch"
# Settings list mirrors the same wiring.
if "searchQuery(engine" not in settings and "searchQuery(&state.app.sub_engine" not in settings:
return "fail", "Settings search no longer routes through the keyless engine"
Expand Down
17 changes: 10 additions & 7 deletions tests/features/test_page_shell3.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,15 +201,18 @@ def test_player_dropups():
"chip anchor recorded": "picker_anchor" in ft and "recordAnchor(" in ft,
"panel anchors to the chip": "footer.anchorFor(" in pk,
# One measured spine keeps every control on the same optical baseline.
"44px control spine": (
"36 + 4 + 4 = 44" in ft
and ".min_size_content = .{ .w = 0, .h = 36 }" in ft
and ".max_size_content = .{ .w = 0, .h = 36 }" in ft
"40pt control spine": (
"36 + 2 + 2 = 40" in ft
and ".min_size_content = .{ .w = 0, .h = footer_pure.CONTROL_CONTENT_HEIGHT }" in ft
and ".max_size_content = .{ .w = 0, .h = footer_pure.CONTROL_CONTENT_HEIGHT }" in ft
and ".y = footer_pure.CONTROL_VERTICAL_PADDING" in ft
and "CONTROL_CONTENT_HEIGHT: f32 = 36" in _src("src/ui/footer_pure.zig")
and "CONTROL_VERTICAL_PADDING: f32 = 2" in _src("src/ui/footer_pure.zig")
),
"32px icon controls": _between(ft, "var ctrl_row", "ROW 3").count(
"32px icon controls": _between(ft, "var ctrl_row", "// Secondary controls and transfer actions").count(
".min_size_content = .{ .w = 20, .h = 20 }"
) >= 7,
"transport hover feedback": _between(ft, "var ctrl_row", "ROW 3").count(
"transport hover feedback": _between(ft, "var ctrl_row", "// Secondary controls and transfer actions").count(
".color_fill_hover = playerControlHover()"
) >= 7,
"bright-video contrast": (
Expand Down Expand Up @@ -238,7 +241,7 @@ def test_player_dropups():
and 'icons.tvg.lucide.@"minimize-2"' in ft),
"fullscreen shortcut truthful": ('"Fullscreen (F F)"' in ft
and '"Exit fullscreen (Esc or F F)"' in ft),
"fullscreen no longer seeks": '"seek 10"' not in ft,
"fullscreen no longer seeks": '"seek ' not in _between(ft, "// Fullscreen toggle.", "if (has_playlist)"),
# The old Esc label promised an action Esc does not perform.
"close tooltip truthful": '"Close player (Esc)"' not in ft,
# Hidden fullscreen top chrome must not retain an invisible click target.
Expand Down
35 changes: 30 additions & 5 deletions tests/features/test_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -1261,20 +1261,20 @@ def test_control_bar_v2_wired():
# barLayout must gate real groups, not be computed and ignored.
"layout computed": "footer_pure.barLayout(bar_pt)" in ft,
"gates volume": "if (fit.volume_slider)" in ft,
"gates chips": "if (fit.secondary_chips)" in ft,
"gates badges": "if (fit.status_badges)" in ft,
"secondary options in overflow": "if (open_picker == .more)" in ft,
"gates badges": "if (fit.status_badges and bar_pt >= 1400)" in ft,
"gates skips": "fit.skip_buttons" in ft,
# Width measured in on-screen points, same rule as the shell.
"width in points": "layoutPoints(" in ft and "state.app.ui_scale" in ft,
# Thresholds record the measurement that set them.
"thresholds measured": "clipped the close button" in fp,
"conservative width budget": "Conservative width budgets for the single strip" in fp,
}
bad = [k for k, v in checks.items() if not v]
if bad:
return "fail", "control bar v2 wiring incomplete: " + ", ".join(bad)
return "pass", (f"all {len(exported)} footer_pure exports reachable from production; "
"barLayout sheds volume -> chips -> badges -> skips so the close "
"button survives at 460pt (verified on screen at 1200/850/700/460)")
"barLayout moves optional groups to overflow so the close "
"button retains its strip slot; layout and source contracts checked")


@test("Loading screen shows art + rotating facts for every source", "Player")
Expand Down Expand Up @@ -1827,3 +1827,28 @@ def movie_completion_sync():
if missing:
return "fail", "movie history sync incomplete: " + ", ".join(missing)
return "pass", "verified TMDB identity syncs once only after real viewing"

@test("Compact single-strip native playback controls", "Player")
def test_compact_native_playback_strip():
ft = _src("src/ui/footer.zig")
pk = _src("src/ui/pickers.zig")
fp = _src("src/ui/footer_pure.zig")
controls = ft[ft.index("// ROW 2 — Controls:"):ft.index("// Secondary controls and transfer actions")]
checks = {
"shared compact height": "footer_pure.CONTROL_CONTENT_HEIGHT" in controls and controls.count("footer_pure.CONTROL_VERTICAL_PADDING") == 2,
"filename shares strip": "footer_pure.compactTitle(" in controls and "textSizeEx(" in controls,
"filename cannot pin its old minimum width": ".max_size_content = .{ .w = 0," in _between(controls, "var title_host", "const title_width"),
"no separate title/status row": "np_row" not in ft and "ROW 3" not in ft,
"narrow navigation retained": "Previous episode" in ft and "Next episode" in ft and "Previous track" in ft and "Next track" in ft,
"secondary transfer options": "if (open_picker == .more)" in ft and 'confirmDangerButton(@src(), "Delete", 201)' in ft,
"secondary options explain their values": "Picture: {s}" in ft and '"Output device"' in ft and "Download limit: Unlimited" in ft,
"one subtitle trigger": '703, icons.tvg.lucide.captions' in controls and '704,' not in controls and '705,' not in controls and '709,' not in controls,
"subtitle actions retained": "Search language:" in pk and "Use this language for audio and subtitles" in pk and "Find subtitles online…" in pk,
"subtitle panels track the shared trigger": "if (kind == .sub)" in _between(ft, "fn recordAnchor", "// Persist the close-button") and "@intFromEnum(PickerKind.subs)" in _between(ft, "fn recordAnchor", "// Persist the close-button") and "@intFromEnum(PickerKind.lang)" in _between(ft, "fn recordAnchor", "// Persist the close-button"),
"secondary panels track More without moving CC panels": "[_]PickerKind{ .aspect, .chapter, .quality, .audio_device, .playlist }" in _between(ft, "if (kind == .more)", "// Persist the close-button") and "@intFromEnum(secondary)" in _between(ft, "if (kind == .more)", "// Persist the close-button"),
"wheel seek belongs to scrubber": "scrubber_wheel_rect = band_rect" in ft and "const r = scrubber_wheel_rect" in ft,
"keyboard focus retained": "dvui.tabIndexSet(btn_id, null)" in ft and 'matchBind("activate")' in ft,
"boundary regressions": "compact title respects measured width and UTF8 boundaries" in fp,
}
bad = [key for key, good in checks.items() if not good]
return ("fail", ", ".join(bad)) if bad else ("pass", "40pt strip shares measured filename; subtitle actions and confirmed transfer actions retained in drop-ups")
Loading