diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f96db6..7401b15f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,21 @@ match the tag exactly. ## Unreleased +- **Desktop and web controls fit their content.** Native Browse uses the global + search field; local library forms, playback menus and torrent file dialogs + keep their actions visible. Web artwork has bounded dimensions and useful + fallbacks, and compact search controls stay readable. +- **macOS playback controls reclaim title space.** The native title bar carries + the media name; bottom controls focus on transport and playback options. +- **Idle macOS windows close cleanly from the terminal.** A bounded event pump + lets Ctrl+C and process termination reach normal cleanup. +- **Playback stays in Opal.** External VLC launch controls and their launch + service have been removed. +- **Small text is readable in every native theme.** Captions and secondary + labels retain contrast on raised controls and cards. +- **CJK media titles render correctly.** Search and playback titles select a + bundled CJK face when needed, preserving the regular UI typography. + - **Audio tracks finish and advance correctly.** Files no longer repeat forever by default. Audiobooks support whole-book resume across files and automatic track advancement, including headless playback. @@ -115,8 +130,7 @@ match the tag exactly. cancels pending reads and clears its last frame before removing downloaded files; the empty player offers a local file picker or universal search. - **Windows playback controls fit scaled displays.** The native titlebar - hitboxes follow DPI scaling, compact navigation stays accessible, and an - optional VLC handoff is available under Playback settings. + hitboxes follow DPI scaling and compact navigation stays accessible. - **Source installation explains progress and failures.** Its current stage, final status, redacted logs, and recovery steps are visible in the catalog. - **Cinemeta poster artwork loads from full image URLs.** Movies & TV no diff --git a/README.md b/README.md index f804563b..9e8257f1 100644 --- a/README.md +++ b/README.md @@ -209,8 +209,7 @@ Hover toolbar icons for labels, or scroll the toolbar in narrow windows. Click a result or **Play** to watch; **+** queues a torrent and **Details** opens title info. **×** on Now Playing stops playback. Find controls in **More → Playback options**. -Install VLC to use **Settings → Playback → Open in VLC**. Source installation -errors include recovery steps in redacted **Logs**. +Source installation errors include recovery steps in redacted **Logs**. For temporary torrents, enable **Settings → Network → Stream torrents in memory**. Choose 128, 256 (default), or 512 MiB per new torrent. Seeking may redownload pieces; diff --git a/build.zig b/build.zig index eef93ac0..aa880209 100644 --- a/build.zig +++ b/build.zig @@ -213,6 +213,46 @@ pub fn build(b: *std.Build) void { msys_path_prefix, b.graph.environ_map.get("PATH") orelse "", })); b.step("test-browse", "Test production catalog parsing and asynchronous publication").dependOn(&run_browse_tests.step); + const gallery_tests = b.addTest(.{ .root_module = exe.root_module, .filters = &.{"Native Search gallery offline SDL pixel capture"} }); + const run_gallery_tests = b.addRunArtifact(gallery_tests); + if (is_windows) run_gallery_tests.setEnvironmentVariable("PATH", b.fmt("{s};{s}", .{ msys_path_prefix, b.graph.environ_map.get("PATH") orelse "" })); + b.step("test-search-gallery", "Render the native search gallery in an isolated hidden SDL window").dependOn(&run_gallery_tests.step); + const media_tests = b.addTest(.{ .root_module = exe.root_module, .filters = &.{"Native media offline SDL fixture"} }); + const run_media_tests = b.addRunArtifact(media_tests); + if (is_windows) run_media_tests.setEnvironmentVariable("PATH", b.fmt("{s};{s}", .{ msys_path_prefix, b.graph.environ_map.get("PATH") orelse "" })); + b.step("test-native-media", "Render native playback controls in an isolated hidden SDL window").dependOn(&run_media_tests.step); + const shell_tests = b.addTest(.{ .root_module = exe.root_module, .filters = &.{"Native shell offline route pixel capture"} }); + const run_shell_tests = b.addRunArtifact(shell_tests); + if (is_windows) run_shell_tests.setEnvironmentVariable("PATH", b.fmt("{s};{s}", .{ msys_path_prefix, b.graph.environ_map.get("PATH") orelse "" })); + b.step("test-native-shell", "Capture native shell routes with isolated offline fixtures").dependOn(&run_shell_tests.step); + const modal_tests = b.addTest(.{ .root_module = exe.root_module, .filters = &.{"Native global modals offline SDL pixel capture"} }); + const run_modal_tests = b.addRunArtifact(modal_tests); + if (is_windows) run_modal_tests.setEnvironmentVariable("PATH", b.fmt("{s};{s}", .{ msys_path_prefix, b.graph.environ_map.get("PATH") orelse "" })); + b.step("test-native-modals", "Capture native onboarding and dialogs in an isolated hidden SDL window").dependOn(&run_modal_tests.step); + const activity_tests = b.addTest(.{ .root_module = exe.root_module, .filters = &.{"Native activity offline SDL pixel capture"} }); + const run_activity_tests = b.addRunArtifact(activity_tests); + if (is_windows) run_activity_tests.setEnvironmentVariable("PATH", b.fmt("{s};{s}", .{ msys_path_prefix, b.graph.environ_map.get("PATH") orelse "" })); + b.step("test-native-activity", "Capture native queue and transfers with isolated offline fixtures").dependOn(&run_activity_tests.step); + // Fixture rendering may synchronously inspect plugin/profile directories. + // Isolate the child process even when callers run these targets directly. + const native_profile_root = b.cache_root.join(b.allocator, &.{"native-fixture-profiles"}) catch @panic("native fixture profile path"); + const native_capture_runs = [_]struct { run: *std.Build.Step.Run, name: []const u8 }{ + .{ .run = run_gallery_tests, .name = "gallery" }, + .{ .run = run_media_tests, .name = "media" }, + .{ .run = run_shell_tests, .name = "shell" }, + .{ .run = run_modal_tests, .name = "modals" }, + .{ .run = run_activity_tests, .name = "activity" }, + }; + for (native_capture_runs) |fixture| { + const profile = std.fs.path.resolve(b.allocator, &.{ b.build_root.path orelse ".", native_profile_root, fixture.name }) catch @panic("native fixture profile path"); + fixture.run.setEnvironmentVariable("HOME", profile); + fixture.run.setEnvironmentVariable("USERPROFILE", profile); + fixture.run.setEnvironmentVariable("XDG_CONFIG_HOME", b.fmt("{s}/config", .{profile})); + fixture.run.setEnvironmentVariable("XDG_CACHE_HOME", b.fmt("{s}/cache", .{profile})); + fixture.run.setEnvironmentVariable("XDG_DATA_HOME", b.fmt("{s}/data", .{profile})); + fixture.run.setEnvironmentVariable("APPDATA", b.fmt("{s}/config", .{profile})); + fixture.run.setEnvironmentVariable("LOCALAPPDATA", b.fmt("{s}/cache", .{profile})); + } // DPI-bypass sidecar (debpalash/zig-bypassdpi): a cross-platform userspace // proxy that fragments the TLS ClientHello so ISP DPI can't read the SNI. @@ -834,6 +874,31 @@ pub fn build(b: *std.Build) void { // throttle, the volume ramp, the transport state machine, and the // width-based collapse order. All of it is wired into footer.zig — a test // there asserts no export goes unreachable. + const test_search_reading_pure = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("src/services/search_reading_pure.zig"), + .target = target, + .optimize = optimize, + }) }); + test_step.dependOn(&b.addRunArtifact(test_search_reading_pure).step); + const test_search_content_pure = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("src/services/search_content_pure.zig"), + .target = target, + .optimize = optimize, + }) }); + test_step.dependOn(&b.addRunArtifact(test_search_content_pure).step); + const test_search_preview_pure = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("src/services/search_preview_pure.zig"), + .target = target, + .optimize = optimize, + }) }); + test_step.dependOn(&b.addRunArtifact(test_search_preview_pure).step); + const test_search_gallery_pure = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("src/ui/search_gallery_pure.zig"), + .target = target, + .optimize = optimize, + }) }); + test_step.dependOn(&b.addRunArtifact(test_search_gallery_pure).step); + const test_search_view_pure = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/services/search_view_pure.zig"), .target = target, @@ -1400,6 +1465,15 @@ pub fn build(b: *std.Build) void { }); test_step.dependOn(&b.addRunArtifact(test_scale_pure).step); + const test_event_loop_pure = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/core/event_loop_pure.zig"), + .target = target, + .optimize = optimize, + }), + }); + test_step.dependOn(&b.addRunArtifact(test_event_loop_pure).step); + // Windows title-bar control hitboxes must follow the scaled player // overlay without pushing buttons outside a laptop-sized viewport. const test_titlebar_geometry = b.addTest(.{ @@ -1642,6 +1716,22 @@ pub fn build(b: *std.Build) void { }), }); test_step.dependOn(&b.addRunArtifact(test_tv_layout).step); + const test_local_library_layout = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/ui/local_library_layout_pure.zig"), + .target = target, + .optimize = optimize, + }), + }); + test_step.dependOn(&b.addRunArtifact(test_local_library_layout).step); + const test_browse_layout = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/ui/browse_layout_pure.zig"), + .target = target, + .optimize = optimize, + }), + }); + test_step.dependOn(&b.addRunArtifact(test_browse_layout).step); const test_episode_art = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/services/episode_art_pure.zig"), diff --git a/docs/browse-sources.md b/docs/browse-sources.md index c4a0d4c5..1ff2f0a1 100644 --- a/docs/browse-sources.md +++ b/docs/browse-sources.md @@ -226,3 +226,85 @@ audio advancing across two files, and whole-book resume into the second file. Radio actions now use stable station UUIDs, and comics actions copy the selected reader identity. These fixtures establish those contracts; they do not establish external provider uptime or configured personal-server availability. + +## Universal content search coverage (2026-10-03) + +Universal search projects content works separately from their source options. +Positive typed catalog identities and matching IMDb identities retain their media +kind. Movie releases attach only with an exact normalized title and matching +positive year. Explicit `Sxx` / `SxxExx` releases can attach to a uniquely matching +TV title without a year; known same-title remakes remain ambiguous even when a +facet hides one. Ambiguous releases remain independently actionable. + +The shared wave retains at most 192 rows. At capacity, an underrepresented +category can reclaim rows from categories containing more than eight rows; +within represented categories, relevance selects replacements. This protects +late catalog arrivals from torrent saturation without promising every provider +result will fit. Searches fetch bounded first pages; universal pagination across +all providers is not implemented. + +| Content | Independent universal search providers | Conditions and limits | +| --- | --- | --- | +| Movies and shows | TMDB or keyless Cinemeta; installed Nova2 torrent engines, YTS, EZTV, configured Torznab; configured Stremio, local files, Jellyfin and Plex | Catalog search runs independently of playable-source toggles. Release quality and swarm health rank source options, rather than substituting for content identity. Personal services require configuration. | +| Anime | Jikan, with AniList fallback; installed AllAnime; anime torrent engines through the shared torrent search | Metadata search retains real title, cover, synopsis, year and rating. Metadata opens Anime discovery; it is not a verified playable stream. Up to twelve metadata rows are admitted per adapter. | +| Comics and manga | Keyless MangaDex; installed ReadAllComics | MangaDex retains its real cover and stable reader identity, and opens Comics discovery. The other Comics Browse adapters are not yet universal workers. | +| Books and novels | Wikisource, Internet Archive English DjVuTXT works, Gutenberg, public-access Open Library works, installed Royal Road and NovelFire; configured OPDS | Royal Road and NovelFire use the existing Browse listing parser with owned query/endpoint buffers and at most twelve works each. Challenge pages are parser failures, not successful empty listings. No Browse query or results are overwritten. Restricted Open Library lending items are excluded. | +| Visual novels | VNDB | Metadata discovery only; no game download or playback claim. | +| Audiobooks | Configured Audiobookshelf; Internet Archive audio | Audiobookshelf uses allowed libraries and real file metadata. Actual service availability depends on the configured server. | +| Music | Public JioSaavn; installed Audius; configured Subsonic; connected Jellyfin Audio and Plex tracks | Independent queries do not replace Music Browse state. A shared **16-result** budget is divided fairly among configured providers. Requests are bounded to four seconds and 512 KiB each. Private actions retain provider IDs and bind the searched server/library namespace; playback obtains fresh credentials. Private artwork URLs are withheld. | +| Podcasts | Apple Podcasts and gpodder; installed RSS feeds | Independent searches retain exact feed identities and share a **16-result** budget. At most four installed feeds are inspected per query, with two-second requests; additional installed feeds produce a partial-source status. The two public directories share the remaining budget, with four-second requests. Open a show in Podcasts and choose an episode to play. | +| Radio and live TV | Radio Browser; installed/configured IPTV | Each adapter contributes up to 16 results and retains actual station/channel stream identities. | +| Video and previews | YouTube search, Internet Archive video, NASA video, Wikimedia Commons video; explicit official TMDB trailer lookup | Preview lookup requires a current opaque result key and generation. TMDB previews require credentials and a verified official YouTube video; unavailable credentials or trailers remain explicit outcomes. Preview playback does not stand in for the full work. | +| Images and articles | Cover/backdrop metadata only; no standalone universal image or article adapter | NASA and Commons workers currently request video. Existing RSS records expose torrent magnets, rather than an article reader contract. | +| Installed executable plugins | Trusted installed plugin search workers | Coverage depends on each plugin's actual search contract and installation. An installed source configuration alone does not imply a universal worker exists. | + +Reading cards carry covers only when supplied by the actual listing/provider; +authors and summaries are not invented for HTML listings that omit them. Other +installed novel Browse adapters (Madara, LightNovelWP, ReadWN and ReadNovelFull) +are not yet universal workers. + +Search cache identity includes schema, source mask and an opaque fingerprint of +installed source configuration. Queries are hashed into bounded keys; endpoint +URLs and credentials are not emitted as cache-key text. A wave captures its +initial scope and skips cache writes when that scope changes while workers run. +Connected personal-library configuration outside the installed-source table is +not represented by that fingerprint. Account-bound Jellyfin, Plex, OPDS and +Audiobookshelf results, private music identities, installed executable plugin +results and unclassified Stremio session transports are therefore excluded from +the persisted search cache. They remain live search results and are fetched +afresh for each query. Public catalog/artwork and explicitly public Archive, +NASA, Commons, JioSaavn and Audius rows remain eligible. The cache policy version +invalidates earlier cached account rows. External provider uptime, personal +account access and full interactive playback are separate validation requirements. + +### Visual search behavior and checks + +The desktop omnibox submits a global query from every Browse page. All-content +results use artwork shelves; selecting a content category expands its results +into a wrapping grid. Cards reveal contextual details, saved-title controls, +and source comparisons. Unmatched releases remain separately actionable. +Saved titles reopen through global search rather than an expired stream URL. + +Official trailer playback starts only on request, muted. Desktop uses a separate +libmpv context; the web client uses a validated YouTube privacy-enhanced embed. +Changing the query, selection, route, or closing the preview stops it. Trailer +lookup requires a TMDB key. Audio samples are not fabricated when a provider +does not supply a verified sample. + +Reproducible checks: + +```sh +zig build test +node --test tests/test_web_lifecycle.mjs +zig build test-search-gallery +python3 tests/test_features.py --database /tmp/opal-feature-fixture.sqlite --results /tmp/opal-feature-results.json +``` + +The optional native gallery check renders a hidden SDL window with generated +offline artwork; `OPAL_GALLERY_ART_DIR` can supply local poster fixtures and +`OPAL_GALLERY_CAPTURE_DIR` can retain PNG captures. It exercises shelves, filtered +grids, and scrolling at 1360×1000 and 640×800. No provider request or playback +occurs. Web regressions cover grouping, stale actions, filters, saving, and +verified preview lifecycle. Isolated API cases in +`tests/test_content_maturity_live.py` additionally cover preview/cancel generation +validation and saved-title persistence. diff --git a/src/assets/fonts/LICENSE-NOTO-CJK.txt b/src/assets/fonts/LICENSE-NOTO-CJK.txt new file mode 100644 index 00000000..79a917be --- /dev/null +++ b/src/assets/fonts/LICENSE-NOTO-CJK.txt @@ -0,0 +1,93 @@ +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/fonts/NotoSansKR-Regular.ttf b/src/assets/fonts/NotoSansKR-Regular.ttf new file mode 100644 index 00000000..1b14d324 Binary files /dev/null and b/src/assets/fonts/NotoSansKR-Regular.ttf differ diff --git a/src/assets/fonts/README.md b/src/assets/fonts/README.md index d4a5fbb1..ef5401d9 100644 --- a/src/assets/fonts/README.md +++ b/src/assets/fonts/README.md @@ -9,3 +9,11 @@ The files were sourced from Arch Linux's `noto-fonts` package - `NotoSans-Regular.ttf`: `478c558ea716033cd60c03438f628dfa75694dcf6b5f6d505a2f05fd2b4f3823` - `NotoSans-Bold.ttf`: `1df075a380fc7cb898acf64c1f7b3b4dd780de3caa860178bf929de35817a913` + +Media titles containing Han, kana, or Hangul use `NotoSansKR-Regular.ttf` +from the pinned DVUI dependency (`dvui 0.5.0-dev`), with its original bytes. +This face includes Japanese, Chinese, and Korean glyphs; it does not promise +coverage for arbitrary Unicode. Other titles retain the regular UI font. +It is distributed under SIL OFL 1.1; see `LICENSE-NOTO-CJK.txt`. + +- `NotoSansKR-Regular.ttf`: `9db318b65ee9c575a43e7efd273dbdd1afef26e467eea3e1073a50e1a6595f6d` diff --git a/src/core/event_loop_pure.zig b/src/core/event_loop_pure.zig new file mode 100644 index 00000000..90b950f4 --- /dev/null +++ b/src/core/event_loop_pure.zig @@ -0,0 +1,13 @@ +//! SDL2 on macOS converts pending process signals while pumping events. +const std = @import("std"); + +pub fn nativeSignalPumpMicros(os: std.Target.Os.Tag) ?i32 { + // Cocoa can otherwise wait indefinitely on an idle, media-free window. + return if (os == .macos) 1_000_000 else null; +} + +test "macOS idle event pump remains bounded without changing other platforms" { + try std.testing.expectEqual(@as(?i32, 1_000_000), nativeSignalPumpMicros(.macos)); + try std.testing.expect(nativeSignalPumpMicros(.linux) == null); + try std.testing.expect(nativeSignalPumpMicros(.windows) == null); +} diff --git a/src/core/source_config.zig b/src/core/source_config.zig index fe4c9d8a..d561afa1 100644 --- a/src/core/source_config.zig +++ b/src/core/source_config.zig @@ -35,6 +35,33 @@ const Entry = struct { var entries: [MAX_ENTRIES]Entry = undefined; var entry_count: usize = 0; var mutex: @import("sync.zig").Mutex = .{}; +pub const EntryFieldSnapshot = pure.FieldSnapshot; + +/// Owned atomic multi-field source view. Null means absent or insufficient +/// output capacity; callers must never use partially copied credentials. +pub fn copyEntry(id: []const u8, out: []EntryFieldSnapshot) ?usize { + mutex.lock(); + defer mutex.unlock(); + return pure.copyEntryFields(entries[0..entry_count], id, out); +} + +pub fn copyValue(id: []const u8, field: []const u8, out: []u8) ?[]const u8 { + mutex.lock(); + defer mutex.unlock(); + for (entries[0..entry_count]) |entry| { + if (!std.mem.eql(u8, entry.id[0..entry.id_len], id) or !std.mem.eql(u8, entry.field[0..entry.field_len], field)) continue; + if (entry.val_len > out.len) return null; + @memcpy(out[0..entry.val_len], entry.val[0..entry.val_len]); + return out[0..entry.val_len]; + } + return null; +} + +pub fn fingerprint() u64 { + mutex.lock(); + defer mutex.unlock(); + return pure.fingerprintEntries(entries[0..entry_count]); +} const Protection = union(enum) { unchanged, diff --git a/src/core/source_config_pure.zig b/src/core/source_config_pure.zig index 69818142..4482e3fc 100644 --- a/src/core/source_config_pure.zig +++ b/src/core/source_config_pure.zig @@ -30,6 +30,57 @@ pub const MAX_ENTRIES = 512; pub const MAX_ID_LEN = 32; pub const MAX_FIELD_LEN = 24; pub const MAX_VAL_LEN = 512; +pub const FieldSnapshot = struct { + field: [MAX_FIELD_LEN]u8 = @splat(0), + field_len: usize = 0, + val: [MAX_VAL_LEN]u8 = @splat(0), + val_len: usize = 0, + pub fn value(self: *const FieldSnapshot, name: []const u8) ?[]const u8 { + return if (std.mem.eql(u8, name, self.field[0..self.field_len])) self.val[0..self.val_len] else null; + } +}; +/// Order independent configuration identity. Only used inside encrypted cache +/// keys; callers must not expose the digest or source values to remote clients. +pub fn fingerprintEntries(entries: anytype) u64 { + var result: u64 = 0; + for (entries) |entry| { + var hash = std.hash.Wyhash.init(0); + inline for (.{ entry.id[0..entry.id_len], entry.field[0..entry.field_len], entry.val[0..entry.val_len] }) |part| { + const length: u64 = @intCast(part.len); + hash.update(std.mem.asBytes(&length)); + hash.update(part); + } + result +%= hash.final(); + } + return result; +} +pub fn copyEntryFields(entries: anytype, id: []const u8, out: []FieldSnapshot) ?usize { + var count: usize = 0; + for (entries) |entry| { + if (!std.mem.eql(u8, id, entry.id[0..entry.id_len])) continue; + if (count >= out.len) return null; // Never publish partial credentials. + out[count] = .{ .field_len = entry.field_len, .val_len = entry.val_len }; + @memcpy(out[count].field[0..entry.field_len], entry.field[0..entry.field_len]); + @memcpy(out[count].val[0..entry.val_len], entry.val[0..entry.val_len]); + count += 1; + } + return if (count > 0) count else null; +} + +test "source snapshots own values and configuration identity ignores enumeration order" { + const Entry = struct { id: []const u8, id_len: usize, field: []const u8, field_len: usize, val: []const u8, val_len: usize }; + var value = [_]u8{'a'}; + const a = Entry{ .id = "source", .id_len = 6, .field = "base", .field_len = 4, .val = &value, .val_len = 1 }; + const b = Entry{ .id = "source", .id_len = 6, .field = "user", .field_len = 4, .val = "b", .val_len = 1 }; + var fields: [2]FieldSnapshot = undefined; + try std.testing.expectEqual(@as(?usize, 2), copyEntryFields(&[_]Entry{ a, b }, "source", &fields)); + const before = fingerprintEntries(&[_]Entry{ a, b }); + try std.testing.expectEqual(before, fingerprintEntries(&[_]Entry{ b, a })); + value[0] = 'c'; + try std.testing.expectEqualStrings("a", fields[0].value("base").?); + try std.testing.expect(before != fingerprintEntries(&[_]Entry{ a, b })); + try std.testing.expectEqual(@as(?usize, null), copyEntryFields(&[_]Entry{ a, b }, "source", fields[0..1])); +} /// Whether a source id is safe to use as a filename. /// diff --git a/src/headless.zig b/src/headless.zig index 8109f27c..1be39e78 100644 --- a/src/headless.zig +++ b/src/headless.zig @@ -66,6 +66,8 @@ pub fn headlessMain() !void { } io.sleep(10 * std.time.ns_per_ms); } + const app_state = &@import("core/state.zig").app; + @import("services/search_preview.zig").publishCredentials(app_state.tmdb.api_key[0..app_state.tmdb.api_key_len]); @import("core/state.zig").app.web_remote_enabled = true; const remote = @import("services/remote.zig"); remote.start(); diff --git a/src/main.zig b/src/main.zig index fa4fd094..c121dc88 100644 --- a/src/main.zig +++ b/src/main.zig @@ -14,6 +14,21 @@ const std = @import("std"); test "TV detail module" { _ = @import("services/tmdb.zig"); } +test "Native Search gallery offline SDL pixel capture" { + if (!@import("build_options").headless) _ = @import("services/search_gallery_native_test.zig"); +} +test "Native media offline SDL fixture" { + if (!@import("build_options").headless) _ = @import("ui/media_native_test.zig"); +} +test "Native shell offline route pixel capture" { + if (!@import("build_options").headless) _ = @import("ui/shell_native_test.zig"); +} +test "Native global modals offline SDL pixel capture" { + if (!@import("build_options").headless) _ = @import("ui/modals_native_test.zig"); +} +test "Native activity offline SDL pixel capture" { + if (!@import("build_options").headless) _ = @import("ui/activity_native_test.zig"); +} const builtin = @import("builtin"); const dvui = @import("dvui"); const c = @import("core/c.zig"); @@ -832,6 +847,7 @@ pub fn appDeinit() void { // allocator it may publish into are released. 800 ms is the diagnostic // threshold, not permission for a worker to outlive shared state. workers.beginShutdownAndDrain(800); + search.deinitGallery(); @import("player/drop_ingest.zig").deinit(); @import("services/queue.zig").deinit(); @@ -1079,6 +1095,13 @@ fn renderSlashMenu() void { } fn appFrame() !dvui.App.Result { + // SDL2 defers SIGINT/SIGTERM until its next event pump. Cocoa's native + // wait may sleep indefinitely when no media or widgets request frames. + // Keep that wait bounded so Ctrl+C reaches the normal close/cleanup path. + if (@import("core/event_loop_pure.zig").nativeSignalPumpMicros(builtin.os.tag)) |interval| { + const signal_tick = dvui.Id.extendId(null, @src(), 0); + if (dvui.timerDoneOrNone(signal_tick)) dvui.timer(signal_tick, interval); + } // Suppress dvui's debug widget outline (red 1px rect) — shows when // debug.widget_id matches a rendered widget. Can get stuck if user // accidentally toggles dvui debug panel. @@ -1113,11 +1136,13 @@ fn appFrame() !dvui.App.Result { // Apply any theme change requested off the UI thread (config.load runs // theme.setPreset on the background worker, which can't touch dvui directly). theme.reapplyIfPending(); + if (state.app.config_loaded.load(.acquire)) @import("services/search_preview.zig").publishCredentials(state.app.tmdb.api_key[0..state.app.tmdb.api_key_len]); // Queue producers include playlist/AI/remote worker threads. Drain their // bounded commands here so live queue and player state stay UI-thread-owned. @import("services/queue.zig").drainUi(); @import("services/resolver.zig").drainRemoteAction(); + @import("services/search.zig").updateGalleryRoute(); @import("services/search.zig").drainMemorySearch(); @import("services/auto_subs.zig").pollAttach(); @import("player/watch_history.zig").drainUi(); diff --git a/src/player/player.zig b/src/player/player.zig index 1916ca0c..0a0e3d40 100644 --- a/src/player/player.zig +++ b/src/player/player.zig @@ -21,6 +21,7 @@ const MAX_LOAD_URL = 8192; /// drives the same two-method interface against an in-memory fake in tests. const MpvPlaybackSink = struct { ctx: *c.mpv.mpv_handle, + last_result: c_int = 0, fn stringNode(value: [*:0]const u8) c.mpv.mpv_node { return .{ @@ -119,6 +120,7 @@ const MpvPlaybackSink = struct { .format = c.mpv.MPV_FORMAT_NODE_ARRAY, }; const rc = c.mpv.mpv_command_node(self.ctx, &command_node, null); + self.last_result = rc; if (rc < 0) { // A rejected load used to be silent: mpv wrote one line to stdout // and the UI showed "Opening stream" forever. Say what happened, @@ -145,6 +147,16 @@ const MpvPlaybackSink = struct { } }; +/// Reuse the typed transport boundary for an isolated preview context. This +/// command has no MediaPlayer, navigation, progress, or playback history side +/// effects, and cannot target a context registered as a main player. +pub fn loadDetached(ctx: *c.mpv.mpv_handle, request: LoadRequest) bool { + for (state.app.players.items) |registered| if (registered.mpv_ctx == ctx) return false; + var sink: MpvPlaybackSink = .{ .ctx = ctx }; + if (!playback_load.dispatch(&sink, request)) return false; + return sink.last_result >= 0; +} + pub const video_w = 1920; pub const video_h = 1080; @@ -623,7 +635,7 @@ pub const MediaPlayer = struct { pub fn getMediaTitle(self: *MediaPlayer, out_buf: []u8) usize { // 1. If torrent, get torrent name if (self.current_torrent_id >= 0) { - var t_name: [256]u8 = undefined; + var t_name: [256]u8 = @splat(0); c.mpv.torrent_get_name(state.torrentSession(), self.current_torrent_id, &t_name, 256); const tn_len = std.mem.indexOfScalar(u8, &t_name, 0) orelse 0; if (tn_len > 0) { diff --git a/src/services/anime.zig b/src/services/anime.zig index 5aca41e2..8cbc8289 100644 --- a/src/services/anime.zig +++ b/src/services/anime.zig @@ -134,6 +134,22 @@ var fetched_cal_day: u8 = 255; var fetched_nsfw_filter: bool = true; var clear_for_safe_refresh: bool = false; +/// Offline pixel fixture uses the real grid without admitting provider work. +pub fn setNativeFixtureForTest(rows: []const state.AnimeResult) void { + if (!@import("builtin").is_test) @compileError("Native fixture is test-only"); + const a = &state.app.anime; + a.result_count = @min(rows.len, a.results.len); + @memcpy(a.results[0..a.result_count], rows[0..a.result_count]); + a.mode = .trending; + a.selected_idx = null; + a.last_fetch_s = 9_999_999_999; + a.is_loading.store(false, .release); + fetched_mode = .trending; + fetched_nsfw_filter = state.app.nsfw_filter_enabled; + clear_for_safe_refresh = false; + results_are_scraper = false; +} + /// Switch the active browse mode. Resets the SWR stamp so the explicit switch /// always refetches, bumps the generation (drops any in-flight worker), and /// clears the selection so we land on the grid. The actual fetch is kicked by @@ -3367,10 +3383,23 @@ fn syncNsfwFilter() void { // Mode toolbar (Trending | Seasonal | Calendar | Search | My List) // ══════════════════════════════════════════════════════════ +var native_toolbar_rect: dvui.Rect.Physical = undefined; +pub fn nativeToolbarRectForTest() dvui.Rect.Physical { + if (!@import("builtin").is_test) @compileError("Native fixture is test-only"); + return native_toolbar_rect; +} fn renderModeToolbar(count: usize) void { - // ONE wrapping flexbox holding the mode tabs, the per-mode chips, the result - // count and the card-size controls — all on a single row (wraps if narrow). - var bar = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ + // Keep controls in one scrollable row; narrow windows retain the gallery. + const height = @import("../ui/browse_layout_pure.zig").toolbarHeight(dvui.themeGet().font_body.size); + var scroll = dvui.scrollArea(@src(), .{ .horizontal = .auto, .horizontal_bar = .auto_overlay, .vertical = .none }, .{ + .expand = .horizontal, + .min_size_content = .{ .w = 0, .h = height }, + .max_size_content = dvui.Options.MaxSize.height(height), + .background = false, + }); + defer scroll.deinit(); + if (@import("builtin").is_test) native_toolbar_rect = scroll.data().borderRectScale().r; + var bar = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .padding = .{ .x = 8, .y = 8, .w = 8, .h = 6 }, .background = true, @@ -3381,7 +3410,7 @@ fn renderModeToolbar(count: usize) void { renderModeTab(0, .trending, "Trending"); renderModeTab(1, .seasonal, "Seasonal"); renderModeTab(2, .calendar, "Calendar"); - renderModeTab(3, .search, "Search"); + if (@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) renderModeTab(3, .search, "Search"); renderModeTab(4, .mylist, "My List"); // ── "Airing this week" view toggle (AniList schedule) — a VIEW, not a mode, @@ -3834,6 +3863,7 @@ fn renderRelationsRail() void { /// Search box with LIVE / incremental search-as-you-type (350ms debounce) plus /// the explicit Enter / button path. Empty query → trending. fn renderSearchBar() void { + if (!@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) return; const io = @import("../core/io_global.zig"); var search_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ diff --git a/src/services/browser.zig b/src/services/browser.zig index ca8bf8bf..98245ae9 100644 --- a/src/services/browser.zig +++ b/src/services/browser.zig @@ -2690,6 +2690,10 @@ pub fn loadContentDirectMetaHeaders( /// Load content with automatic provider routing pub fn loadContent(url: []const u8) void { + if (std.mem.startsWith(u8, url, "opal://music/")) { + _ = @import("music_subsonic.zig").playUniversalRoute(url, "Music", ""); + return; + } const extractors = @import("extractors.zig"); // Normalize URL @@ -2756,10 +2760,14 @@ pub fn loadContent(url: []const u8) void { /// launch Resume prompt are *known playback*, so this forces the player: magnets /// go through the torrent engine, comics to the reader, everything else into mpv. pub fn resumePlayback(url: []const u8) void { + if (std.mem.startsWith(u8, url, "opal://music/")) { + _ = @import("music_subsonic.zig").playUniversalRoute(url, "Music", ""); + return; + } if (std.mem.startsWith(u8, url, "opal://search/")) { const query = url["opal://search/".len..]; if (query.len > 0) { - @import("search.zig").triggerSearch(query); + @import("search.zig").submitQuery(query); state.app.router.navigate(.search); } return; diff --git a/src/services/comics.zig b/src/services/comics.zig index 3a2b1ff6..accb3d29 100644 --- a/src/services/comics.zig +++ b/src/services/comics.zig @@ -1643,6 +1643,30 @@ var sr_searching_v: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); var loaded_default: bool = false; var default_requested = std.atomic.Value(bool).init(false); var last_fetch_s: i64 = 0; // SWR cache timestamp + +pub fn setNativeFixtureForTest(textures: []const dvui.Texture) void { + if (!@import("builtin").is_test) @compileError("Native fixture is test-only"); + freeSearchCovers(); + sr_count = @min(textures.len, MAX_SEARCH_RESULTS); + loaded_default = true; + last_fetch_s = 9_999_999_999; + sr_searching_v.store(false, .release); + const title = "A long illustrated story — Adventures Beyond the Distant Horizon"; + const url = "https://example.invalid/offline-comic"; + const gen = search_gen.load(.acquire); + covers_render_gen = gen; + for (textures[0..sr_count], 0..) |art, i| { + @memcpy(sr_titles[i][0..title.len], title); + sr_title_lens[i] = title.len; + @memcpy(sr_urls[i][0..url.len], url); + sr_url_lens[i] = url.len; + sr_cover_url_lens[i] = 0; + sr_cover_tex[i] = art; + sr_cover_w[i] = art.width; + sr_cover_h[i] = art.height; + sr_cover_gen[i] = gen; + } +} var sr_query_buf: [256]u8 = undefined; var sr_query_len: usize = 0; @@ -3225,53 +3249,55 @@ pub fn renderContent() void { defer bar.deinit(); const components = @import("../ui/components.zig"); - const input = std.mem.sliceTo(&state.app.comic.search_buf, 0); - const is_url = std.mem.startsWith(u8, input, "http"); + if (@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) { + const input = std.mem.sliceTo(&state.app.comic.search_buf, 0); + const is_url = std.mem.startsWith(u8, input, "http"); - // Canonical compact toolbar input — fixed width instead of the old - // full-width bar; pasted URLs still load directly. - const enter_pressed = components.toolbarSearch(@src(), &state.app.comic.search_buf, "Search comics… (or URL)", 260); + // Canonical compact toolbar input — fixed width instead of the old + // full-width bar; pasted URLs still load directly. + const enter_pressed = components.toolbarSearch(@src(), &state.app.comic.search_buf, "Search comics… (or URL)", 260); - // Clear button (×) — visible only when there's text. - if (input.len > 0) { - if (dvui.buttonIcon(@src(), "comic-search-clear", icons.tvg.lucide.x, .{}, .{}, .{ - .id_extra = 9100, - .color_fill = theme.colors.bg_elevated, - .color_text = theme.colors.text_secondary, - .corner_radius = theme.dims.rad_sm, - .padding = .{ .x = 5, .y = 3, .w = 5, .h = 3 }, - .margin = .{ .x = 3, .y = 0, .w = 0, .h = 0 }, - .gravity_y = 0.5, - })) { - state.app.comic.search_buf[0] = 0; - last_fired_len = 0; + // Clear button (×) — visible only when there's text. + if (input.len > 0) { + if (dvui.buttonIcon(@src(), "comic-search-clear", icons.tvg.lucide.x, .{}, .{}, .{ + .id_extra = 9100, + .color_fill = theme.colors.bg_elevated, + .color_text = theme.colors.text_secondary, + .corner_radius = theme.dims.rad_sm, + .padding = .{ .x = 5, .y = 3, .w = 5, .h = 3 }, + .margin = .{ .x = 3, .y = 0, .w = 0, .h = 0 }, + .gravity_y = 0.5, + })) { + state.app.comic.search_buf[0] = 0; + last_fired_len = 0; + } } - } - const clicked = components.toolbarGo(@src(), "Search"); + const clicked = components.toolbarGo(@src(), "Search"); - // Explicit submit (Enter / Search) — URLs load directly, text searches. - if (clicked or enter_pressed) { - if (input.len > 0) { - if (is_url) loadComic(input) else searchComics(input); - } - } else { - // Live / incremental debounced search: buffer differs from the - // last fired query, ≥2 chars, not a URL, 400ms since last edit. - const now_ms = @import("../core/io_global.zig").milliTimestamp(); - const changed = !(input.len == last_fired_len and std.mem.eql(u8, input, last_fired_query[0..last_fired_len])); - if (changed) last_edit_ms = now_ms; - if (changed and input.len >= 2 and !is_url and !sr_searching_v.load(.acquire) and - (now_ms - last_edit_ms) >= 400) - { - searchComics(input); + // Explicit submit (Enter / Search) — URLs load directly, text searches. + if (clicked or enter_pressed) { + if (input.len > 0) { + if (is_url) loadComic(input) else searchComics(input); + } + } else { + // Live / incremental debounced search: buffer differs from the + // last fired query, ≥2 chars, not a URL, 400ms since last edit. + const now_ms = @import("../core/io_global.zig").milliTimestamp(); + const changed = !(input.len == last_fired_len and std.mem.eql(u8, input, last_fired_query[0..last_fired_len])); + if (changed) last_edit_ms = now_ms; + if (changed and input.len >= 2 and !is_url and !sr_searching_v.load(.acquire) and + (now_ms - last_edit_ms) >= 400) + { + searchComics(input); + } } - } - _ = dvui.label(@src(), " • ", .{}, .{ - .color_text = theme.colors.border_subtle, - .gravity_y = 0.5, - }); + _ = dvui.label(@src(), " • ", .{}, .{ + .color_text = theme.colors.border_subtle, + .gravity_y = 0.5, + }); + } _ = dvui.label(@src(), "Source:", .{}, .{ .color_text = theme.colors.text_secondary, diff --git a/src/services/external_player.zig b/src/services/external_player.zig deleted file mode 100644 index 9846e1a3..00000000 --- a/src/services/external_player.zig +++ /dev/null @@ -1,154 +0,0 @@ -//! Optional external VLC playback. Never interpolate media paths/URLs into a -//! shell command or a log (signed stream URLs can contain credentials). -const std = @import("std"); -const builtin = @import("builtin"); -const io = @import("../core/io_global.zig"); -const logs = @import("../core/logs.zig"); -const state = @import("../core/state.zig"); -const workers = @import("../core/workers.zig"); - -pub const Status = enum(u8) { idle, launching, opened, failed }; -var status_value = std.atomic.Value(Status).init(.idle); -var busy = std.atomic.Value(bool).init(false); -var generation = std.atomic.Value(u64).init(0); - -pub fn status() Status { - return status_value.load(.acquire); -} - -const Request = struct { - url: [8192]u8 = undefined, - len: usize = 0, - generation: u64 = 0, -}; -/// Only direct media addresses and ordinary file paths can be handed to VLC. -/// Opal-specific identities (opal://, magnet:, etc.) require an internal -/// resolver; passing them to VLC would look like a successful open but fail. -pub fn playable(url: []const u8) bool { - if (url.len == 0 or url.len > 8192 or std.mem.indexOfScalar(u8, url, 0) != null) return false; - const schemes = [_][]const u8{ "http://", "https://", "rtsp://", "rtmp://", "file://", "mms://" }; - for (schemes) |scheme| if (std.ascii.startsWithIgnoreCase(url, scheme)) return true; - if (std.mem.indexOfScalar(u8, url, ':')) |colon| { - // Drive-letter paths are ordinary paths, not URI schemes. - if (!(builtin.os.tag == .windows and colon == 1 and std.ascii.isAlphabetic(url[0]))) return false; - } - return std.mem.indexOf(u8, url, "://") == null; -} - - -/// Queue playback in VLC without replacing or interrupting Opal's player. -/// Returns false if the request cannot be admitted. A later OS launch error is -/// reported by status() and in Logs, without printing the media URL. -pub fn launch(url: []const u8) bool { - if (!playable(url)) return false; - if (busy.swap(true, .acq_rel)) return false; - var request: Request = .{}; - @memcpy(request.url[0..url.len], url); - request.len = url.len; - request.generation = generation.fetchAdd(1, .acq_rel) + 1; - status_value.store(.launching, .release); - workers.spawn(run, .{request}) catch { - busy.store(false, .release); - status_value.store(.failed, .release); - logs.pushLog("error", "vlc", "Could not start external VLC launch worker", true); - return false; - }; - return true; -} - -fn available(path: []const u8) bool { - io.cwdAccess(path, .{}) catch return false; - return true; -} - -fn candidate(buf: []u8, base: []const u8, suffix: []const u8) ?[]const u8 { - const path = std.fmt.bufPrint(buf, "{s}/{s}", .{ base, suffix }) catch return null; - return if (available(path)) path else null; -} - -fn vlcBinary(buf: []u8) []const u8 { - if (builtin.os.tag == .windows) { - // GUI launches rarely inherit the terminal's PATH. Check the normal - // installers first; portable/Scoop installs still resolve via PATH. - const roots = [_][*:0]const u8{ "ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA" }; - for (roots) |key| { - if (std.c.getenv(key)) |raw| { - if (candidate(buf, std.mem.span(raw), "VideoLAN/VLC/vlc.exe")) |path| return path; - } - } - return "vlc.exe"; - } - if (builtin.os.tag == .macos) { - if (available("/Applications/VLC.app/Contents/MacOS/VLC")) return "/Applications/VLC.app/Contents/MacOS/VLC"; - if (candidate(buf, @import("../core/paths.zig").homeDir(), "Applications/VLC.app/Contents/MacOS/VLC")) |path| return path; - return "vlc"; - } - for ([_][]const u8{ "/usr/bin/vlc", "/usr/local/bin/vlc", "/snap/bin/vlc" }) |path| { - if (available(path)) return path; - } - if (std.c.getenv("PATH")) |raw| { - var dirs = std.mem.tokenizeScalar(u8, std.mem.span(raw), ':'); - while (dirs.next()) |dir| { - if (candidate(buf, dir, "vlc")) |path| return path; - } - } - return ""; -} - -fn run(request: Request) void { - var binary_buf: [1024]u8 = undefined; - const binary = vlcBinary(&binary_buf); - if (binary.len == 0) { - status_value.store(.failed, .release); - busy.store(false, .release); - logs.pushLog("error", "vlc", "VLC was not found. Install VLC or add it to PATH; see Settings > Playback.", true); - state.wakeUi(); - return; - } - // Linux setsid -f forks the player away from our worker, so closing Opal - // does not wait for a long-running external playback session. macOS open - // hands the request to LaunchServices. On Windows we spawn VLC directly - // (std.process handles Windows argv quoting) and close its OS handles below. - const linux_detach = builtin.os.tag == .linux and available("/usr/bin/setsid"); - const argv: []const []const u8 = if (linux_detach) - &.{ "/usr/bin/setsid", "-f", binary, "--one-instance", "--", request.url[0..request.len] } - else if (builtin.os.tag == .macos) - &.{ "open", "-a", "VLC", "--", request.url[0..request.len] } - else - &.{ binary, "--one-instance", "--", request.url[0..request.len] }; - var child = io.Child.init(argv, std.heap.c_allocator); - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - child.spawn() catch { - status_value.store(.failed, .release); - busy.store(false, .release); - logs.pushLog("error", "vlc", "VLC could not be started. Install VLC or add vlc to PATH; see Settings > Playback.", true); - state.wakeUi(); - return; - }; - status_value.store(.opened, .release); - busy.store(false, .release); - logs.pushLog("info", "vlc", "Launched external VLC (media URL and any credentials hidden); playback errors are handled in VLC", false); - state.wakeUi(); - if (builtin.os.tag == .windows) { - // Windows has no zombies: unlike POSIX, closing both parent handles - // leaves VLC running independently and needs no waiting worker. - const process = child.real.?; - std.os.windows.CloseHandle(process.id.?); - std.os.windows.CloseHandle(process.thread_handle); - return; - } - // Reap only the short-lived macOS/open or Linux/setsid launcher. If setsid - // is unavailable on Linux, the worker must wait for VLC to avoid a zombie. - const result = child.wait() catch return; - const ok = switch (result) { - .exited => |code| code == 0, - else => false, - }; - if (!ok and generation.load(.acquire) == request.generation) { - status_value.store(.failed, .release); - logs.pushLog("error", "vlc", "VLC exited with an error; check the media URL and VLC installation", true); - state.wakeUi(); - } -} diff --git a/src/services/iptv.zig b/src/services/iptv.zig index 4f9c8e8f..d96d833d 100644 --- a/src/services/iptv.zig +++ b/src/services/iptv.zig @@ -943,6 +943,8 @@ fn codeIndex(codes: []const []const u8, current: []const u8) usize { // [Working][Test]. The search entry expands to eat the slack, so it stays roomy // while the controls pack to the right — one line instead of three. fn renderToolbar() void { + const local_search = @import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled); + if (!local_search and iptvBase() == null) return; var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .padding = .{ .x = 8, .y = 6, .w = 8, .h = 6 }, @@ -961,43 +963,45 @@ fn renderToolbar() void { .margin = .{ .x = 0, .y = 0, .w = 6, .h = 0 }, }); - var te = dvui.textEntry(@src(), .{ - .text = .{ .buffer = &state.app.iptv.search_buf }, - .placeholder = "Search channels...", - }, .{ - .expand = .horizontal, - .padding = .{ .x = 6, .y = 4, .w = 6, .h = 4 }, - .color_fill = theme.colors.bg_elevated, - .color_text = theme.colors.text_primary, - .corner_radius = theme.dims.rad_sm, - .gravity_y = 0.5, - }); - const entered = te.enter_pressed; - te.deinit(); - - // Clear (×) — only when there's text. Zeroing the buffer lets pollLiveSearch - // restore the full directory on the next frame, so "remove the filter" is a - // dynamic update, not a stuck search. - if (std.mem.sliceTo(&state.app.iptv.search_buf, 0).len > 0) { - if (dvui.buttonIcon(@src(), "iptvclear", icons.tvg.lucide.x, .{}, .{}, .{ - .color_text = theme.colors.text_secondary, - .color_fill = theme.transparent, - .color_fill_hover = theme.colors.bg_hover, - .border = dvui.Rect.all(0), - .min_size_content = theme.iconSize(.sm), - .margin = .{ .x = 4, .y = 0, .w = 4, .h = 0 }, + if (local_search) { + var te = dvui.textEntry(@src(), .{ + .text = .{ .buffer = &state.app.iptv.search_buf }, + .placeholder = "Search channels...", + }, .{ + .expand = .horizontal, + .padding = .{ .x = 6, .y = 4, .w = 6, .h = 4 }, + .color_fill = theme.colors.bg_elevated, + .color_text = theme.colors.text_primary, + .corner_radius = theme.dims.rad_sm, .gravity_y = 0.5, - })) { - @memset(&state.app.iptv.search_buf, 0); + }); + const entered = te.enter_pressed; + te.deinit(); + + // Clear (×) — only when there's text. Zeroing the buffer lets pollLiveSearch + // restore the full directory on the next frame, so "remove the filter" is a + // dynamic update, not a stuck search. + if (std.mem.sliceTo(&state.app.iptv.search_buf, 0).len > 0) { + if (dvui.buttonIcon(@src(), "iptvclear", icons.tvg.lucide.x, .{}, .{}, .{ + .color_text = theme.colors.text_secondary, + .color_fill = theme.transparent, + .color_fill_hover = theme.colors.bg_hover, + .border = dvui.Rect.all(0), + .min_size_content = theme.iconSize(.sm), + .margin = .{ .x = 4, .y = 0, .w = 4, .h = 0 }, + .gravity_y = 0.5, + })) { + @memset(&state.app.iptv.search_buf, 0); + } } - } - // Live incremental search: results track the text as it's typed or cleared. - // Enter still submits explicitly (a no-op if the live poll already applied it). - pollLiveSearch(); - if (entered) { - const q = std.mem.sliceTo(&state.app.iptv.search_buf, 0); - if (q.len > 0) searchIptv(q); + // Live incremental search: results track the text as it's typed or cleared. + // Enter still submits explicitly (a no-op if the live poll already applied it). + pollLiveSearch(); + if (entered) { + const q = std.mem.sliceTo(&state.app.iptv.search_buf, 0); + if (q.len > 0) searchIptv(q); + } } // Controls only when the plugin is installed (an inert tab shows just search). diff --git a/src/services/library_pure.zig b/src/services/library_pure.zig index 23cb528b..ddcb814d 100644 --- a/src/services/library_pure.zig +++ b/src/services/library_pure.zig @@ -15,6 +15,7 @@ pub const ContentKind = enum { iptv, comics, novels, + search, other, pub fn id(self: ContentKind) []const u8 { @@ -93,3 +94,7 @@ test "content kind keeps persisted adapters typed" { try std.testing.expectEqual(ContentKind.other, parseKind("future-adapter")); try std.testing.expectEqualStrings("audiobook", ContentKind.audiobook.id()); } + +test "saved search works reopen through search instead of a media URL" { + try std.testing.expectEqual(ContentKind.search, parseKind("search")); +} diff --git a/src/services/music_subsonic.zig b/src/services/music_subsonic.zig index 2eb2eefd..aa154d87 100644 --- a/src/services/music_subsonic.zig +++ b/src/services/music_subsonic.zig @@ -101,6 +101,168 @@ pub const SRC_JELLYFIN: u8 = 2; pub const SRC_PLEX: u8 = 3; pub const SRC_AUDIUS: u8 = 4; +pub const UniversalStatus = @import("resolver_lifecycle_pure.zig").SourceStatus; +pub const UniversalReply = struct { count: usize = 0, status: UniversalStatus = .unavailable, connection: u64 = 0 }; +const UniversalConnection = struct { + base: [512]u8 = @splat(0), + base_len: usize = 0, + auth: [512]u8 = @splat(0), + auth_len: usize = 0, + identity: u64 = 0, +}; + +pub fn universalProviderName(source: u8) []const u8 { + return switch (source) { + SRC_JIOSAAVN => "JioSaavn", + SRC_SUBSONIC => "Subsonic", + SRC_JELLYFIN => "Jellyfin music", + SRC_PLEX => "Plex music", + SRC_AUDIUS => "Audius", + else => "Music", + }; +} + +/// Writer-shared snapshots only. Never read mutable Browse/settings arrays. +fn universalConnection(source: u8) ?UniversalConnection { + var out: UniversalConnection = .{}; + switch (source) { + SRC_JIOSAAVN => return out, + SRC_AUDIUS => { + const base = source_config.copyValue("audius", "base", &out.base) orelse return null; + if (!pure.isValidBase(base)) return null; + out.base_len = base.len; + }, + SRC_SUBSONIC => { + var fields: [16]source_config.EntryFieldSnapshot = undefined; + const count = source_config.copyEntry("subsonic", &fields) orelse return null; + var base: []const u8 = ""; + var user: []const u8 = ""; + var pass: []const u8 = ""; + defer for (fields[0..count]) |*field| @memset(&field.val, 0); + for (fields[0..count]) |*field| { + if (field.value("base")) |v| base = v; + if (field.value("user")) |v| user = v; + if (field.value("pass")) |v| pass = v; + } + if (!pure.isValidBase(base) or user.len == 0 or pass.len == 0) return null; + copyField(&out.base, &out.base_len, base); + // Owned, request-local salt; no shared lazy-initialized salt race. + var salt: [24]u8 = undefined; + const salt_text = std.fmt.bufPrint(&salt, "{x}", .{@as(u64, @bitCast(io.milliTimestamp()))}) catch return null; + var token: [32]u8 = undefined; + pure.authToken(pass, salt_text, &token); + const auth = pure.buildAuthQuery(user, &token, salt_text, &out.auth); + if (auth.len == 0) return null; + out.auth_len = auth.len; + out.identity = pure.connectionIdentity(base, user); + }, + SRC_JELLYFIN => { + var snap = @import("jellyfin.zig").connectionSnapshot(); + defer @memset(&snap.token, 0); + if (!snap.connected or snap.server_len == 0 or snap.token_len == 0) return null; + copyField(&out.base, &out.base_len, snap.server[0..snap.server_len]); + copyField(&out.auth, &out.auth_len, snap.token[0..snap.token_len]); + out.identity = pure.connectionIdentity(out.base[0..out.base_len], snap.user_id[0..snap.user_id_len]); + }, + SRC_PLEX => { + var snap = @import("plex.zig").connectionSnapshot(); + defer { + @memset(&snap.account_token, 0); + @memset(&snap.server_token, 0); + } + const token = if (snap.server_token_len > 0) snap.server_token[0..snap.server_token_len] else snap.account_token[0..snap.account_token_len]; + if (snap.server_len == 0 or token.len == 0) return null; + copyField(&out.base, &out.base_len, snap.server[0..snap.server_len]); + copyField(&out.auth, &out.auth_len, token); + }, + else => return null, + } + if (source != SRC_JIOSAAVN and !pure.isValidBase(out.base[0..out.base_len])) return null; + if (out.identity == 0) out.identity = pure.connectionIdentity(out.base[0..out.base_len], ""); + return out; +} + +pub fn universalConfigured(source: u8) bool { + var connection = universalConnection(source) orelse return false; + defer @memset(&connection.auth, 0); + return true; +} + +/// Independent first-page query. Output owns all metadata and safe action URLs; +/// Browse source, rows, paging, and request generation are never changed. +pub fn searchInto(source: u8, query: []const u8, out: []pure.MusicSong) UniversalReply { + if (query.len == 0 or out.len == 0) return .{ .status = .no_results }; + var connection = universalConnection(source) orelse return .{}; + defer @memset(&connection.auth, 0); + const limit = @min(out.len, 16); + var url_buf: [2048]u8 = undefined; + const base = connection.base[0..connection.base_len]; + const auth = connection.auth[0..connection.auth_len]; + const url = switch (source) { + SRC_JIOSAAVN => js_pure.buildSearchUrl(&url_buf, query, @intCast(limit)), + SRC_AUDIUS => au_pure.searchUrl(&url_buf, base, query, limit, 0), + SRC_SUBSONIC => pure.buildSearchUrl(&url_buf, base, auth, query, @intCast(limit)), + SRC_JELLYFIN => jf_pure.buildSearchUrl(&url_buf, base, auth, query, @intCast(limit)), + SRC_PLEX => px_pure.buildSearchUrl(&url_buf, base, auth, query, @intCast(limit)), + else => null, + } orelse return .{ .status = .failed }; + const buffer = alloc.alloc(u8, 512 * 1024) catch return .{ .status = .failed }; + defer alloc.free(buffer); + const headers = [_]std.http.Header{.{ .name = "Accept", .value = "application/json" }}; + const body = @import("../core/http.zig").fetch(url, buffer, .{ .timeout_secs = 4, .extra_headers = &headers }) orelse return .{ .status = .transport_failed }; + var parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch return .{ .status = .parse_failed }; + defer parsed.deinit(); + const response_kind: pure.SearchResponseKind = switch (source) { + SRC_JIOSAAVN => .jiosaavn, + SRC_SUBSONIC => .subsonic, + SRC_JELLYFIN => .jellyfin, + SRC_PLEX => .plex, + SRC_AUDIUS => .audius, + else => return .{ .status = .failed }, + }; + const rows = pure.pageRows(parsed.value, response_kind) orelse return .{ .status = .parse_failed }; + var count: usize = 0; + for (rows) |value| { + if (count >= limit) break; + var row = switch (source) { + SRC_JIOSAAVN => js_pure.parseSongValue(value), + SRC_SUBSONIC => pure.parseSongValue(value), + SRC_JELLYFIN => jf_pure.parseSongValue(value), + SRC_PLEX => px_pure.parseSongValue(value), + SRC_AUDIUS => au_pure.parseTrack(value, base), + else => null, + } orelse continue; + if (source == SRC_SUBSONIC or source == SRC_JELLYFIN or source == SRC_PLEX) { + // Private artwork remains private; public search cannot expose keys. + row.cover_len = 0; + } + if ((source == SRC_JIOSAAVN or source == SRC_AUDIUS) and row.play_url_len == 0) continue; + out[count] = row; + count += 1; + } + return .{ .count = count, .status = if (count > 0) .done else .no_results, .connection = connection.identity }; +} + +/// Resolve private action against fresh credentials and searched connection. +pub fn playUniversalRoute(route_url: []const u8, title: []const u8, artist: []const u8) bool { + const route = pure.parseUniversalRoute(route_url) orelse return false; + var connection = universalConnection(route.source) orelse return false; + defer @memset(&connection.auth, 0); + if (connection.identity != route.connection) return false; + const base = connection.base[0..connection.base_len]; + const auth = connection.auth[0..connection.auth_len]; + const id = route.id[0..route.id_len]; + var url_buf: [2048]u8 = undefined; + const stream = switch (route.source) { + SRC_SUBSONIC => pure.buildStreamUrl(&url_buf, base, auth, id), + SRC_JELLYFIN => jf_pure.buildStreamUrl(&url_buf, base, auth, id, "opal"), + SRC_PLEX => px_pure.buildStreamUrl(&url_buf, base, auth, route.part[0..route.part_len]), + else => null, + } orelse return false; + @import("browser.zig").playDirect(.{ .url = stream, .history_identity = route_url, .restore_target = route_url, .title = title, .subtitle = artist }); + return true; +} + pub fn selectSource(src: u8) bool { if (src > SRC_AUDIUS) return false; search_request.cancel(&state.app.music.is_loading); @@ -1042,6 +1204,7 @@ pub fn renderLyricsPanel(placement: LyricsPanelPlacement) void { const pure_lyric_text_cap = 256; fn renderSearchBar() void { + if (!@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) return; var row = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .expand = .horizontal, .padding = .{ .x = 10, .y = 7, .w = 10, .h = 7 }, diff --git a/src/services/music_subsonic_pure.zig b/src/services/music_subsonic_pure.zig index 2eeb33fd..6a45d7b1 100644 --- a/src/services/music_subsonic_pure.zig +++ b/src/services/music_subsonic_pure.zig @@ -201,6 +201,103 @@ pub fn songIdFromRoute(url: []const u8) ?[]const u8 { return id; } +/// Credential-free universal action bound to the searched server identity. +pub const UniversalRoute = struct { + source: u8, + connection: u64, + id: [128]u8 = @splat(0), + id_len: usize = 0, + part: [256]u8 = @splat(0), + part_len: usize = 0, +}; +pub fn providerQuota(total: usize, providers: usize, index: usize) usize { + if (providers == 0 or index >= providers) return 0; + return total / providers + @as(usize, if (index < total % providers) 1 else 0); +} +pub fn connectionIdentity(base: []const u8, namespace: []const u8) u64 { + var hash = std.hash.Wyhash.init(0x6d75736963); + for ([_][]const u8{ std.mem.trimEnd(u8, base, "/"), namespace }) |part| { + const len: u64 = @intCast(part.len); + hash.update(std.mem.asBytes(&len)); + hash.update(part); + } + return hash.final(); +} +pub fn universalRoute(out: []u8, source: u8, connection: u64, id: []const u8, part: []const u8) ?[]const u8 { + if (source < 1 or source > 3 or id.len == 0 or id.len > 128 or part.len > 256) return null; + var a: [384]u8 = undefined; + var b: [768]u8 = undefined; + const an = percentEncode(id, &a); + const bn = percentEncode(part, &b); + return std.fmt.bufPrint(out, "opal://music/{d}/{x}/{s}/{s}", .{ source, connection, a[0..an], b[0..bn] }) catch null; +} +fn decodeRoutePart(text: []const u8, out: []u8) ?usize { + var i: usize = 0; + var n: usize = 0; + while (i < text.len) { + if (n >= out.len) return null; + const ch = if (text[i] == '%') blk: { + if (i + 2 >= text.len) return null; + const value = std.fmt.parseInt(u8, text[i + 1 .. i + 3], 16) catch return null; + i += 3; + break :blk value; + } else blk: { + const value = text[i]; + i += 1; + break :blk value; + }; + if (ch < 32 or ch == 127) return null; + out[n] = ch; + n += 1; + } + if (!std.unicode.utf8ValidateSlice(out[0..n])) return null; + return n; +} +pub fn parseUniversalRoute(url: []const u8) ?UniversalRoute { + const prefix = "opal://music/"; + if (!std.mem.startsWith(u8, url, prefix)) return null; + var parts = std.mem.splitScalar(u8, url[prefix.len..], '/'); + const source = std.fmt.parseInt(u8, parts.next() orelse return null, 10) catch return null; + if (source < 1 or source > 3) return null; + const connection = std.fmt.parseInt(u64, parts.next() orelse return null, 16) catch return null; + var route = UniversalRoute{ .source = source, .connection = connection }; + route.id_len = decodeRoutePart(parts.next() orelse return null, &route.id) orelse return null; + route.part_len = decodeRoutePart(parts.next() orelse return null, &route.part) orelse return null; + if (route.id_len == 0 or parts.next() != null) return null; + return route; +} + +test "universal music identity roundtrip does not carry credentials or confuse provider" { + var out: [1600]u8 = undefined; + const url = universalRoute(&out, 3, 0x1234, "track a/b", "/library/parts/42/file.mp3").?; + const row = parseUniversalRoute(url).?; + try std.testing.expectEqual(@as(u8, 3), row.source); + try std.testing.expectEqual(@as(u64, 0x1234), row.connection); + try std.testing.expectEqualStrings("track a/b", row.id[0..row.id_len]); + try std.testing.expectEqualStrings("/library/parts/42/file.mp3", row.part[0..row.part_len]); + try std.testing.expect(parseUniversalRoute("opal://music/3/1234/%00/") == null); + try std.testing.expect(parseUniversalRoute("opal://music/4/1234/id/") == null); + try std.testing.expect(parseUniversalRoute("opal://music/1/1234/id//extra") == null); +} + +test "universal music quota stays bounded and fair for all five providers" { + for (1..6) |providers| { + var total: usize = 0; + for (0..providers) |index| total += providerQuota(16, providers, index); + try std.testing.expectEqual(@as(usize, 16), total); + try std.testing.expect(providerQuota(16, providers, 0) - providerQuota(16, providers, providers - 1) <= 1); + } + try std.testing.expectEqual(@as(usize, 0), providerQuota(16, 0, 0)); +} + +test "universal music connection binding rejects another server or library namespace" { + const a = connectionIdentity("https://music.local/", "user"); + try std.testing.expectEqual(a, connectionIdentity("https://music.local", "user")); + try std.testing.expect(a != connectionIdentity("https://other.local", "user")); + try std.testing.expect(a != connectionIdentity("https://music.local", "other")); + try std.testing.expect(connectionIdentity("https://music.local/a", "b") != connectionIdentity("https://music.local/", "ab")); +} + // ── JSON extraction ── /// Read a JSON string field `"key":"…"` from `scope` into `dst` (bytes written, diff --git a/src/services/novels.zig b/src/services/novels.zig index 85eee2d4..13de983c 100644 --- a/src/services/novels.zig +++ b/src/services/novels.zig @@ -90,6 +90,36 @@ var nr_source: [MAX_RESULTS]NovelSource = undefined; var nr_count: usize = 0; var nr_metadata: [MAX_RESULTS]NovelMetadata = [_]NovelMetadata{.{}} ** MAX_RESULTS; var novel_covers: [MAX_RESULTS]components.CoverSlot = [_]components.CoverSlot{.{}} ** MAX_RESULTS; +var native_card_rects: [MAX_RESULTS]dvui.Rect.Physical = undefined; +pub fn nativeCardRectsForTest() []const dvui.Rect.Physical { + if (!@import("builtin").is_test) @compileError("Native fixture is test-only"); + return native_card_rects[0..nr_count]; +} + +pub fn setNativeFixtureForTest(textures: []const dvui.Texture) void { + if (!@import("builtin").is_test) @compileError("Native fixture is test-only"); + for (&novel_covers) |*cover| cover.reset(); + nr_count = @min(textures.len, MAX_RESULTS); + state.app.novels.view = .search; + state.app.novels.is_loading.store(false, .release); + state.app.novels.fetch_error = false; + more_available.store(false, .release); + const cover_url = "https://example.invalid/offline-cover.jpg"; + const author = "Offline fixture author"; + for (textures[0..nr_count], 0..) |art, i| { + addResult(i, .wikisource, "A long literary title — Journeys through Extraordinary Worlds", ""); + nr_metadata[i] = .{}; + @memcpy(nr_metadata[i].author_buf[0..author.len], author); + nr_metadata[i].author_len = author.len; + @memcpy(nr_metadata[i].cover_buf[0..cover_url.len], cover_url); + nr_metadata[i].cover_len = cover_url.len; + components.syncCoverSlot(&novel_covers[i], cover_url); + novel_covers[i].tex = art; + novel_covers[i].w = art.width; + novel_covers[i].h = art.height; + novel_covers[i].attempted = true; + } +} const MAX_CHAPTERS: usize = 400; // For Wikisource: the full page title ("Frankenstein/Chapter 1") — the fetch key @@ -1773,7 +1803,9 @@ fn renderSearchView() void { }); const layout_w = @import("../core/scale_pure.zig").layoutUnits(dvui.windowRect().w, state.app.ui_scale); - const cols: usize = if (layout_w >= 1060) 4 else if (layout_w >= 760) 3 else if (layout_w >= 500) 2 else 1; + const viewport_width = if (scroll.data().rect.w > 1) scroll.data().rect.w else layout_w; + const grid = @import("../ui/browse_layout_pure.zig").readingGrid(viewport_width); + const cols = grid.columns; const rows = (count + cols - 1) / cols; const row_h: f32 = 126; const win = tmdb_pure.visibleRows(rows, row_h, scroll.si.viewport.y, scroll.si.viewport.h, 2); @@ -1800,7 +1832,7 @@ fn renderSearchView() void { const i = row_idx * cols + col; if (i >= count) break; const item = resultRow(i) orelse continue; - renderNovelCard(item, i); + renderNovelCard(item, i, grid.card_width); } } @@ -1837,6 +1869,7 @@ fn renderSearchView() void { } fn renderSearchBar() void { + if (!@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) return; var row = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .expand = .horizontal, .padding = .{ .x = 10, .y = 7, .w = 10, .h = 7 }, @@ -1892,7 +1925,7 @@ fn novelSourceIcon(source: NovelSource) []const u8 { }; } -fn renderNovelCard(item: ListRow, index: usize) void { +fn renderNovelCard(item: ListRow, index: usize, card_width: f32) void { var name_buf: [256]u8 = undefined; const name = safeUtf8Buf(item.title(), &name_buf); const source: NovelSource = @enumFromInt(item.source); @@ -1900,9 +1933,8 @@ fn renderNovelCard(item: ListRow, index: usize) void { var bw: dvui.ButtonWidget = undefined; bw.init(@src(), .{}, .{ .id_extra = 52000 + index, - .expand = .horizontal, - .min_size_content = .{ .w = 120, .h = 112 }, - .max_size_content = .{ .w = std.math.floatMax(f32), .h = 112 }, + .min_size_content = .{ .w = card_width, .h = 112 }, + .max_size_content = .{ .w = card_width, .h = 112 }, .background = true, .color_fill = theme.colors.bg_elevated, .color_fill_hover = theme.colors.bg_hover, @@ -1912,11 +1944,13 @@ fn renderNovelCard(item: ListRow, index: usize) void { }); bw.processEvents(); bw.drawBackground(); + if (@import("builtin").is_test) native_card_rects[index] = bw.data().borderRectScale().r; { var body = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = 53000 + index, .expand = .both, + .max_size_content = .{ .w = 0, .h = 112 }, .padding = .{ .x = 0, .y = 0, .w = 10, .h = 0 }, }); defer body.deinit(); @@ -1938,6 +1972,7 @@ fn renderNovelCard(item: ListRow, index: usize) void { var text = dvui.box(@src(), .{ .dir = .vertical }, .{ .id_extra = 55000 + index, .expand = .both, + .max_size_content = .{ .w = 0, .h = 94 }, .padding = .{ .x = 10, .y = 10, .w = 4, .h = 8 }, }); defer text.deinit(); @@ -1945,7 +1980,7 @@ fn renderNovelCard(item: ListRow, index: usize) void { .id_extra = 56000 + index, .expand = .horizontal, .color_text = theme.colors.text_primary, - .font = dvui.themeGet().font_heading, + .font = theme.mediaTitleFont(name, dvui.themeGet().font_body), }); if (item.metadata.author_len > 0 or item.metadata.overview_len > 0) { var subtitle_buf: [256]u8 = undefined; diff --git a/src/services/podcasts_pure.zig b/src/services/podcasts_pure.zig index 07d17dc3..2dcebedc 100644 --- a/src/services/podcasts_pure.zig +++ b/src/services/podcasts_pure.zig @@ -677,9 +677,15 @@ test "podcast deep link: an episode title containing '|' survives" { pub fn parseGpodder(a: std.mem.Allocator, json: []const u8, out: []Podcast) usize { const doc = std.json.parseFromSlice(std.json.Value, a, json, .{}) catch return 0; defer doc.deinit(); - if (doc.value != .array) return 0; + return parseGpodderValue(doc.value, out) orelse 0; +} + +/// Independent directory adapter distinguishes a valid empty page from an +/// error/malformed top-level response, matching the Apple parser contract. +pub fn parseGpodderValue(root: std.json.Value, out: []Podcast) ?usize { + if (root != .array) return null; var n: usize = 0; - for (doc.value.array.items) |row| { + for (root.array.items) |row| { if (n == out.len) break; if (row != .object) continue; const title = jsonValueString(row.object.get("title")); @@ -695,6 +701,16 @@ pub fn parseGpodder(a: std.mem.Allocator, json: []const u8, out: []Podcast) usiz return n; } +test "gpodder universal search distinguishes empty directory from error JSON" { + var rows: [2]Podcast = undefined; + const empty = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "[]", .{}); + defer empty.deinit(); + try std.testing.expectEqual(@as(?usize, 0), parseGpodderValue(empty.value, &rows)); + const failure = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"error\":\"unavailable\"}", .{}); + defer failure.deinit(); + try std.testing.expect(parseGpodderValue(failure.value, &rows) == null); +} + fn jsonValueString(value: ?std.json.Value) []const u8 { const v = value orelse return ""; return if (v == .string) v.string else ""; diff --git a/src/services/queue.zig b/src/services/queue.zig index 88c04185..491b9a88 100644 --- a/src/services/queue.zig +++ b/src/services/queue.zig @@ -464,8 +464,20 @@ pub fn playNextUnplayed(player: anytype) void { // UI Rendering (called from drawer.zig) // ══════════════════════════════════════════════════════════ +var fixture_for_test = false; +pub var test_row_rects: [8]dvui.Rect = @splat(.{}); +pub fn setRenderFixtureForTest(items: ?[]const QueueItem) void { + if (!@import("builtin").is_test) @compileError("Queue fixture is test-only"); + fixture_for_test = items != null; + queue_count = if (items) |data| @min(data.len, MAX_QUEUE) else 0; + if (items) |data| @memcpy(queue_items[0..queue_count], data[0..queue_count]); + thumb_backfill_done.store(true, .release); + queue_scroll = .{}; + queue_row_heights = @splat(0); +} + pub fn renderContent() void { - if (!isReady()) { + if (!isReady() and !(@import("builtin").is_test and fixture_for_test)) { _ = dvui.label(@src(), "Loading queue…", .{}, .{ .color_text = theme.colors.text_secondary, .gravity_x = 0.5, @@ -516,7 +528,7 @@ pub fn renderContent() void { if (dvui.button(@src(), if (thumb_backfill_active.load(.acquire)) "Stop Fetch" else "Fetch Thumbs", .{}, .{ .color_fill = if (thumb_backfill_active.load(.acquire)) dvui.Color{ .r = 80, .g = 30, .b = 30, .a = 200 } else theme.colors.accent, - .color_text = if (thumb_backfill_active.load(.acquire)) theme.colors.danger else dvui.Color.white, + .color_text = if (thumb_backfill_active.load(.acquire)) theme.colors.danger else theme.colors.text_on_accent, .corner_radius = theme.dims.rad_sm, .padding = .{ .x = 10, .y = 4, .w = 10, .h = 4 }, .margin = .{ .x = 0, .y = 0, .w = 4, .h = 0 }, @@ -785,6 +797,7 @@ fn renderQueueCard(item: *QueueItem, idx: usize) void { .padding = .{ .x = 6, .y = 6, .w = 6, .h = 6 }, }); defer { + if (@import("builtin").is_test and fixture_for_test and idx < test_row_rects.len) test_row_rects[idx] = card.data().rect; const height = card.data().rect.h; if (height > 0) queue_row_heights[idx] = height; card.deinit(); @@ -881,7 +894,7 @@ fn renderQueueCard(item: *QueueItem, idx: usize) void { _ = dvui.label(@src(), "{s}", .{source}, .{ .id_extra = idx + 720, - .color_text = theme.colors.border_subtle, + .color_text = theme.colors.text_tertiary, .font = dvui.themeGet().font_body.withSize(theme.font_size.small), .max_size_content = .{ .w = title_w, .h = std.math.floatMax(f32) }, }); diff --git a/src/services/radio.zig b/src/services/radio.zig index 0080196a..66b2c8c7 100644 --- a/src/services/radio.zig +++ b/src/services/radio.zig @@ -726,6 +726,7 @@ pub fn renderContent() void { } fn renderSearchBar() void { + if (!@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) return; var row = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .expand = .horizontal, .padding = .{ .x = 10, .y = 7, .w = 10, .h = 7 }, diff --git a/src/services/remote.zig b/src/services/remote.zig index f8732110..1a16794a 100644 --- a/src/services/remote.zig +++ b/src/services/remote.zig @@ -1527,6 +1527,16 @@ fn handleApi(stream: std.Io.net.Stream, api_path: []const u8, query: []const u8, apiUnifiedResolverSearch(stream, query); return; } + if (std.mem.eql(u8, api_path, "/unified_search/preview")) { + if (!requireMethod(stream, method, "GET")) return; + apiUnifiedSearchPreview(stream, query); + return; + } + if (std.mem.eql(u8, api_path, "/unified_search/cancel")) { + if (!requireMethod(stream, method, "POST")) return; + apiUnifiedSearchCancel(stream, query); + return; + } if (std.mem.eql(u8, api_path, "/unified_search/play")) { if (!requireMethod(stream, method, "POST")) return; apiUnifiedSearchAction(stream, query, .play); @@ -4302,15 +4312,15 @@ fn apiUnifiedResolverSearch(stream: std.Io.net.Stream, query: []const u8) void { .{ .name = "torrent", .enabled = resolver.sourceOn(.torrent), .status = resolver.combineManySourceStatuses(&.{ resolver.status_torrent.load(.acquire), resolver.status_yts.load(.acquire), resolver.status_torznab.load(.acquire), resolver.status_eztv.load(.acquire) }) }, .{ .name = "jellyfin", .enabled = resolver.sourceOn(.jellyfin), .status = resolver.status_jf.load(.acquire) }, .{ .name = "youtube", .enabled = resolver.sourceOn(.youtube), .status = resolver.status_yt.load(.acquire) }, - .{ .name = "anime", .enabled = resolver.sourceOn(.anime), .status = resolver.status_anime.load(.acquire) }, - .{ .name = "comics", .enabled = resolver.sourceOn(.comics), .status = resolver.status_comics.load(.acquire) }, + .{ .name = "anime", .enabled = resolver.sourceOn(.anime), .status = resolver.combineSourceStatuses(resolver.status_anime.load(.acquire), resolver.status_anime_catalog.load(.acquire)) }, + .{ .name = "comics", .enabled = resolver.sourceOn(.comics), .status = resolver.combineSourceStatuses(resolver.status_comics.load(.acquire), resolver.status_manga_catalog.load(.acquire)) }, .{ .name = "stremio", .enabled = resolver.sourceOn(.stremio), .status = resolver.combineManySourceStatuses(&.{ resolver.status_stremio.load(.acquire), resolver.status_archive.load(.acquire), resolver.status_nasa.load(.acquire), resolver.status_commons.load(.acquire) }) }, .{ .name = "rss", .enabled = resolver.sourceOn(.rss), .status = resolver.status_rss.load(.acquire) }, .{ .name = "livetv", .enabled = resolver.sourceOn(.livetv), .status = resolver.status_livetv.load(.acquire) }, .{ .name = "music", .enabled = resolver.sourceOn(.music), .status = resolver.status_music.load(.acquire) }, .{ .name = "radio", .enabled = resolver.sourceOn(.radio), .status = resolver.status_radio.load(.acquire) }, .{ .name = "podcast", .enabled = resolver.sourceOn(.podcast), .status = resolver.status_podcast.load(.acquire) }, - .{ .name = "novels", .enabled = resolver.sourceOn(.novels), .status = resolver.combineSourceStatuses(resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire)) }, + .{ .name = "novels", .enabled = resolver.sourceOn(.novels), .status = resolver.combineManySourceStatuses(&.{ resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire), resolver.status_public_books.load(.acquire) }) }, .{ .name = "audiobooks", .enabled = resolver.sourceOn(.audiobooks), .status = resolver.status_audiobooks.load(.acquire) }, .{ .name = "opds", .enabled = resolver.sourceOn(.opds), .status = resolver.status_opds.load(.acquire) }, .{ .name = "vndb", .enabled = resolver.sourceOn(.vndb), .status = resolver.status_vndb.load(.acquire) }, @@ -4320,9 +4330,22 @@ fn apiUnifiedResolverSearch(stream: std.Io.net.Stream, query: []const u8) void { }; resolver.unlockRemoteSnapshot(); + const groups = allocator.create(resolver.content.Projection) catch { + sendJsonStatus(stream, "503 Service Unavailable", "{\"error\":\"search grouping unavailable\"}"); + return; + }; + defer allocator.destroy(groups); + var indexes: [resolver.MAX_RESULTS]usize = undefined; + for (indexes[0..count], 0..) |*index, i| index.* = i; + resolver.content.projectInto(snapshot[0..count], indexes[0..count], groups); + var membership: [resolver.MAX_RESULTS]usize = undefined; + for (groups.groups[0..groups.count], 0..) |group, g| { + for (group.indexes[0..group.count]) |index| membership[index] = g; + } + // Each bounded row fits even if every string byte becomes a six-byte JSON // escape. Encoding stays off the connection stack and never drops rows. - const json_buf = allocator.alloc(u8, @as(usize, count) * 12000 + 8192) catch { + const json_buf = allocator.alloc(u8, @as(usize, count) * 16000 + 8192) catch { sendJsonStatus(stream, "503 Service Unavailable", "{\"error\":\"search response unavailable\"}"); return; }; @@ -4348,10 +4371,14 @@ fn apiUnifiedResolverSearch(stream: std.Io.net.Stream, query: []const u8) void { escJsonWrite(&w, txt.safeUtf8(item.author[0..@min(item.author_len, item.author.len)])); w.writeAll("\",\"poster_url\":\"") catch return; escJsonWrite(&w, txt.safeUtf8(item.poster_url[0..@min(item.poster_url_len, item.poster_url.len)])); + w.writeAll("\",\"backdrop_url\":\"") catch return; + escJsonWrite(&w, txt.safeUtf8(item.backdrop_url[0..@min(item.backdrop_url_len, item.backdrop_url.len)])); w.writeAll("\",\"summary\":\"") catch return; escJsonWrite(&w, txt.safeUtf8(item.summary[0..@min(item.summary_len, item.summary.len)])); const rating = if (std.math.isFinite(item.rating) and item.rating > 0 and item.rating <= 10) item.rating else 0; w.print("\",\"rating\":{d:.1}", .{rating}) catch return; + const group = &groups.groups[membership[idx]]; + w.print(",\"year\":{d},\"work_key\":\"{x}\",\"work_category\":\"{s}\",\"work_representative\":{s},\"offer_count\":{d},\"verified_work_match\":{s}", .{ item.year, group.identity, @tagName(group.category), if (group.representative == idx) "true" else "false", group.offer_count, if (item.source == .torrent and group.category != .releases) "true" else "false" }) catch return; // The native and web views share the same typed facet projection. Only // public metadata is serialized; action keys retain private transport. const view = resolver.searchView(item); @@ -4377,6 +4404,72 @@ fn apiUnifiedResolverSearch(stream: std.Io.net.Stream, query: []const u8) void { sendJson(stream, json_buf[0..w.end]); } +fn apiUnifiedSearchPreview(stream: std.Io.net.Stream, query: []const u8) void { + const resolver = @import("resolver.zig"); + const preview = @import("search_preview.zig"); + const generation = std.fmt.parseInt(u32, getQueryParam(query, "generation") orelse "", 10) catch { + sendJsonStatus(stream, "400 Bad Request", "{\"error\":\"invalid generation\"}"); + return; + }; + const key = std.fmt.parseInt(u64, getQueryParam(query, "key") orelse "", 16) catch { + sendJsonStatus(stream, "400 Bad Request", "{\"error\":\"invalid result key\"}"); + return; + }; + var kind: preview.Kind = .movie; + var id: i32 = 0; + const current = resolver.lockRemoteSnapshot(); + if (current != generation) { + resolver.unlockRemoteSnapshot(); + sendJsonStatus(stream, "409 Conflict", "{\"error\":\"search changed; refresh and retry\"}"); + return; + } + for (resolver.results[0..resolver.result_count]) |*row| { + if (resolver.actionKey(row) != key) continue; + const media = row.catalog_kind[0..row.catalog_kind_len]; + if (row.source == .tmdb and row.catalog_id > 0 and + (std.mem.eql(u8, media, "movie") or std.mem.eql(u8, media, "tv"))) + { + id = row.catalog_id; + kind = if (std.mem.eql(u8, media, "tv")) .tv else .movie; + } + break; + } + resolver.unlockRemoteSnapshot(); + if (id == 0) { + sendJsonStatus(stream, "404 Not Found", "{\"error\":\"preview unavailable for this result\"}"); + return; + } + const credentials = preview.copyCredentials(); + const reply = preview.lookupMetadata(kind, id, credentials.key[0..credentials.key_len]); + // Fetching occurs without resolver locks. Reject a response whose search + // wave was superseded during the network lookup, preserving UI selection. + if (!resolver.generationIsCurrent(generation)) { + sendJsonStatus(stream, "409 Conflict", "{\"error\":\"search changed; refresh and retry\"}"); + return; + } + var buf: [8192]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + w.print("{{\"generation\":{d},\"key\":\"{x}\",\"status\":\"{s}\",\"failure\":\"{s}\",\"title\":\"", .{ generation, key, @tagName(reply.status), @tagName(reply.failure) }) catch return; + escJsonWrite(&w, reply.metadata.title[0..reply.metadata.title_len]); + w.writeAll("\",\"trailer_url\":\"") catch return; + escJsonWrite(&w, reply.metadata.trailer_url[0..reply.metadata.trailer_url_len]); + w.writeAll("\",\"backdrop_url\":\"") catch return; + escJsonWrite(&w, reply.metadata.backdrop_url[0..reply.metadata.backdrop_url_len]); + w.print("\",\"catalog_id\":{d}}}", .{reply.metadata.catalog_id}) catch return; + sendJson(stream, buf[0..w.end]); +} + +fn apiUnifiedSearchCancel(stream: std.Io.net.Stream, query: []const u8) void { + const generation = std.fmt.parseInt(u32, getQueryParam(query, "generation") orelse "", 10) catch { + sendJsonStatus(stream, "400 Bad Request", "{\"error\":\"invalid generation\"}"); + return; + }; + if (@import("resolver.zig").cancelExpected(generation)) + sendJson(stream, "{\"ok\":true}") + else + sendJsonStatus(stream, "409 Conflict", "{\"error\":\"search changed; refresh and retry\"}"); +} + fn apiUnifiedSearchAction( stream: std.Io.net.Stream, query: []const u8, diff --git a/src/services/remote_limits_pure.zig b/src/services/remote_limits_pure.zig index 743f7e1f..346d02a4 100644 --- a/src/services/remote_limits_pure.zig +++ b/src/services/remote_limits_pure.zig @@ -134,6 +134,7 @@ fn queryEquals(query: []const u8, wanted: []const u8, value: []const u8) bool { /// charged. Playback/status/media polling is intentionally absent. pub fn expensiveCost(path: []const u8, query: []const u8) u16 { if (std.mem.eql(u8, path, "/api/scrape")) return 4; + if (std.mem.eql(u8, path, "/api/unified_search/preview")) return 1; if (std.mem.eql(u8, path, "/api/search") or std.mem.eql(u8, path, "/api/unified_search") or std.mem.endsWith(u8, path, "/search")) return if (hasQueryValue(query, "q")) 1 else 0; @@ -193,6 +194,8 @@ test "weighted scrape cost and expensive route allowlist exclude polling" { try std.testing.expectEqual(@as(u16, 4), expensiveCost("/api/scrape", "")); try std.testing.expectEqual(@as(u16, 1), expensiveCost("/api/tmdb/search", "q=opal")); try std.testing.expectEqual(@as(u16, 1), expensiveCost("/api/unified_search", "q=opal")); + try std.testing.expectEqual(@as(u16, 1), expensiveCost("/api/unified_search/preview", "generation=2&key=ab")); + try std.testing.expectEqual(@as(u16, 0), expensiveCost("/api/unified_search/cancel", "generation=2")); try std.testing.expectEqual(@as(u16, 1), expensiveCost("/api/recommendations", "refresh=1")); for ([_]struct { path: []const u8, query: []const u8 }{ .{ .path = "/api/search", .query = "" }, diff --git a/src/services/remote_static.zig b/src/services/remote_static.zig index 1b765bce..cc90c8ef 100644 --- a/src/services/remote_static.zig +++ b/src/services/remote_static.zig @@ -149,7 +149,7 @@ fn serveFile(stream: std.Io.net.Stream, path: []const u8, asset: Asset, index: u .immutable => "Cache-Control: public, max-age=31536000, immutable\r\n", }; const privacy_header: []const u8 = if (std.mem.eql(u8, asset.content_type, "text/html")) - "Referrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob:; connect-src 'self'\r\n" + "Referrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob:; connect-src 'self'; frame-src 'self' https://www.youtube-nocookie.com\r\n" else ""; const validator: []const u8 = if (etag.len > 0) "ETag: " else ""; diff --git a/src/services/resolver.zig b/src/services/resolver.zig index d942d065..efd8b080 100644 --- a/src/services/resolver.zig +++ b/src/services/resolver.zig @@ -52,6 +52,9 @@ pub const ResolvedItem = struct { poster_url_len: usize = 0, summary: [512]u8 = std.mem.zeroes([512]u8), summary_len: usize = 0, + year: u16 = 0, + backdrop_url: [512]u8 = std.mem.zeroes([512]u8), + backdrop_url_len: usize = 0, rating: f32 = 0, // Provider rating out of ten; zero means unknown. author: [160]u8 = std.mem.zeroes([160]u8), author_len: usize = 0, @@ -110,7 +113,8 @@ pub const ResolvedItem = struct { // Shared result buffer. The cap is a hard ceiling on a single search wave — // with the audio/live verticals fanning out alongside video, 64 starved the // late finishers, so it is sized to give every source room to land. -pub const MAX_RESULTS: usize = 96; +pub const content = @import("search_content_pure.zig"); +pub const MAX_RESULTS: usize = content.MAX_ROWS; pub var results: [MAX_RESULTS]ResolvedItem = std.mem.zeroes([MAX_RESULTS]ResolvedItem); pub var result_count: usize = 0; pub var results_mutex = @import("../core/sync.zig").Mutex{}; @@ -151,6 +155,9 @@ pub var status_livetv = std.atomic.Value(SourceStatus).init(.idle); pub var status_music = std.atomic.Value(SourceStatus).init(.idle); pub var status_radio = std.atomic.Value(SourceStatus).init(.idle); pub var status_podcast = std.atomic.Value(SourceStatus).init(.idle); +pub var status_anime_catalog = std.atomic.Value(SourceStatus).init(.idle); +pub var status_manga_catalog = std.atomic.Value(SourceStatus).init(.idle); +pub var status_public_books = std.atomic.Value(SourceStatus).init(.idle); pub var status_catalog = std.atomic.Value(SourceStatus).init(.idle); pub var status_plex = std.atomic.Value(SourceStatus).init(.idle); pub var status_plugins = std.atomic.Value(SourceStatus).init(.idle); @@ -177,6 +184,12 @@ pub const SourceBit = enum(u4) { local, torrent, jellyfin, youtube, anime, comic /// vertical can't silently leave its pill off (or make `all on` mis-detect). pub const ALL_SOURCE_BITS: u16 = @intCast((@as(u32, 1) << @typeInfo(SourceBit).@"enum".fields.len) - 1); pub var source_mask: std.atomic.Value(u16) = std.atomic.Value(u16).init(ALL_SOURCE_BITS); +const CacheScope = struct { mask: u16 = ALL_SOURCE_BITS, fingerprint: u64 = 0 }; +var resolver_cache_scope: CacheScope = .{}; // lifecycle_mutex +fn currentCacheScope() CacheScope { + const mask = source_mask.load(.acquire); + return .{ .mask = if (mask == 0) ALL_SOURCE_BITS else mask, .fingerprint = @import("../core/source_config.zig").fingerprint() }; +} pub fn sourceOn(bit: SourceBit) bool { const m = source_mask.load(.acquire); @@ -397,6 +410,7 @@ pub fn resolveTracked(query: []const u8, intent: []const u8) u32 { // the generation orphans the in-flight wave: its pushResult calls no-op // (worker_gen mismatch) while this wave's workers own the fresh statuses. const this_run = run_gen.fetchAdd(1, .acq_rel) +% 1; + resolver_cache_scope = currentCacheScope(); // Save query — normalize "season X episode Y" → "SXXEYY" var norm_buf: [256]u8 = undefined; @@ -458,6 +472,9 @@ pub fn resolveTracked(query: []const u8, intent: []const u8) u32 { Pre.set(&status_podcast, .podcast); Pre.set(&status_novels, .novels); Pre.set(&status_novel_archive, .novels); + Pre.set(&status_anime_catalog, .anime); + Pre.set(&status_manga_catalog, .comics); + Pre.set(&status_public_books, .novels); Pre.set(&status_vndb, .vndb); Pre.set(&status_audiobooks, .audiobooks); Pre.set(&status_opds, .opds); @@ -490,6 +507,9 @@ pub fn resolveTracked(query: []const u8, intent: []const u8) u32 { } }; if (sourceOn(.local)) Spawn.go(resolveLocalFiles, &status_local); // instant — already on disk + if (sourceOn(.anime)) Spawn.go(resolveAnimeCatalog, &status_anime_catalog); + if (sourceOn(.comics)) Spawn.go(resolveMangaCatalog, &status_manga_catalog); + if (sourceOn(.novels)) Spawn.go(resolvePublicBooks, &status_public_books); Spawn.go(resolveCatalog, &status_catalog); // typed movie/show metadata, always available keylessly Spawn.go(resolvePlex, &status_plex); // connected personal library; unavailable is immediate Spawn.go(resolveInstalledPlugins, &status_plugins); // trusted executable content plugins @@ -658,6 +678,7 @@ pub fn searchView(item: *const ResolvedItem) search_view.Item { .torrent = torrent, .library = switch (item.source) { .local, .jellyfin, .plex, .audiobooks, .opds => true, + .music => std.mem.startsWith(u8, url, "opal://music/"), else => false, }, .quality = item.quality, @@ -754,6 +775,15 @@ pub fn cancel() void { } /// Admission and invalidation share the lifecycle mutex with worker publish. +/// Remote cancellation binds to the visible wave and cannot stop a successor. +pub fn cancelExpected(expected: u32) bool { + lifecycle_mutex.lock(); + defer lifecycle_mutex.unlock(); + if (run_gen.load(.acquire) != expected) return false; + cancelLocked(); + return true; +} + fn cancelLocked() void { _ = run_gen.fetchAdd(1, .acq_rel); is_resolving.store(false, .release); @@ -781,6 +811,9 @@ fn resetSearchingStatuses() void { &status_music, &status_radio, &status_podcast, + &status_anime_catalog, + &status_manga_catalog, + &status_public_books, &status_catalog, &status_plex, &status_plugins, @@ -851,6 +884,7 @@ threadlocal var thread_sink: ?*Sink = null; pub fn warmQuery(query: []const u8) void { if (!state.app.content_cache_enabled) return; if (query.len == 0 or query.len > 255) return; + const cache_scope = currentCacheScope(); const rows = alloc.alloc(ResolvedItem, MAX_RESULTS) catch return; defer alloc.free(rows); @@ -886,7 +920,9 @@ pub fn warmQuery(query: []const u8) void { defer alloc.free(buf); const blob = serializeRows(sink.items[0..sink.count], buf) orelse return; var key_buf: [288]u8 = undefined; - content_cache.put(cacheKey(&key_buf, query), blob, SEARCH_TTL_S); + const current_scope = currentCacheScope(); + if (current_scope.mask != cache_scope.mask or current_scope.fingerprint != cache_scope.fingerprint) return; + content_cache.put(cacheKey(&key_buf, query, cache_scope), blob, SEARCH_TTL_S); var lb: [96]u8 = undefined; logs.pushLog("info", "resolver", std.fmt.bufPrint( @@ -1005,8 +1041,12 @@ fn pushInto( // Legacy callers can reorder the live list; restore its relevance invariant // before selecting the worst bounded candidate or inserting a streamed row. std.sort.insertion(ResolvedItem, items[0..count.*], {}, relevanceBefore); - if (!search_view.admitTopK(count.*, items.len, .{ .score = scored_item.score, .key = actionKey(&scored_item) }, if (count.* > 0) .{ .score = items[count.* - 1].score, .key = actionKey(&items[count.* - 1]) } else .{})) return false; - if (count.* >= items.len) count.* -= 1; + if (count.* >= items.len) { + const victim = content.evictionIndex(items[0..count.*], scored_item) orelse return false; + var shift = victim; + while (shift + 1 < count.*) : (shift += 1) items[shift] = items[shift + 1]; + count.* -= 1; + } // Insert sorted by relevance with the same stable identity tie policy. var insert_at: usize = count.*; @@ -1125,10 +1165,11 @@ fn resolveCatalog(query_buf: [256]u8, qlen: usize) void { if (std.mem.startsWith(u8, poster, "https://") or std.mem.startsWith(u8, poster, "http://")) { if (poster.len <= item.poster_url.len) copyField(&item.poster_url, &item.poster_url_len, poster); } else if (std.mem.startsWith(u8, poster, "/")) { - const address = std.fmt.bufPrint(&item.poster_url, "https://image.tmdb.org/t/p/w185{s}", .{poster}) catch ""; + const address = std.fmt.bufPrint(&item.poster_url, "https://image.tmdb.org/t/p/w342{s}", .{poster}) catch ""; item.poster_url_len = address.len; } const year = entry.year[0..@min(entry.year_len, entry.year.len)]; + item.year = std.fmt.parseInt(u16, year, 10) catch 0; const label = if (std.mem.eql(u8, kind, "tv")) "TV details" else "Movie details"; const detail = if (year.len > 0) std.fmt.bufPrint(&item.detail, "{s} · {s}", .{ year, label }) catch label @@ -1341,8 +1382,8 @@ fn attrValue(html: []const u8, name: []const u8, limit: usize) ?[]const u8 { // Serialization routes through content_cache_pure.Writer/Reader (tested). // ══════════════════════════════════════════════════════════ -fn cacheKey(buf: []u8, query: []const u8) []const u8 { - return std.fmt.bufPrint(buf, "search:v8:{s}", .{query}) catch "search:v8:"; +fn cacheKey(buf: []u8, query: []const u8, scope: CacheScope) []const u8 { + return content.cacheIdentity(buf, query, scope.mask, scope.fingerprint) orelse ""; } /// Serialize the current `results` (under caller's lock) into `out`. @@ -1356,11 +1397,15 @@ fn serializeResults(out: []u8) ?[]u8 { /// cache the live path reads back. fn serializeRows(rows: []const ResolvedItem, out: []u8) ?[]u8 { var w = ccp.Writer.init(out); - const n: u16 = @intCast(@min(rows.len, MAX_RESULTS)); + var n: u16 = 0; + for (rows[0..@min(rows.len, MAX_RESULTS)]) |*row| { + if (content.cacheEligible(.{ .source = @tagName(row.source), .provider = row.provider.name(), .url = row.url[0..row.url_len] })) n += 1; + } w.u16v(n); var i: usize = 0; - while (i < n) : (i += 1) { + while (i < @min(rows.len, MAX_RESULTS)) : (i += 1) { const it = rows[i]; + if (!content.cacheEligible(.{ .source = @tagName(it.source), .provider = it.provider.name(), .url = it.url[0..it.url_len] })) continue; w.blob(it.name[0..@min(it.name_len, it.name.len)]); w.blob(it.detail[0..@min(it.detail_len, it.detail.len)]); w.blob(it.provider.name()); @@ -1390,6 +1435,8 @@ fn serializeRows(rows: []const ResolvedItem, out: []u8) ?[]u8 { w.blob(it.poster_url[0..@min(it.poster_url_len, it.poster_url.len)]); w.blob(it.summary[0..@min(it.summary_len, it.summary.len)]); w.f32v(it.rating); + w.u16v(it.year); + w.blob(it.backdrop_url[0..@min(it.backdrop_url_len, it.backdrop_url.len)]); w.blob(it.author[0..@min(it.author_len, it.author.len)]); if (it.source == .opds) { w.blob(it.opds_entry.title[0..@min(it.opds_entry.title_len, it.opds_entry.title.len)]); @@ -1461,6 +1508,8 @@ fn deserializeInto(bytes: []const u8) usize { copyField(&it.poster_url, &it.poster_url_len, r.blob() orelse break); copyField(&it.summary, &it.summary_len, r.blob() orelse break); it.rating = r.f32v() orelse break; + it.year = r.u16v() orelse break; + copyField(&it.backdrop_url, &it.backdrop_url_len, r.blob() orelse break); copyField(&it.author, &it.author_len, r.blob() orelse break); if (it.source == .opds) { copyField(&it.opds_entry.title, &it.opds_entry.title_len, r.blob() orelse break); @@ -1489,7 +1538,7 @@ fn populateFromCache(query: []const u8) void { const buf = alloc.alloc(u8, SEARCH_BLOB_CAP) catch return; defer alloc.free(buf); var key_buf: [288]u8 = undefined; - const key = cacheKey(&key_buf, query); + const key = cacheKey(&key_buf, query, resolver_cache_scope); const hit = content_cache.get(key, buf) orelse return; results_mutex.lock(); defer results_mutex.unlock(); @@ -1506,6 +1555,8 @@ fn populateFromCache(query: []const u8) void { /// cache-seeded placeholder (that would reset the TTL without a network fetch). fn storeToCache() void { if (!state.app.content_cache_enabled) return; + const current_scope = currentCacheScope(); + if (current_scope.mask != resolver_cache_scope.mask or current_scope.fingerprint != resolver_cache_scope.fingerprint) return; results_mutex.lock(); if (result_count == 0 or results_from_cache) { results_mutex.unlock(); @@ -1523,7 +1574,9 @@ fn storeToCache() void { results_mutex.unlock(); if (blob) |b| { var key_buf: [288]u8 = undefined; - const key = cacheKey(&key_buf, qbuf[0..qn]); + // The wave retains its initial configuration scope. A reload while + // workers drain must not write old results under the new credentials. + const key = cacheKey(&key_buf, qbuf[0..qn], resolver_cache_scope); content_cache.put(key, b, SEARCH_TTL_S); } } @@ -1565,6 +1618,7 @@ fn checkAllDoneLocked(run: u32) void { status_nasa.load(.acquire) != .searching and status_commons.load(.acquire) != .searching and status_music.load(.acquire) != .searching and status_radio.load(.acquire) != .searching and status_podcast.load(.acquire) != .searching and status_livetv.load(.acquire) != .searching and + status_anime_catalog.load(.acquire) != .searching and status_manga_catalog.load(.acquire) != .searching and status_public_books.load(.acquire) != .searching and status_catalog.load(.acquire) != .searching and status_plex.load(.acquire) != .searching and status_plugins.load(.acquire) != .searching and status_novels.load(.acquire) != .searching and status_novel_archive.load(.acquire) != .searching and @@ -2964,8 +3018,8 @@ fn resolveArchive(query_buf: [256]u8, qlen: usize) void { var item = std.mem.zeroes(ResolvedItem); item.source = .stremio; // HTTP-direct stream — plays via mpv load_file - const nlen = @min(raw_title.len, 255); + item.provider = search_view.Provider.init("archive"); @memcpy(item.name[0..nlen], raw_title[0..nlen]); item.name_len = nlen; @@ -3118,8 +3172,8 @@ fn resolveNasa(query_buf: [256]u8, qlen: usize) void { var item = std.mem.zeroes(ResolvedItem); item.source = .stremio; // HTTP-direct stream — plays via mpv load_file - const nlen = @min(hit.title.len, 255); + item.provider = search_view.Provider.init("nasa"); @memcpy(item.name[0..nlen], hit.title[0..nlen]); item.name_len = nlen; @@ -3216,8 +3270,8 @@ fn resolveCommons(query_buf: [256]u8, qlen: usize) void { var item = std.mem.zeroes(ResolvedItem); item.source = .stremio; // HTTP-direct stream — plays via mpv load_file - const nlen = @min(pg.title.len, 255); + item.provider = search_view.Provider.init("commons"); @memcpy(item.name[0..nlen], pg.title[0..nlen]); item.name_len = nlen; @@ -3482,7 +3536,7 @@ fn resolveYouTube(query_buf: [256]u8, qlen: usize) void { /// Cap per audio source — the fan-out already competes for MAX_RESULTS slots, /// and a music query returning 30 rows would bury every video result. -const AUDIO_MAX: usize = 6; +const AUDIO_MAX: usize = 16; fn resolveLiveTv(query_buf: [256]u8, qlen: usize) void { defer finishWorker(&status_livetv, .done); @@ -3533,51 +3587,47 @@ fn resolveMusic(query_buf: [256]u8, qlen: usize) void { defer finishWorker(&status_music, .done); const query = query_buf[0..qlen]; if (query.len < 2) return; - - const jp = @import("music_jiosaavn_pure.zig"); - var url_buf: [640]u8 = undefined; - const url = jp.buildSearchUrl(&url_buf, query, 20) orelse { - noteWorkerOutcome(.failed); + const music = @import("music_subsonic.zig"); + const mp = @import("music_subsonic_pure.zig"); + var enabled: [5]bool = @splat(false); + var providers: usize = 0; + for (&enabled, 0..) |*on, index| { + on.* = music.universalConfigured(@intCast(index)); + if (on.*) providers += 1; + } + if (providers == 0) { + noteWorkerOutcome(.unavailable); return; - }; - - // JioSaavn search payloads run large — heap, not the worker stack. - const buf = alloc.alloc(u8, 512 * 1024) catch { + } + const songs = alloc.alloc(mp.MusicSong, AUDIO_MAX) catch { noteWorkerOutcome(.failed); return; }; - defer alloc.free(buf); - const body = @import("../core/http.zig").fetch(url, buf, .{ .timeout_secs = 8 }) orelse { - noteWorkerOutcome(.transport_failed); - return; - }; - - var parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch { - noteWorkerOutcome(.parse_failed); - return; - }; - defer parsed.deinit(); - const rows = @import("music_subsonic_pure.zig").pageRows(parsed.value, .jiosaavn) orelse { - noteWorkerOutcome(.parse_failed); - return; - }; - var found: usize = 0; - for (rows) |obj| { - if (found >= AUDIO_MAX) break; - const song = jp.parseSongValue(obj) orelse continue; - - var item = std.mem.zeroes(ResolvedItem); - item.source = .music; - copyField(&item.name, &item.name_len, song.title[0..song.title_len]); - copyField(&item.url, &item.url_len, song.play_url[0..song.play_url_len]); - copyField(&item.poster_url, &item.poster_url_len, song.cover[0..song.cover_len]); - var d: [128]u8 = undefined; - const detail = if (song.artist_len > 0) - std.fmt.bufPrint(&d, "Music - {s}", .{song.artist[0..song.artist_len]}) catch "Music" - else - "Music"; - copyField(&item.detail, &item.detail_len, detail); - if (pushResult(item)) found += 1; + defer alloc.free(songs); + var visited: usize = 0; + for (enabled, 0..) |on, index| { + if (!on) continue; + if (thread_sink == null and run_gen.load(.acquire) != worker_gen) return; + const quota = mp.providerQuota(AUDIO_MAX, providers, visited); + visited += 1; + const source: u8 = @intCast(index); + const reply = music.searchInto(source, query, songs[0..quota]); + if (reply.status != .done and reply.status != .no_results) noteWorkerOutcome(reply.status); + for (songs[0..reply.count]) |song| { + var item: ResolvedItem = .{ .source = .music }; + item.provider = search_view.Provider.init(music.universalProviderName(source)); + copyField(&item.name, &item.name_len, song.title[0..song.title_len]); + copyField(&item.author, &item.author_len, song.artist[0..song.artist_len]); + copyField(&item.poster_url, &item.poster_url_len, song.cover[0..song.cover_len]); + if (source == music.SRC_SUBSONIC or source == music.SRC_JELLYFIN or source == music.SRC_PLEX) { + const route = mp.universalRoute(&item.url, source, reply.connection, song.id[0..song.id_len], if (source == music.SRC_PLEX) song.play_url[0..song.play_url_len] else "") orelse continue; + item.url_len = route.len; + } else copyField(&item.url, &item.url_len, song.play_url[0..song.play_url_len]); + var detail_buf: [128]u8 = undefined; + const detail = std.fmt.bufPrint(&detail_buf, "{s} · {s}", .{ music.universalProviderName(source), song.artist[0..song.artist_len] }) catch music.universalProviderName(source); + copyField(&item.detail, &item.detail_len, detail); + _ = pushResult(item); + } } } @@ -3606,9 +3656,12 @@ fn resolveRadio(query_buf: [256]u8, qlen: usize) void { return; }; - // Station is ~1.3KB; AUDIO_MAX of them is well under the stack budget. - var stations: [AUDIO_MAX]rp.Station = undefined; - const page = rp.parsePage(alloc, body, &stations) orelse { + const stations = alloc.alloc(rp.Station, AUDIO_MAX) catch { + noteWorkerOutcome(.failed); + return; + }; + defer alloc.free(stations); + const page = rp.parsePage(alloc, body, stations) orelse { noteWorkerOutcome(.parse_failed); return; }; @@ -3626,6 +3679,7 @@ fn resolveRadio(query_buf: [256]u8, qlen: usize) void { var item = std.mem.zeroes(ResolvedItem); item.source = .radio; + item.provider = search_view.Provider.init("Radio Browser"); copyField(&item.name, &item.name_len, s.name[0..s.name_len]); copyField(&item.url, &item.url_len, stream); copyField(&item.poster_url, &item.poster_url_len, s.favicon[0..s.favicon_len]); @@ -3640,77 +3694,94 @@ fn resolveRadio(query_buf: [256]u8, qlen: usize) void { } } +fn publishPodcastRows(rows: []const @import("podcasts_pure.zig").Podcast, provider: []const u8) void { + for (rows) |show| { + if (show.feed_url_len == 0 or show.name_len == 0) continue; + var item: ResolvedItem = .{ .source = .podcast }; + item.provider = search_view.Provider.init(provider); + copyField(&item.name, &item.name_len, show.name[0..show.name_len]); + copyField(&item.author, &item.author_len, show.artist[0..show.artist_len]); + copyField(&item.poster_url, &item.poster_url_len, show.artwork[0..show.artwork_len]); + copyField(&item.url, &item.url_len, show.feed_url[0..show.feed_url_len]); + var detail_buf: [128]u8 = undefined; + const detail = std.fmt.bufPrint(&detail_buf, "{s} · {s}", .{ provider, show.artist[0..show.artist_len] }) catch provider; + copyField(&item.detail, &item.detail_len, detail); + _ = pushResult(item); + } +} + fn resolvePodcasts(query_buf: [256]u8, qlen: usize) void { defer finishWorker(&status_podcast, .done); const query = query_buf[0..qlen]; if (query.len < 2) return; - const pp = @import("podcasts_pure.zig"); - var enc_buf: [512]u8 = undefined; - const enc = @import("../core/http.zig").urlEncode(query, &enc_buf); - var url_buf: [640]u8 = undefined; - const url = std.fmt.bufPrint( - &url_buf, - "https://itunes.apple.com/search?media=podcast&limit=20&term={s}", - .{enc}, - ) catch { - noteWorkerOutcome(.failed); - return; - }; - - const buf = alloc.alloc(u8, 512 * 1024) catch { + const cfg = @import("../core/source_config.zig"); + const http = @import("../core/http.zig"); + const buffer = alloc.alloc(u8, 512 * 1024) catch { noteWorkerOutcome(.failed); return; }; - defer alloc.free(buf); - const body = @import("../core/http.zig").fetch(url, buf, .{ .timeout_secs = 8 }) orelse { - noteWorkerOutcome(.transport_failed); - return; - }; - - var parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch { - noteWorkerOutcome(.parse_failed); - return; - }; - defer parsed.deinit(); - if (parsed.value != .object) { - noteWorkerOutcome(.parse_failed); - return; - } - const entries = parsed.value.object.get("results") orelse { - noteWorkerOutcome(.parse_failed); - return; - }; - if (entries != .array) { - noteWorkerOutcome(.parse_failed); - return; - } + defer alloc.free(buffer); const shows = alloc.alloc(pp.Podcast, AUDIO_MAX) catch { noteWorkerOutcome(.failed); return; }; defer alloc.free(shows); - const n = pp.parseItunesValue(parsed.value, shows) orelse { - noteWorkerOutcome(.parse_failed); + const feeds = alloc.alloc(cfg.FieldSnapshot, 256) catch { + noteWorkerOutcome(.failed); return; }; - var i: usize = 0; - while (i < n) : (i += 1) { - const s = shows[i]; - if (s.feed_url_len == 0 or s.name_len == 0) continue; - var item = std.mem.zeroes(ResolvedItem); - item.source = .podcast; - copyField(&item.name, &item.name_len, s.name[0..s.name_len]); - copyField(&item.poster_url, &item.poster_url_len, s.artwork[0..s.artwork_len]); - // The feed URL is the identity — playItem opens the Podcasts tab on it. - copyField(&item.url, &item.url_len, s.feed_url[0..s.feed_url_len]); - var d: [128]u8 = undefined; - const detail = if (s.artist_len > 0) - std.fmt.bufPrint(&d, "Podcast - {s}", .{s.artist[0..s.artist_len]}) catch "Podcast" + defer alloc.free(feeds); + const feed_count = cfg.copyFields("feed", feeds); + // Bound total network work. An explicit partial state exposes skipped + // installed feeds rather than reporting a complete search of every feed. + const feed_limit: usize = 4; + if (feed_count > feed_limit) noteWorkerOutcome(.partial); + var installed_count: usize = 0; + for (feeds[0..@min(feed_count, feed_limit)]) |feed| { + if (thread_sink == null and run_gen.load(.acquire) != worker_gen) return; + const url = feed.value[0..feed.value_len]; + if (!pp.isFeedUrl(url)) continue; + const body = http.fetch(url, buffer, .{ .timeout_secs = 2 }) orelse { + noteWorkerOutcome(.transport_failed); + continue; + }; + // General RSS/torrent feeds are not podcast directories. + if (std.mem.indexOf(u8, body, "itunes:") == null and std.mem.indexOf(u8, body, "type=\"audio/") == null and std.mem.indexOf(u8, body, "type='audio/") == null) continue; + const show = pp.parseFeedShow(body, url) orelse { + noteWorkerOutcome(.parse_failed); + continue; + }; + if (std.ascii.indexOfIgnoreCase(show.name[0..show.name_len], query) == null and std.ascii.indexOfIgnoreCase(show.artist[0..show.artist_len], query) == null) continue; + shows[installed_count] = show; + installed_count += 1; + } + publishPodcastRows(shows[0..installed_count], "Installed RSS"); + var enc_buf: [768]u8 = undefined; + const encoded = http.urlEncode(query, &enc_buf); + var url_buf: [1200]u8 = undefined; + // Divide remaining audio quota fairly across independent public catalogs. + const directory_quota = (AUDIO_MAX - installed_count) / 2; + for (0..2) |directory| { + if (thread_sink == null and run_gen.load(.acquire) != worker_gen) return; + const url = if (directory == 0) + std.fmt.bufPrint(&url_buf, "https://gpodder.net/search.json?q={s}", .{encoded}) catch continue else - "Podcast"; - copyField(&item.detail, &item.detail_len, detail); - _ = pushResult(item); + std.fmt.bufPrint(&url_buf, "https://itunes.apple.com/search?media=podcast&limit={d}&term={s}", .{ directory_quota, encoded }) catch continue; + const body = http.fetch(url, buffer, .{ .timeout_secs = 4 }) orelse { + noteWorkerOutcome(.transport_failed); + continue; + }; + var parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch { + noteWorkerOutcome(.parse_failed); + continue; + }; + defer parsed.deinit(); + const count = (if (directory == 0) pp.parseGpodderValue(parsed.value, shows[0..directory_quota]) else pp.parseItunesValue(parsed.value, shows[0..directory_quota])) orelse { + noteWorkerOutcome(.parse_failed); + continue; + }; + publishPodcastRows(shows[0..count], if (directory == 0) "gpodder" else "Apple Podcasts"); } } @@ -4145,7 +4216,10 @@ pub fn playResolvedItem(item: *const ResolvedItem) void { // Load the issue and reveal the Browse › Comics reader (comics read // inside the Comics tab now, not the player route). const comics = @import("comics.zig"); - comics.loadComic(item.url[0..item.url_len]); + if (std.mem.eql(u8, item.provider.name(), "mangadex")) + comics.searchComics(item.name[0..item.name_len]) + else + comics.loadComic(item.url[0..item.url_len]); state.navigateToTab(.Comics); }, .tmdb => { @@ -4158,7 +4232,17 @@ pub fn playResolvedItem(item: *const ResolvedItem) void { "", ); } else { - @import("search.zig").submitQuery(item.name[0..item.name_len]); + // Movie metadata opens its real catalog page; never re-submit + // the same universal query in a navigation loop. + var address: [256]u8 = undefined; + const imdb_id = item.catalog_imdb[0..item.catalog_imdb_len]; + const url: []const u8 = if (imdb_id.len > 2 and std.mem.startsWith(u8, imdb_id, "tt")) + std.fmt.bufPrint(&address, "https://www.imdb.com/title/{s}/", .{imdb_id}) catch "" + else if (item.catalog_id > 0) + std.fmt.bufPrint(&address, "https://www.themoviedb.org/movie/{d}", .{item.catalog_id}) catch "" + else + ""; + if (url.len > 0) @import("../ui/settings.zig").openExternal(url) else state.showToast("Catalog details unavailable"); } }, .opds => @import("opds.zig").openCatalogEntry(item.opds_entry, item.opds_connection_identity), @@ -4176,11 +4260,10 @@ pub fn playResolvedItem(item: *const ResolvedItem) void { @import("vndb.zig").openCatalogIdentity(url[prefix.len..]); }, .podcast => { - // No open-by-feed entry point exists yet, so hand the show title to - // the Podcasts tab's own search and reveal it — the user lands on - // the show with its episode list, one click from playing. + // Resolve this exact feed in Podcasts rather than searching the + // display title, which can select a different show. const podcasts = @import("podcasts.zig"); - podcasts.searchPodcasts(item.name[0..item.name_len]); + podcasts.searchPodcasts(item.url[0..item.url_len]); state.navigateToTab(.Podcasts); }, .livetv => { @@ -4208,7 +4291,14 @@ pub fn playResolvedItem(item: *const ResolvedItem) void { &hdrs, ); }, - .youtube, .stremio, .local, .music, .radio => { + .music => { + const url = item.url[0..item.url_len]; + if (std.mem.startsWith(u8, url, "opal://music/")) { + if (!@import("music_subsonic.zig").playUniversalRoute(url, item.name[0..item.name_len], item.author[0..item.author_len])) + state.showToast("Music connection changed or track is unavailable; search again"); + } else @import("browser.zig").playDirect(.{ .url = url, .title = item.name[0..item.name_len], .subtitle = item.author[0..item.author_len], .art_url = item.poster_url[0..item.poster_url_len] }); + }, + .youtube, .stremio, .local, .radio => { @import("browser.zig").playDirect(.{ .url = item.url[0..item.url_len], .fallback_url = item.fallback_url[0..item.fallback_url_len], @@ -4272,3 +4362,243 @@ fn findObjEnd(data: []const u8, start: usize) usize { } return data.len; } + +// Independent metadata searches never mutate the visible Browse page state. +fn fetchCatalogJson(url: []const u8, provider: []const u8) ?[]u8 { + @import("../core/rate_limit.zig").acquire(provider, 1.0); + const buf = alloc.alloc(u8, 1024 * 1024) catch { + noteWorkerOutcome(.failed); + return null; + }; + const body = @import("../core/http.zig").fetch(url, buf, .{ .timeout_secs = 10, .user_agent = @import("../core/app_meta.zig").user_agent }) orelse { + alloc.free(buf); + noteWorkerOutcome(.transport_failed); + return null; + }; + // Preserve the actual allocation for cleanup while returning a body-sized copy. + const owned = alloc.dupe(u8, body) catch { + alloc.free(buf); + noteWorkerOutcome(.failed); + return null; + }; + alloc.free(buf); + return owned; +} +fn resolveAnimeCatalog(q: [256]u8, qlen: usize) void { + defer { + if (!worker_produced and generationIsCurrent(worker_gen)) resolveAniListCatalog(q[0..qlen]); + finishWorker(&status_anime_catalog, .done); + } + var enc: [1024]u8 = undefined; + const query = @import("../core/http.zig").urlEncode(q[0..qlen], &enc); + var address: [1400]u8 = undefined; + const url = std.fmt.bufPrint(&address, "https://api.jikan.moe/v4/anime?q={s}&limit=12{s}", .{ query, @import("anime_pure.zig").sfwSuffix(state.app.nsfw_filter_enabled) }) catch return; + const body = fetchCatalogJson(url, "jikan") orelse return; + defer alloc.free(body); + const parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch { + noteWorkerOutcome(.parse_failed); + return; + }; + defer parsed.deinit(); + if (parsed.value != .object) { + noteWorkerOutcome(.parse_failed); + return; + } + const records = parsed.value.object.get("data") orelse { + noteWorkerOutcome(.parse_failed); + return; + }; + if (records != .array) { + noteWorkerOutcome(.parse_failed); + return; + } + for (records.array.items) |record| { + if (record != .object) continue; + const id = record.object.get("mal_id") orelse continue; + if (id != .integer or id.integer <= 0 or id.integer > std.math.maxInt(i32)) continue; + var item: ResolvedItem = .{ .source = .anime, .catalog_id = @intCast(id.integer), .provider = search_view.Provider.init("jikan") }; + var name = catalogString(record, "title_english"); + if (name.len == 0) name = catalogString(record, "title"); + if (name.len == 0) continue; + copyField(&item.name, &item.name_len, name); + copyField(&item.catalog_kind, &item.catalog_kind_len, "anime"); + copyField(&item.summary, &item.summary_len, catalogString(record, "synopsis")); + copyField(&item.detail, &item.detail_len, "Anime details · MyAnimeList"); + if (record.object.get("year")) |year| if (year == .integer and year.integer > 0 and year.integer <= 9999) { + item.year = @intCast(year.integer); + }; + if (record.object.get("score")) |score| { + if (score == .float) item.rating = @floatCast(score.float) else if (score == .integer) item.rating = @floatFromInt(score.integer); + } + if (record.object.get("images")) |images| if (images == .object) { + if (images.object.get("jpg")) |jpg| copyField(&item.poster_url, &item.poster_url_len, catalogString(jpg, "image_url")); + }; + const identity_url: []const u8 = std.fmt.bufPrint(&item.url, "opal://anime/{d}", .{item.catalog_id}) catch ""; + item.url_len = identity_url.len; + _ = pushResult(item); + } +} +fn resolveMangaCatalog(q: [256]u8, qlen: usize) void { + defer finishWorker(&status_manga_catalog, .done); + const pure = @import("comics_pure.zig"); + var address: [1400]u8 = undefined; + const url = pure.buildSearchUrl(&address, q[0..qlen], 12, 0) orelse return; + const body = fetchCatalogJson(url, "mangadex") orelse return; + defer alloc.free(body); + const data = pure.findJsonNode(body, "\"data\"") orelse { + noteWorkerOutcome(.parse_failed); + return; + }; + var iter = pure.ObjIter{ .buf = data }; + var n: usize = 0; + while (iter.next()) |obj| { + if (n >= 12) break; + const manga = pure.parseMangaEntry(obj) orelse continue; + var item: ResolvedItem = .{ .source = .comics, .provider = search_view.Provider.init("mangadex") }; + item.name_len = pure.jsonUnescape(manga.title, &item.name); + const identity_url: []const u8 = std.fmt.bufPrint(&item.url, "mangadex:{s}", .{manga.id}) catch ""; + item.url_len = identity_url.len; + if (pure.buildCoverUrl(&item.poster_url, manga.id, manga.cover_file)) |cover| item.poster_url_len = cover.len; + copyField(&item.detail, &item.detail_len, "MangaDex · Read manga"); + if (item.name_len > 0 and item.url_len > 0) { + _ = pushResult(item); + n += 1; + } + } +} +fn resolvePublicBooks(q: [256]u8, qlen: usize) void { + defer finishWorker(&status_public_books, .done); + defer { + if (generationIsCurrent(worker_gen)) resolveInstalledReading(q[0..qlen]); + } + const books = @import("public_books_pure.zig"); + const novels = @import("novels_pure.zig"); + var address: [2048]u8 = undefined; + if (books.openLibrarySearch(&address, q[0..qlen], 1)) |url| { + if (fetchCatalogJson(url, "openlibrary")) |body| { + defer alloc.free(body); + if (std.json.parseFromSlice(std.json.Value, alloc, body, .{})) |parsed| { + defer parsed.deinit(); + if (parsed.value == .object) { + if (parsed.value.object.get("docs")) |docs| if (docs == .array) { + for (docs.array.items[0..@min(docs.array.items.len, 12)]) |doc| { + const book = books.openLibraryItem(doc) orelse continue; + var item: ResolvedItem = .{ .source = .novels, .year = book.year, .provider = search_view.Provider.init("openlibrary") }; + copyField(&item.name, &item.name_len, book.title); + copyField(&item.author, &item.author_len, book.author); + item.url_len = novels.formatDeepLink(&item.url, "openlibrary", book.id, book.title).len; + if (books.openLibraryCover(&item.poster_url, book.cover_id)) |cover| item.poster_url_len = cover.len; + copyField(&item.detail, &item.detail_len, "Open Library · Public text"); + if (item.url_len > 0) _ = pushResult(item); + } + }; + } + } else |_| noteWorkerOutcome(.parse_failed); + } + } + if (books.gutenbergSearch(&address, q[0..qlen], 1)) |url| { + if (fetchCatalogJson(url, "gutenberg")) |body| { + defer alloc.free(body); + var iter = books.GutenbergIter{ .body = body }; + var n: usize = 0; + while (iter.next()) |book| { + if (n >= 12) break; + var item: ResolvedItem = .{ .source = .novels, .provider = search_view.Provider.init("gutenberg") }; + item.name_len = novels.htmlToText(book.title, &item.name); + item.author_len = novels.htmlToText(book.author, &item.author); + item.url_len = novels.formatDeepLink(&item.url, "gutenberg", book.id, book.title).len; + if (std.mem.startsWith(u8, book.cover, "/")) { + const cover_url: []const u8 = std.fmt.bufPrint(&item.poster_url, "https://www.gutenberg.org{s}", .{book.cover}) catch ""; + item.poster_url_len = cover_url.len; + } else copyField(&item.poster_url, &item.poster_url_len, book.cover); + copyField(&item.detail, &item.detail_len, "Project Gutenberg · Read work"); + if (item.url_len > 0 and item.name_len > 0) { + _ = pushResult(item); + n += 1; + } + } + } + } +} + +/// Installed reading adapters use owned endpoint/query values and Browse's +/// pure parsing seam, without publishing to its mutable query/list state. +fn resolveInstalledReading(query: []const u8) void { + const reading = @import("search_reading_pure.zig"); + const sc = @import("../core/source_config.zig"); + for ([_]reading.Source{ .royalroad, .novelfire }) |source| { + if (!generationIsCurrent(worker_gen)) return; + var base_buf: [512]u8 = undefined; + const base = sc.copyValue(@tagName(source), "base", &base_buf) orelse continue; + var address: [1600]u8 = undefined; + const url = @import("expanded_reading_pure.zig").novelSearchUrl(&address, base, @tagName(source), query, 1) orelse continue; + @import("../core/rate_limit.zig").acquire(@tagName(source), 1.0); + const buf = alloc.alloc(u8, 2 * 1024 * 1024) catch { + noteWorkerOutcome(.failed); + continue; + }; + defer alloc.free(buf); + const body = @import("../core/http.zig").fetch(url, buf, .{ .timeout_secs = 10, .user_agent = "Mozilla/5.0" }) orelse { + noteWorkerOutcome(.transport_failed); + continue; + }; + if (!generationIsCurrent(worker_gen)) return; + // Twelve owned records total ~22 KiB; heap avoids adding their storage + // to the already substantial resolver worker stack. + const rows = alloc.alloc(reading.Item, reading.MAX_ITEMS) catch { + noteWorkerOutcome(.failed); + continue; + }; + defer alloc.free(rows); + const page = reading.parseInto(body, base, source, rows); + if (!page.valid_listing) { + noteWorkerOutcome(.parse_failed); + continue; + } + for (rows[0..page.count]) |*row| { + var item: ResolvedItem = .{ .source = .novels, .provider = search_view.Provider.init(@tagName(source)) }; + copyField(&item.name, &item.name_len, row.title[0..row.title_len]); + copyField(&item.poster_url, &item.poster_url_len, row.cover[0..row.cover_len]); + item.url_len = @import("novels_pure.zig").formatDeepLink(&item.url, @tagName(source), row.url[0..row.url_len], row.title[0..row.title_len]).len; + copyField(&item.detail, &item.detail_len, if (source == .royalroad) "Royal Road · Read work" else "NovelFire · Read work"); + if (item.url_len > 0) _ = pushResult(item); + } + } +} + +fn resolveAniListCatalog(query: []const u8) void { + const buf = alloc.alloc(u8, 512 * 1024) catch { + noteWorkerOutcome(.failed); + return; + }; + defer alloc.free(buf); + const n = @import("anilist.zig").fetchSearch(query, state.app.nsfw_filter_enabled, buf); + if (n == 0) { + noteWorkerOutcome(.transport_failed); + return; + } + const pure = @import("anilist_pure.zig"); + const body = @import("anime_pure.zig").anilistCatalogBody(alloc, buf[0..n]) orelse { + noteWorkerOutcome(.parse_failed); + return; + }; + var iter = pure.Iter{ .json = body }; + var count: usize = 0; + while (iter.next()) |media| { + if (count >= 12) break; + if (media.id <= 0 or media.id > std.math.maxInt(i32)) continue; + var item: ResolvedItem = .{ .source = .anime, .catalog_id = @intCast(media.id), .year = media.year, .rating = media.score10, .provider = search_view.Provider.init("anilist") }; + const name = if (media.title_english.len > 0) media.title_english else media.title_romaji; + item.name_len = @import("comics_pure.zig").jsonUnescape(name, &item.name); + item.summary_len = @import("comics_pure.zig").jsonUnescape(media.description, &item.summary); + item.poster_url_len = @import("comics_pure.zig").jsonUnescape(media.cover, &item.poster_url); + copyField(&item.catalog_kind, &item.catalog_kind_len, "anilist"); + copyField(&item.detail, &item.detail_len, "Anime details · AniList"); + const identity_url: []const u8 = std.fmt.bufPrint(&item.url, "opal://anilist/{d}", .{media.id}) catch ""; + item.url_len = identity_url.len; + if (item.name_len > 0) { + _ = pushResult(item); + count += 1; + } + } +} diff --git a/src/services/search.zig b/src/services/search.zig index 46713869..07242728 100644 --- a/src/services/search.zig +++ b/src/services/search.zig @@ -854,6 +854,7 @@ pub fn submitQuery(query_text: []const u8) void { // Own the bytes before cancelling work or clearing either visible buffer. @memcpy(owned[0..n], query_text[0..n]); cancelPendingMemorySearch(); + @import("search_preview.zig").stop(); @import("activity.zig").record(.search, owned[0..n], .{}); setUniversalQuery(owned[0..n]); @import("resolver.zig").resolve(owned[0..n], "auto"); @@ -882,6 +883,7 @@ pub fn cancelPendingMemorySearch() void { /// Unified Clear supersedes both searches and pending memory publication. pub fn clearShellSearch() void { + @import("search_preview.zig").stop(); cancelPendingMemorySearch(); search_abort.store(true, .release); _ = search_generation.fetchAdd(1, .acq_rel); @@ -1018,6 +1020,7 @@ pub fn reapWorkers() void { /// first would deadlock shutdown behind a child process that has not yet been /// told to exit. pub fn shutdown() void { + @import("search_preview.zig").stop(); search_abort.store(true, .release); _ = search_generation.fetchAdd(1, .acq_rel); if (nova_may_exist.load(.acquire)) reapWorkers(); @@ -1401,6 +1404,11 @@ fn renderUniversalCapabilities() void { .{ .icon = icons.tvg.lucide.image, .name = "Comics" }, .{ .icon = icons.tvg.lucide.clapperboard, .name = "Stremio" }, .{ .icon = icons.tvg.lucide.rss, .name = "RSS" }, + .{ .icon = icons.tvg.lucide.book, .name = "Books & audiobooks" }, + .{ .icon = icons.tvg.lucide.headphones, .name = "Music & podcasts" }, + .{ .icon = icons.tvg.lucide.radio, .name = "Radio & live TV" }, + .{ .icon = icons.tvg.lucide.server, .name = "Plex & connected catalogs" }, + .{ .icon = icons.tvg.lucide.book, .name = "Visual novels" }, }; var flow = dvui.flexbox(@src(), .{ .justify_content = .center }, .{ .expand = .horizontal, .padding = .{ .x = 0, .y = theme.spacing.md, .w = 0, .h = 0 } }); defer flow.deinit(); @@ -1436,15 +1444,15 @@ fn renderSourceStatusCluster() void { .{ .icon = icons.tvg.lucide.magnet, .name = "Torrents", .bit = .torrent, .st = combinedTorrentStatus() }, .{ .icon = icons.tvg.lucide.server, .name = "Jellyfin", .bit = .jellyfin, .st = resolver.status_jf.load(.acquire) }, .{ .icon = icons.tvg.lucide.youtube, .name = "YouTube", .bit = .youtube, .st = resolver.status_yt.load(.acquire) }, - .{ .icon = icons.tvg.lucide.tv, .name = "Anime", .bit = .anime, .st = resolver.status_anime.load(.acquire) }, - .{ .icon = icons.tvg.lucide.image, .name = "Comics", .bit = .comics, .st = resolver.status_comics.load(.acquire) }, + .{ .icon = icons.tvg.lucide.tv, .name = "Anime", .bit = .anime, .st = resolver.combineSourceStatuses(resolver.status_anime.load(.acquire), resolver.status_anime_catalog.load(.acquire)) }, + .{ .icon = icons.tvg.lucide.image, .name = "Comics", .bit = .comics, .st = resolver.combineSourceStatuses(resolver.status_comics.load(.acquire), resolver.status_manga_catalog.load(.acquire)) }, .{ .icon = icons.tvg.lucide.clapperboard, .name = "Streams", .bit = .stremio, .st = combinedStreamStatus() }, .{ .icon = icons.tvg.lucide.rss, .name = "RSS", .bit = .rss, .st = resolver.status_rss.load(.acquire) }, .{ .icon = icons.tvg.lucide.tv, .name = "Live TV", .bit = .livetv, .st = resolver.status_livetv.load(.acquire) }, .{ .icon = icons.tvg.lucide.music, .name = "Music", .bit = .music, .st = resolver.status_music.load(.acquire) }, .{ .icon = icons.tvg.lucide.radio, .name = "Radio", .bit = .radio, .st = resolver.status_radio.load(.acquire) }, .{ .icon = icons.tvg.lucide.podcast, .name = "Podcasts", .bit = .podcast, .st = resolver.status_podcast.load(.acquire) }, - .{ .icon = icons.tvg.lucide.book, .name = "Novels", .bit = .novels, .st = resolver.combineSourceStatuses(resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire)) }, + .{ .icon = icons.tvg.lucide.book, .name = "Novels", .bit = .novels, .st = resolver.combineSourceStatuses(resolver.combineSourceStatuses(resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire)), resolver.status_public_books.load(.acquire)) }, .{ .icon = icons.tvg.lucide.book, .name = "Visual novels", .bit = .vndb, .st = resolver.status_vndb.load(.acquire) }, .{ .icon = icons.tvg.lucide.headphones, .name = "Audiobooks", .bit = .audiobooks, .st = resolver.status_audiobooks.load(.acquire) }, .{ .icon = icons.tvg.lucide.book, .name = "OPDS", .bit = .opds, .st = resolver.status_opds.load(.acquire) }, @@ -1609,9 +1617,17 @@ fn renderUniversalResults() void { const resolver = @import("resolver.zig"); // Facet changes from this frame update counts and rows together. if (view_dirty) refreshSearchView(); + if (view_cache.rows == null or view_cache.loaded == 0 or view_cache.count == 0) @import("search_preview.zig").stop(); + if (view_cache.rows != null) ensureContentProjection(); if (view_cache.query_len > 0 or view_cache.loaded > 0) { - var count_buf: [96]u8 = undefined; - const count = std.fmt.bufPrint(&count_buf, "{d} shown of {d} loaded{s}", .{ view_cache.count, view_cache.loaded, if (view_cache.loading) " · searching…" else "" }) catch "Results"; + var titles: usize = 0; + var releases: usize = 0; + for (content_projection.groups[0..content_projection.count]) |*group| { + if (!galleryGroupVisible(group)) continue; + if (group.category == .releases) releases += 1 else titles += 1; + } + var count_buf: [128]u8 = undefined; + const count = std.fmt.bufPrint(&count_buf, "{d} titles · {d} other releases{s}", .{ titles, releases, if (view_cache.loading) " · searching…" else "" }) catch "Results"; _ = dvui.label(@src(), "{s}", .{count}, .{ .color_text = theme.colors.text_secondary, .padding = dvui.Rect.all(theme.spacing.sm) }); } if (view_cache.rows == null) { @@ -1626,33 +1642,8 @@ fn renderUniversalResults() void { components.emptyState(icons.tvg.lucide.@"search-x", "No matches for these filters", "Remove a filter to see more of the loaded results."); return; } - if (view_dirty) refreshSearchView(); - var scroll = dvui.scrollArea(@src(), .{ .scroll_info = &result_scroll }, .{ .expand = .both, .background = true, .color_fill = theme.colors.bg_surface }); - defer scroll.deinit(); - var list = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); - defer list.deinit(); - var tab_pressed = false; - var pointer_pressed = false; - for (dvui.events()) |event| { - if (event.evt == .key and event.evt.key.code == .tab and event.evt.key.action == .down) tab_pressed = true; - if (event.evt == .mouse and event.evt.mouse.action == .press) pointer_pressed = true; - } - result_keyboard_layout = @import("queue_layout_pure.zig").keyboardLayoutMode(result_keyboard_layout, tab_pressed, pointer_pressed); - const row_height = dvui.themeGet().font_body.textHeight() * 4 + 14; - const range = if (result_keyboard_layout) search_view.Range{ .start = 0, .end = view_cache.count } else search_view.visibleRange(view_cache.count, result_scroll.viewport.y, result_scroll.viewport.h, row_height); - if (range.start > 0) { - var spacer = dvui.box(@src(), .{}, .{ .id_extra = 91400, .min_size_content = .{ .w = 1, .h = row_height * @as(f32, @floatFromInt(range.start)) } }); - spacer.deinit(); - } var pending: ?ResultAction = null; - for (range.start..range.end) |position| { - const idx = view_cache.order[position]; - renderCompactRow(idx, &view_cache.rows.?[idx], &pending); - } - if (range.end < view_cache.count) { - var spacer = dvui.box(@src(), .{}, .{ .id_extra = 91401, .min_size_content = .{ .w = 1, .h = row_height * @as(f32, @floatFromInt(view_cache.count - range.end)) } }); - spacer.deinit(); - } + renderContentGallery(&pending); if (pending) |action| { // The selected row is owned by this frame's immutable snapshot. const item = &view_cache.rows.?[action.idx]; @@ -1673,6 +1664,378 @@ fn renderUniversalResults() void { } } +const search_content = @import("search_content_pure.zig"); +const gallery_layout = @import("../ui/search_gallery_pure.zig"); +var content_projection: search_content.Projection = .{}; +var gallery_selection: u64 = 0; +var gallery_selected_manually = false; +var gallery_generation: u32 = 0; +var gallery_show_sources = false; +var gallery_release_limit: usize = 12; +const GalleryCover = struct { identity: u64 = 0, used: u64 = 0, slot: components.CoverSlot = .{} }; +// Stable addresses: poster workers publish into these slots. Never move or +// recycle a slot while its worker is fetching. +var gallery_covers: [196]GalleryCover = @splat(.{}); +var gallery_cover_clock: u64 = 0; +var gallery_backdrop: components.CoverSlot = .{}; + +fn galleryCover(identity: u64) ?*components.CoverSlot { + gallery_cover_clock +%= 1; + var oldest: ?usize = null; + for (&gallery_covers, 0..) |*entry, index| { + if (entry.identity == identity) { + entry.used = gallery_cover_clock; + return &entry.slot; + } + if (!entry.slot.fetching and (oldest == null or entry.used < gallery_covers[oldest.?].used)) oldest = index; + } + if (oldest) |index| { + const entry = &gallery_covers[index]; + entry.slot.reset(); + entry.identity = identity; + entry.used = gallery_cover_clock; + return &entry.slot; + } + return null; +} + +/// Called after the owned-worker barrier, while the graphics context exists. +pub fn deinitGallery() void { + for (&gallery_covers) |*entry| entry.slot.reset(); + gallery_backdrop.reset(); + @import("search_preview.zig").deinit(); +} + +pub fn updateGalleryRoute() void { + if (state.app.router.current != .search) @import("search_preview.zig").stop(); +} + +/// Native test harness only: immutable provider fixtures, no resolver network. +pub fn setGalleryFixtureForTest(rows: []const @import("resolver.zig").ResolvedItem, query: []const u8) void { + if (!@import("builtin").is_test) @compileError("gallery fixture API is test-only"); + const resolver = @import("resolver.zig"); + if (view_cache.rows == null) view_cache.rows = @import("../core/alloc.zig").allocator.alloc(resolver.ResolvedItem, resolver.MAX_RESULTS) catch return; + view_cache.loaded = @min(rows.len, resolver.MAX_RESULTS); + view_cache.count = view_cache.loaded; + @memcpy(view_cache.rows.?[0..view_cache.loaded], rows[0..view_cache.loaded]); + for (rows[0..view_cache.loaded], 0..) |*item, index| { + view_cache.projected[index] = resolver.searchView(item); + view_cache.order[index] = index; + } + view_cache.query_len = @min(query.len, view_cache.query.len); + @memcpy(view_cache.query[0..view_cache.query_len], query[0..view_cache.query_len]); + view_cache.loading = false; + view_cache.nsfw = false; + view_cache.revision +%= 1; + view_cache.generation +%= 1; + view_filters = .{}; + view_sort = .relevance; + view_dirty = false; + state.app.router.current = .search; + result_scroll.viewport.y = 0; +} +pub fn setGalleryTextureForTest(identity: u64, url: []const u8, texture: dvui.Texture, width: u32, height: u32) void { + if (!@import("builtin").is_test) @compileError("gallery texture API is test-only"); + const slot = galleryCover(identity) orelse return; + components.syncCoverSlot(slot, url); + slot.tex = texture; + slot.w = width; + slot.h = height; + slot.attempted = true; +} +pub fn setGalleryContentFilterForTest(filter: search_view.ContentKind) void { + if (!@import("builtin").is_test) @compileError("gallery filter API is test-only"); + view_filters.content = filter; + view_dirty = false; +} +pub fn renderGalleryForTest() void { + if (!@import("builtin").is_test) @compileError("gallery renderer API is test-only"); + renderUniversalResults(); +} + +fn galleryCategoryLabel(category: search_content.Category) []const u8 { + return switch (category) { + .movies => "Movies", + .shows => "Shows", + .anime => "Anime", + .comics => "Comics & manga", + .books => "Books & audiobooks", + .music => "Music", + .podcasts => "Podcasts", + .radio => "Radio", + .live_tv => "Live TV", + .visual_novels => "Visual novels", + .videos => "Videos", + .releases => "Other releases", + }; +} +fn galleryCategoryFilter(category: search_content.Category) search_view.ContentKind { + return switch (category) { + .movies => .movies, + .shows => .shows, + .anime => .anime, + .comics => .comics, + .books => .books, + .music => .music, + .podcasts => .podcasts, + .radio => .radio, + .live_tv => .live_tv, + .visual_novels => .visual_novels, + .videos, .releases => .video, + }; +} +fn galleryShape(category: search_content.Category) gallery_layout.Shape { + return switch (category) { + .movies, .shows, .anime, .comics, .books, .visual_novels => .portrait, + .music, .podcasts, .radio => .square, + .videos, .live_tv, .releases => .landscape, + }; +} +fn galleryIcon(category: search_content.Category) []const u8 { + return switch (category) { + .books, .comics, .visual_novels => icons.tvg.lucide.book, + .music, .podcasts => icons.tvg.lucide.headphones, + .radio => icons.tvg.lucide.radio, + .releases => icons.tvg.lucide.magnet, + else => icons.tvg.lucide.film, + }; +} +fn galleryGroupVisible(group: *const search_content.Group) bool { + for (group.indexes[0..group.count]) |index| if (search_view.matches(view_cache.projected[index], view_filters)) return true; + return false; +} + +fn ensureContentProjection() void { + const rows = view_cache.rows.?[0..view_cache.loaded]; + var indexes: [@import("resolver.zig").MAX_RESULTS]usize = undefined; + var count: usize = 0; + for (rows, 0..) |item, index| { + if (item.name_len == 0 or (view_cache.nsfw and item.is_nsfw)) continue; + indexes[count] = index; + count += 1; + } + // Projection is rebuilt only when the immutable row revision/filter changes. + const ProjectionCache = struct { + var revision: u64 = std.math.maxInt(u64); + var generation: u32 = 0; + var nsfw = false; + }; + if (ProjectionCache.revision != view_cache.revision or ProjectionCache.generation != view_cache.generation or ProjectionCache.nsfw != view_cache.nsfw) { + search_content.projectInto(rows, indexes[0..count], &content_projection); + ProjectionCache.revision = view_cache.revision; + ProjectionCache.generation = view_cache.generation; + ProjectionCache.nsfw = view_cache.nsfw; + } +} + +fn renderContentGallery(pending: *?ResultAction) void { + const rows = view_cache.rows.?[0..view_cache.loaded]; + ensureContentProjection(); + if (gallery_generation != view_cache.generation) { + gallery_selection = 0; + gallery_selected_manually = false; + gallery_show_sources = false; + gallery_release_limit = 12; + gallery_generation = view_cache.generation; + @import("search_preview.zig").stop(); + } + var visible: [search_content.MAX_ROWS]usize = undefined; + var identities: [search_content.MAX_ROWS]u64 = undefined; + var visible_count: usize = 0; + for (content_projection.groups[0..content_projection.count], 0..) |*group, index| { + if (!galleryGroupVisible(group)) continue; + visible[visible_count] = index; + identities[visible_count] = group.identity; + visible_count += 1; + } + std.sort.insertion(usize, visible[0..visible_count], {}, struct { + fn less(_: void, a: usize, b: usize) bool { + return search_view.lessThan(view_sort, view_cache.projected[content_projection.groups[a].representative], view_cache.projected[content_projection.groups[b].representative]); + } + }.less); + for (visible[0..visible_count], 0..) |index, position| identities[position] = content_projection.groups[index].identity; + const selected_position = gallery_layout.retainSelection(identities[0..visible_count], gallery_selection) orelse { + @import("search_preview.zig").stop(); + return; + }; + const selected = &content_projection.groups[visible[selected_position]]; + gallery_selection = selected.identity; + var scroll = dvui.scrollArea(@src(), .{ .scroll_info = &result_scroll }, .{ .expand = .both, .background = true, .color_fill = theme.colors.bg_app }); + defer scroll.deinit(); + var content = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .padding = dvui.Rect.all(theme.spacing.md) }); + defer content.deinit(); + const width = content.data().contentRect().w; + // A raw release has no dependable feature artwork. It stays in the compact + // source comparison until a matching catalog or library record arrives. + const featured_item = &rows[selected.representative]; + if (selected.category != .releases and gallery_layout.shouldFeature(view_cache.query[0..view_cache.query_len], featured_item.name[0..featured_item.name_len], featured_item.poster_url_len > 0 or featured_item.backdrop_url_len > 0, gallery_selected_manually)) renderGalleryHero(selected, width, pending) else @import("search_preview.zig").stop(); + if (gallery_show_sources) { + renderResultGroupHeading("Available sources", 94500); + var source_indexes: [search_content.MAX_ROWS]u16 = undefined; + @memcpy(source_indexes[0..selected.count], selected.indexes[0..selected.count]); + std.sort.insertion(u16, source_indexes[0..selected.count], {}, struct { + fn less(_: void, a: u16, b: u16) bool { + return search_view.lessThan(view_sort, view_cache.projected[a], view_cache.projected[b]); + } + }.less); + for (source_indexes[0..selected.count]) |index| { + if (rows[index].source == .tmdb and selected.count > 1) continue; + renderCompactRow(index, &rows[index], pending); + } + } + inline for (std.meta.tags(search_content.Category)) |category| { + var category_count: usize = 0; + for (visible[0..visible_count]) |index| if (content_projection.groups[index].category == category) { + category_count += 1; + }; + if (category_count > 0) { + var heading = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = 94600 + @as(usize, @intFromEnum(category)), .expand = .horizontal, .padding = .{ .x = 0, .y = 16, .w = 0, .h = 8 } }); + _ = dvui.label(@src(), "{s}", .{galleryCategoryLabel(category)}, .{ .font = dvui.themeGet().font_heading, .color_text = theme.colors.text_primary, .gravity_y = 0.5 }); + var tally: [32]u8 = undefined; + const tally_text = std.fmt.bufPrint(&tally, "{d}", .{category_count}) catch ""; + _ = dvui.label(@src(), "{s}", .{tally_text}, .{ .color_text = theme.colors.text_tertiary, .gravity_y = 0.5, .margin = dvui.Rect.all(6) }); + var spacer = dvui.box(@src(), .{}, .{ .expand = .horizontal }); + spacer.deinit(); + if (category != .releases and view_filters.content == .all and components.actionButton(@src(), "See all", .secondary, 94650 + @as(usize, @intFromEnum(category)))) { + view_filters.content = galleryCategoryFilter(category); + view_dirty = true; + result_scroll.viewport.y = 0; + } + heading.deinit(); + if (category == .releases) { + var shown: usize = 0; + for (visible[0..visible_count]) |index| { + const group = &content_projection.groups[index]; + if (group.category != category or shown >= gallery_release_limit) continue; + renderCompactRow(group.representative, &rows[group.representative], pending); + shown += 1; + } + if (shown < category_count and components.actionButton(@src(), "Show more releases", .secondary, 94700)) gallery_release_limit += 24; + } else { + const art = gallery_layout.cardSize(galleryShape(category), width); + if (view_filters.content != .all) { + var grid = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .id_extra = 94950 + @as(usize, @intFromEnum(category)), .expand = .horizontal }); + for (visible[0..visible_count]) |index| { + const group = &content_projection.groups[index]; + if (group.category == category) renderGalleryCard(group, if (rows[group.representative].source == .audiobooks) gallery_layout.cardSize(.square, width) else art); + } + grid.deinit(); + } else { + const rail_height = gallery_layout.shelfHeight(art.h, dvui.themeGet().font_body.textHeight()); + var rail = dvui.scrollArea(@src(), .{ .horizontal = .auto, .horizontal_bar = .auto_overlay, .vertical = .none }, .{ .id_extra = 94800 + @as(usize, @intFromEnum(category)), .expand = .horizontal, .min_size_content = .{ .w = 1, .h = rail_height }, .max_size_content = .{ .w = std.math.floatMax(f32), .h = rail_height }, .background = false, .color_fill = theme.transparent }); + var strip = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = 94900 + @as(usize, @intFromEnum(category)) }); + for (visible[0..visible_count]) |index| { + const group = &content_projection.groups[index]; + if (group.category == category) renderGalleryCard(group, if (rows[group.representative].source == .audiobooks) gallery_layout.cardSize(.square, width) else art); + } + strip.deinit(); + rail.deinit(); + } + } + } + } +} + +fn renderGalleryCard(group: *const search_content.Group, size: gallery_layout.Size) void { + const item = &view_cache.rows.?[group.representative]; + const key: usize = @truncate(group.identity); + var card = dvui.box(@src(), .{ .dir = .vertical }, .{ .id_extra = key, .min_size_content = .{ .w = size.w, .h = 0 }, .max_size_content = .{ .w = size.w, .h = std.math.floatMax(f32) }, .margin = .{ .x = 0, .y = 0, .w = 12, .h = if (view_filters.content == .all) 0 else 16 }, .background = false, .color_fill = theme.transparent }); + defer card.deinit(); + var art = dvui.overlay(@src(), .{ .id_extra = key +% 1, .min_size_content = .{ .w = size.w, .h = size.h }, .max_size_content = .{ .w = size.w, .h = size.h }, .background = true, .color_fill = theme.colors.bg_surface, .corner_radius = dvui.Rect.all(theme.radius.md), .border = dvui.Rect.all(if (gallery_selection == group.identity) 2 else 0), .color_border = theme.colors.accent }); + if (art.data().visible()) { + if (galleryCover(group.identity)) |slot| components.galleryCoverArt(@src(), key +% 2, slot, item.poster_url[0..item.poster_url_len], galleryIcon(group.category), theme.radius.md, size.w, size.h); + } + var hovered = false; + if (dvui.clicked(art.data(), .{ .hovered = &hovered })) { + gallery_selection = group.identity; + gallery_selected_manually = true; + gallery_show_sources = false; + result_scroll.viewport.y = 0; + } + art.deinit(); + const body = theme.mediaTitleFont(item.name[0..item.name_len], dvui.themeGet().font_body); + var title: [520]u8 = undefined; + _ = dvui.label(@src(), "{s}", .{searchRowTitle(item.name[0..item.name_len], body, size.w, &title)}, .{ .id_extra = key +% 3, .font = body, .color_text = theme.colors.text_primary, .min_size_content = .{ .w = size.w, .h = body.textHeight() * 2 }, .max_size_content = .{ .w = size.w, .h = body.textHeight() * 2 }, .margin = .{ .x = 0, .y = 7, .w = 0, .h = 0 } }); + var metadata: [128]u8 = undefined; + const line = if (group.offer_count > 0) std.fmt.bufPrint(&metadata, "{d} source{s}", .{ group.offer_count, if (group.offer_count == 1) "" else "s" }) catch "" else if (item.author_len > 0) item.author[0..item.author_len] else item.detail[0..item.detail_len]; + searchRowLine(key +% 4, safeUtf8(line), body.withSize(theme.font_size.small), size.w, theme.colors.text_secondary); + if (components.actionButton(@src(), "Details", .secondary, key +% 5)) { + gallery_selection = group.identity; + gallery_selected_manually = true; + gallery_show_sources = false; + result_scroll.viewport.y = 0; + } + components.tipId(@src(), card.data().*, safeUtf8(item.name[0..item.name_len]), key); + renderSearchResultContext(key, card.data().borderRectScale().r, item); +} + +fn renderGalleryHero(group: *const search_content.Group, width: f32, pending: *?ResultAction) void { + const item = &view_cache.rows.?[group.representative]; + const key: usize = @truncate(group.identity); + var hero = dvui.box(@src(), .{ .dir = if (gallery_layout.stackedHero(width)) .vertical else .horizontal }, .{ .id_extra = 95000, .expand = .horizontal, .background = true, .color_fill = theme.colors.bg_surface, .corner_radius = dvui.Rect.all(theme.radius.lg), .padding = dvui.Rect.all(12), .margin = .{ .x = 0, .y = 0, .w = 0, .h = 12 } }); + defer hero.deinit(); + const preview = @import("search_preview.zig"); + if (item.source == .tmdb and item.catalog_id > 0) { + preview.select(group.identity, if (std.mem.eql(u8, item.catalog_kind[0..item.catalog_kind_len], "tv")) .tv else .movie, item.catalog_id); + } else preview.stop(); + preview.poll(); + const preview_state = preview.snapshot(); + const has_preview_backdrop = preview_state.identity == group.identity and preview_state.metadata.backdrop_url_len > 0; + const has_backdrop = item.backdrop_url_len > 0 or has_preview_backdrop; + const preview_active = preview_state.status == .playing or preview_state.status == .starting; + const art_size = gallery_layout.heroArtSize(width, has_backdrop or preview_active); + var art = dvui.overlay(@src(), .{ .id_extra = key +% 8, .min_size_content = .{ .w = art_size.w, .h = art_size.h }, .max_size_content = .{ .w = art_size.w, .h = art_size.h }, .color_fill = theme.colors.bg_elevated, .background = true, .corner_radius = dvui.Rect.all(theme.radius.md), .margin = .{ .x = 0, .y = 0, .w = 20, .h = 0 } }); + if (preview_active) { + if (art.data().visible()) preview.render(@src(), key +% 9, art_size.w, art_size.h); + } else if (has_backdrop) { + const backdrop = if (item.backdrop_url_len > 0) item.backdrop_url[0..item.backdrop_url_len] else preview_state.metadata.backdrop_url[0..preview_state.metadata.backdrop_url_len]; + components.galleryCoverArt(@src(), key +% 9, &gallery_backdrop, backdrop, galleryIcon(group.category), theme.radius.md, art_size.w, art_size.h); + } else if (galleryCover(group.identity)) |slot| components.galleryCoverArt(@src(), key +% 9, slot, item.poster_url[0..item.poster_url_len], galleryIcon(group.category), theme.radius.md, art_size.w, art_size.h); + art.deinit(); + var copy = dvui.box(@src(), .{ .dir = .vertical }, .{ .id_extra = 95001, .expand = .horizontal, .gravity_y = gallery_layout.heroCopyGravity(width), .padding = dvui.Rect.all(4) }); + defer copy.deinit(); + _ = dvui.label(@src(), "{s}", .{galleryCategoryLabel(group.category)}, .{ .color_text = theme.colors.accent, .font = dvui.themeGet().font_body.withSize(theme.font_size.small) }); + const copy_width = @max(1, copy.data().contentRect().w); + const heading = theme.mediaTitleFont(item.name[0..item.name_len], dvui.themeGet().font_title.withSize(if (width < 760) 26 else 34)); + var title: [520]u8 = undefined; + _ = dvui.label(@src(), "{s}", .{searchRowTitle(item.name[0..item.name_len], heading, copy_width, &title)}, .{ .id_extra = 95002, .color_text = theme.colors.text_primary, .font = heading, .max_size_content = .{ .w = copy_width, .h = heading.textHeight() * 2 }, .margin = .{ .x = 0, .y = 4, .w = 0, .h = 8 } }); + searchRowLine(95003, safeUtf8(item.detail[0..item.detail_len]), dvui.themeGet().font_body.withSize(theme.font_size.small), copy_width, theme.colors.text_secondary); + if (item.summary_len > 0) { + var summary = dvui.textLayout(@src(), .{ .break_lines = true }, .{ .id_extra = 95004, .expand = .horizontal, .background = false, .color_fill = theme.transparent, .max_size_content = .{ .w = copy_width, .h = dvui.themeGet().font_body.textHeight() * 4 }, .margin = .{ .x = 0, .y = 10, .w = 0, .h = 12 } }); + summary.addText(safeUtf8(item.summary[0..item.summary_len]), .{ .color_text = theme.colors.text_secondary }); + summary.deinit(); + } + var actions = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .id_extra = 95005, .expand = .horizontal }); + const television = item.source == .tmdb and std.mem.eql(u8, item.catalog_kind[0..item.catalog_kind_len], "tv"); + if (item.source != .tmdb or television) { + if (components.actionButton(@src(), if (television) "Episodes" else "Open", .primary, 95006)) pending.* = .{ .idx = group.representative }; + } + if (group.offer_count > 0) { + var source_text: [48]u8 = undefined; + const text = std.fmt.bufPrint(&source_text, "Sources ({d})", .{group.offer_count}) catch "Sources"; + if (components.actionButton(@src(), text, .secondary, 95007)) gallery_show_sources = !gallery_show_sources; + } + const store = @import("library_store.zig"); + var identity: [32]u8 = undefined; + const identity_text = std.fmt.bufPrint(&identity, "{x}", .{group.identity}) catch ""; + const saved = store.isFavorite("search", identity_text); + if (components.actionButton(@src(), if (saved) "Saved" else "Save", .secondary, 95008)) { + var saved_link: [288]u8 = undefined; + const reopen = std.fmt.bufPrint(&saved_link, "opal://search/{s}", .{item.name[0..item.name_len]}) catch ""; + store.setFavorite("search", identity_text, !saved, item.name[0..item.name_len], item.poster_url[0..item.poster_url_len], reopen); + state.showToast(if (saved) "Removed from saved content" else "Saved to your library"); + } + if (preview_state.canStart()) { + if (components.actionButton(@src(), "Preview", .secondary, 95009)) _ = preview.start(); + } + if (preview_active) { + if (components.actionButton(@src(), if (preview_state.muted) "Unmute" else "Mute", .secondary, 95010)) preview.setMuted(!preview_state.muted); + if (components.actionButton(@src(), "Close preview", .secondary, 95011)) preview.closePlayback(); + } + actions.deinit(); + if (item.source == .tmdb and preview_state.status != .idle) searchRowLine(95012, preview_state.label(), dvui.themeGet().font_body.withSize(theme.font_size.small), copy_width, theme.colors.text_tertiary); +} + fn showResult(item: *const @import("resolver.zig").ResolvedItem) bool { const resolver = @import("resolver.zig"); if (item.name_len == 0 or (state.app.nsfw_filter_enabled and item.is_nsfw)) return false; @@ -1741,9 +2104,9 @@ fn renderSourceSummary(source_has: std.EnumSet(@import("resolver.zig").SourceBit const entries = [_]Entry{ .{ .name = "Torrents", .src = .torrent, .rss = false, .st = combinedTorrentStatus(), .bit = .torrent }, .{ .name = "Jellyfin", .src = .jellyfin, .rss = false, .st = resolver.status_jf.load(.acquire), .bit = .jellyfin }, - .{ .name = "Anime", .src = .anime, .rss = false, .st = resolver.status_anime.load(.acquire), .bit = .anime }, + .{ .name = "Anime", .src = .anime, .rss = false, .st = resolver.combineSourceStatuses(resolver.status_anime.load(.acquire), resolver.status_anime_catalog.load(.acquire)), .bit = .anime }, .{ .name = "YouTube", .src = .youtube, .rss = false, .st = resolver.status_yt.load(.acquire), .bit = .youtube }, - .{ .name = "Comics", .src = .comics, .rss = false, .st = resolver.status_comics.load(.acquire), .bit = .comics }, + .{ .name = "Comics", .src = .comics, .rss = false, .st = resolver.combineSourceStatuses(resolver.status_comics.load(.acquire), resolver.status_manga_catalog.load(.acquire)), .bit = .comics }, .{ .name = "Streams", .src = .stremio, .rss = false, .st = combinedStreamStatus(), .bit = .stremio }, .{ .name = "RSS", .src = .torrent, .rss = true, .st = resolver.status_rss.load(.acquire), .bit = .rss }, .{ .name = "On-disk", .src = .local, .rss = false, .st = resolver.status_local.load(.acquire), .bit = .local }, @@ -1751,7 +2114,7 @@ fn renderSourceSummary(source_has: std.EnumSet(@import("resolver.zig").SourceBit .{ .name = "Music", .src = .music, .rss = false, .st = resolver.status_music.load(.acquire), .bit = .music }, .{ .name = "Radio", .src = .radio, .rss = false, .st = resolver.status_radio.load(.acquire), .bit = .radio }, .{ .name = "Podcasts", .src = .podcast, .rss = false, .st = resolver.status_podcast.load(.acquire), .bit = .podcast }, - .{ .name = "Novels", .src = .novels, .rss = false, .st = resolver.combineSourceStatuses(resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire)), .bit = .novels }, + .{ .name = "Novels", .src = .novels, .rss = false, .st = resolver.combineSourceStatuses(resolver.combineSourceStatuses(resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire)), resolver.status_public_books.load(.acquire)), .bit = .novels }, .{ .name = "Visual novels", .src = .vndb, .rss = false, .st = resolver.status_vndb.load(.acquire), .bit = .vndb }, .{ .name = "Audiobooks", .src = .audiobooks, .rss = false, .st = resolver.status_audiobooks.load(.acquire), .bit = .audiobooks }, .{ .name = "OPDS", .src = .opds, .rss = false, .st = resolver.status_opds.load(.acquire), .bit = .opds }, @@ -1931,9 +2294,9 @@ fn renderCompactRow(idx: usize, item: *const @import("resolver.zig").ResolvedIte .audiobooks => "Audiobook", .opds => "OPDS", }; - const body = dvui.themeGet().font_body; + const body = theme.mediaTitleFont(item.name[0..item.name_len], dvui.themeGet().font_body); const small = body.withSize(theme.font_size.small); - const row_height = body.textHeight() * 4 + 14; + const row_height = body.textHeight() * 3 + 14 + (if (risk.risk != .ok) small.textHeight() else @as(f32, 0)); var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = row_key, .expand = .horizontal, @@ -1965,7 +2328,7 @@ fn renderCompactRow(idx: usize, item: *const @import("resolver.zig").ResolvedIte else std.fmt.bufPrint(&line, "{s}{s}{s}{s}{s}", .{ chip_text, if (meta.len > 0) " · " else "", meta, if (item.detail_len > 0) " · " else "", safeUtf8(item.detail[0..item.detail_len]) }) catch chip_text; searchRowLine(row_key +% 2, metadata, small, text_width, theme.colors.text_secondary); - if (risk.risk != .ok) searchRowLine(row_key +% 3, if (risk.risk == .block) "Scam? · blocked torrent" else "Caution · check torrent details", small, text_width, if (risk.risk == .block) theme.colors.danger else theme.colors.warning) else if (item.summary_len > 0) searchRowLine(row_key +% 3, safeUtf8(item.summary[0..item.summary_len]), small, text_width, theme.colors.text_tertiary); + if (risk.risk != .ok) searchRowLine(row_key +% 3, if (risk.risk == .block) "Scam? · blocked torrent" else "Caution · check torrent details", small, text_width, if (risk.risk == .block) theme.colors.danger else theme.colors.warning); content.deinit(); var actions = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5 }); defer actions.deinit(); diff --git a/src/services/search_content_pure.zig b/src/services/search_content_pure.zig new file mode 100644 index 00000000..cbe30eaa --- /dev/null +++ b/src/services/search_content_pure.zig @@ -0,0 +1,452 @@ +//! Bounded content identities projected from immutable search rows. No UI or I/O. +const std = @import("std"); +pub const MAX_ROWS: usize = 192; +/// Opaque bounded cache scope includes installed endpoint/credential identity. +/// Hashing the query avoids long-prefix overflow and exposes no source URLs. +pub fn cacheIdentity(out: []u8, query: []const u8, mask: u16, fingerprint: u64) ?[]const u8 { + return std.fmt.bufPrint(out, "search:v10:{x}:{x}:{x}", .{ mask, fingerprint, std.hash.Wyhash.hash(0, query) }) catch null; +} +pub const CacheItem = struct { source: []const u8, provider: []const u8 = "", url: []const u8 = "" }; +/// Account-bound rows are fetched afresh each wave. Public metadata/cache art +/// remain useful without replaying another account's opaque item or stream ID. +pub fn cacheEligible(item: CacheItem) bool { + inline for (.{ "jellyfin", "plex", "opds", "audiobooks", "plugin", "local" }) |private| { + if (std.mem.eql(u8, item.source, private)) return false; + } + if (std.mem.eql(u8, item.source, "music") and + !std.mem.eql(u8, item.provider, "jiosaavn") and !std.mem.eql(u8, item.provider, "audius")) return false; + if (std.mem.eql(u8, item.source, "stremio") and + !std.mem.eql(u8, item.provider, "archive") and !std.mem.eql(u8, item.provider, "nasa") and !std.mem.eql(u8, item.provider, "commons")) return false; + // Deep links can contain an embedded HTTP URL. Conservatively avoid user + // authority text even there; the original row stays in the live result set. + if (std.mem.indexOf(u8, item.url, "://")) |scheme| { + const start = scheme + 3; + const end = std.mem.indexOfAnyPos(u8, item.url, start, "/?#|") orelse item.url.len; + if (std.mem.indexOfScalar(u8, item.url[start..end], '@') != null) return false; + } + return true; +} +pub const Category = enum { + movies, + shows, + anime, + comics, + books, + music, + podcasts, + radio, + live_tv, + visual_novels, + videos, + releases, + pub fn label(self: Category) []const u8 { + return switch (self) { + .movies => "Movies", + .shows => "Shows", + .anime => "Anime", + .comics => "Comics", + .books => "Books", + .music => "Music", + .podcasts => "Podcasts", + .radio => "Radio", + .live_tv => "Live TV", + .visual_novels => "Visual novels", + .videos => "Videos", + .releases => "Other releases", + }; + } +}; +pub const Group = struct { + representative: usize = 0, + indexes: [MAX_ROWS]u16 = undefined, + count: usize = 0, + offer_count: usize = 0, + category: Category = .videos, + identity: u64 = 0, + score: u32 = 9999, +}; +pub const Projection = struct { groups: [MAX_ROWS]Group = undefined, count: usize = 0 }; +pub fn category(row: anytype) Category { + const source = @tagName(row.source); + const kind = row.catalog_kind[0..row.catalog_kind_len]; + if (std.mem.eql(u8, source, "torrent")) return .releases; + if (std.mem.eql(u8, source, "anime")) return .anime; + if (std.mem.eql(u8, source, "comics")) return .comics; + if (std.mem.eql(u8, source, "novels") or std.mem.eql(u8, source, "opds") or std.mem.eql(u8, source, "audiobooks")) return .books; + if (std.mem.eql(u8, source, "music")) return .music; + if (std.mem.eql(u8, source, "podcast")) return .podcasts; + if (std.mem.eql(u8, source, "radio")) return .radio; + if (std.mem.eql(u8, source, "livetv")) return .live_tv; + if (std.mem.eql(u8, source, "vndb")) return .visual_novels; + if (std.mem.eql(u8, kind, "movie")) return .movies; + if (std.mem.eql(u8, kind, "tv") or std.mem.eql(u8, kind, "series") or std.mem.eql(u8, kind, "episode")) return .shows; + return .videos; +} +pub fn isRelease(row: anytype) bool { + return std.mem.eql(u8, @tagName(row.source), "torrent"); +} +fn title(row: anytype) []const u8 { + return row.name[0..row.name_len]; +} +fn normalized(text: []const u8, out: []u8) []const u8 { + var n: usize = 0; + var space = false; + for (text) |c| { + if (std.ascii.isAlphanumeric(c) or c >= 128) { + if (space and n > 0 and n < out.len) { + out[n] = ' '; + n += 1; + } + if (n == out.len) break; + out[n] = std.ascii.toLower(c); + n += 1; + space = false; + } else space = true; + } + return out[0..n]; +} +/// Only a title immediately followed by a positive year is eligible. No fuzzy +/// prefix/substring matches, no inference from quality digits or episode names. +pub fn releaseMatches(work_title: []const u8, work_year: u16, release_title: []const u8) bool { + if (work_year < 1800 or work_year > 2199) return false; + var i: usize = 0; + while (i + 4 <= release_title.len) : (i += 1) { + if (i > 0 and std.ascii.isAlphanumeric(release_title[i - 1])) continue; + const year = std.fmt.parseInt(u16, release_title[i..][0..4], 10) catch continue; + if (year != work_year or (i + 4 < release_title.len and std.ascii.isDigit(release_title[i + 4]))) continue; + var a: [256]u8 = undefined; + var b: [256]u8 = undefined; + return std.mem.eql(u8, normalized(work_title, &a), normalized(release_title[0..i], &b)); + } + return false; +} +/// Explicit season tokens establish a show release, never a movie. The caller +/// still requires exactly one matching typed show across all visible catalogs. +pub fn episodeMatches(work_title: []const u8, release_title: []const u8) bool { + var i: usize = 1; + while (i + 3 <= release_title.len) : (i += 1) { + if (std.ascii.toLower(release_title[i]) != 's' or std.ascii.isAlphanumeric(release_title[i - 1])) continue; + if (!std.ascii.isDigit(release_title[i + 1]) or !std.ascii.isDigit(release_title[i + 2])) continue; + const end = i + 3; + if (end < release_title.len and std.ascii.isAlphanumeric(release_title[end])) { + if (std.ascii.toLower(release_title[end]) != 'e' or end + 2 >= release_title.len or !std.ascii.isDigit(release_title[end + 1]) or !std.ascii.isDigit(release_title[end + 2])) continue; + } + var a: [256]u8 = undefined; + var b: [256]u8 = undefined; + return std.mem.eql(u8, normalized(work_title, &a), normalized(release_title[0..i], &b)); + } + return false; +} +fn imdb(row: anytype) []const u8 { + return row.catalog_imdb[0..row.catalog_imdb_len]; +} +fn validImdb(text: []const u8) bool { + if (text.len < 3 or !std.mem.startsWith(u8, text, "tt")) return false; + for (text[2..]) |c| if (!std.ascii.isDigit(c)) return false; + return true; +} +pub fn identity(row: anytype) u64 { + var h = std.hash.Wyhash.init(0); + const typed_imdb = imdb(&row); + if (validImdb(typed_imdb) and row.catalog_kind_len > 0) { + h.update("imdb"); + h.update(row.catalog_kind[0..row.catalog_kind_len]); + h.update(typed_imdb); + const key = h.final(); + return if (key == 0) 1 else key; + } + h.update(@tagName(row.source)); + h.update(row.catalog_kind[0..row.catalog_kind_len]); + if (row.catalog_id > 0) { + h.update(std.mem.asBytes(&row.catalog_id)); + } else { + h.update(row.url[0..row.url_len]); + } + const value = h.final(); + return if (value == 0) 1 else value; +} +fn sameWork(a: anytype, b: anytype) bool { + const same_kind = a.catalog_kind_len > 0 and std.mem.eql(u8, a.catalog_kind[0..a.catalog_kind_len], b.catalog_kind[0..b.catalog_kind_len]); + return same_kind and ((a.catalog_id > 0 and b.catalog_id == a.catalog_id and a.source == b.source) or (validImdb(imdb(&a)) and std.mem.eql(u8, imdb(&a), imdb(&b)))); +} +fn isOffer(row: anytype) bool { + const source = @tagName(row.source); + inline for (.{ "tmdb", "vndb" }) |catalog| if (std.mem.eql(u8, source, catalog)) return false; + if (std.mem.eql(u8, source, "anime")) { + if (@hasField(@TypeOf(row), "provider")) { + const provider = row.provider.name(); + if (std.mem.eql(u8, provider, "jikan") or std.mem.eql(u8, provider, "anilist")) return false; + } + } + if (@hasField(@TypeOf(row), "opds_entry")) { + if (std.mem.eql(u8, source, "opds") and row.opds_entry.is_navigation) return false; + } + return row.url_len > 0; +} +fn hasSameOffer(rows: anytype, group: *const Group, row: anytype) bool { + for (group.indexes[0..group.count]) |index| { + const previous = rows[index]; + if (!isOffer(previous) or previous.source != row.source) continue; + if (std.mem.eql(u8, previous.url[0..previous.url_len], row.url[0..row.url_len])) return true; + } + return false; +} +fn append(group: *Group, index: usize, offer: bool) void { + if (group.count >= MAX_ROWS) return; + group.indexes[group.count] = @intCast(index); + group.count += 1; + if (offer) group.offer_count += 1; +} +/// Caller owns the output (static or heap); indexes refer to the original rows, +/// including after faceting. Ambiguous matches stay independent and actionable. +pub fn projectInto(rows: anytype, indices: []const usize, out: *Projection) void { + out.count = 0; + for (indices) |index| { + if (index >= rows.len or index >= MAX_ROWS or isRelease(rows[index])) continue; + var found: ?usize = null; + for (out.groups[0..out.count], 0..) |group, g| if (sameWork(rows[group.representative], rows[index]) or identity(rows[group.representative]) == identity(rows[index])) { + found = g; + break; + }; + if (found) |g| { + append(&out.groups[g], index, isOffer(rows[index]) and !hasSameOffer(rows, &out.groups[g], rows[index])); + if (std.mem.eql(u8, @tagName(rows[index].source), "tmdb")) { + out.groups[g].representative = index; + out.groups[g].category = category(rows[index]); + } + continue; + } + if (out.count == MAX_ROWS) break; + out.groups[out.count] = .{ .representative = index, .category = category(rows[index]), .identity = identity(rows[index]), .score = 100 - @as(u32, rows[index].match_pct) }; + append(&out.groups[out.count], index, isOffer(rows[index])); + out.count += 1; + } + for (indices) |index| { + if (index >= rows.len or index >= MAX_ROWS or !isRelease(rows[index])) continue; + var match: ?usize = null; + var matches: usize = 0; + for (out.groups[0..out.count], 0..) |group, g| { + const work = rows[group.representative]; + if ((group.category == .movies or group.category == .shows) and (releaseMatches(title(&work), work.year, rows[index].name[0..rows[index].name_len]) or (group.category == .shows and episodeMatches(title(&work), rows[index].name[0..rows[index].name_len])))) { + match = g; + matches += 1; + } + } + // Hidden facets cannot turn a known remake ambiguity into a verified + // association. Evaluate candidate identities against the entire snapshot. + if (matches == 1) { + const linked = rows[out.groups[match.?].representative]; + for (rows) |other| { + if (isRelease(other) or identity(other) == identity(linked)) continue; + const other_cat = category(other); + if ((other_cat == .movies or other_cat == .shows) and + (releaseMatches(other.name[0..other.name_len], other.year, rows[index].name[0..rows[index].name_len]) or + (other_cat == .shows and episodeMatches(other.name[0..other.name_len], rows[index].name[0..rows[index].name_len])))) + { + matches += 1; + break; + } + } + } + if (matches == 1) { + append(&out.groups[match.?], index, true); + continue; + } + if (out.count == MAX_ROWS) break; + out.groups[out.count] = .{ .representative = index, .category = .releases, .identity = identity(rows[index]), .score = 200 + 100 - @as(u32, rows[index].match_pct) }; + append(&out.groups[out.count], index, true); + out.count += 1; + } + std.sort.insertion(Group, out.groups[0..out.count], {}, struct { + fn less(_: void, a: Group, b: Group) bool { + return a.score < b.score or (a.score == b.score and a.identity < b.identity); + } + }.less); +} +/// Fair bounded admission reserves eight rows per represented category. A late +/// category can reclaim an overrepresented category's worst row regardless of +/// swarm score. Within a category relevance still selects its strongest rows. +pub fn evictionIndex(rows: anytype, candidate: anytype) ?usize { + if (rows.len == 0) return null; + var counts: [@typeInfo(Category).@"enum".fields.len]usize = @splat(0); + for (rows) |row| counts[@intFromEnum(category(row))] += 1; + const cat = category(candidate); + const under = counts[@intFromEnum(cat)] < 8; + var selected: ?usize = null; + for (rows, 0..) |row, i| { + const row_cat = category(row); + if (under) { + if (counts[@intFromEnum(row_cat)] <= 8) continue; + } else if (row_cat != cat) continue; + if (selected == null or row.score > rows[selected.?].score) selected = i; + } + if (selected) |i| { + if (under or candidate.score < rows[i].score or (candidate.score == rows[i].score and identity(candidate) < identity(rows[i]))) return i; + } + return null; +} +test "strict work release linking preserves title year and rejects ambiguity" { + try std.testing.expect(releaseMatches("Reacher", 2022, "Reacher.2022.S01.1080p")); + try std.testing.expect(!releaseMatches("Reacher", 2022, "Jack.Reacher.2022.1080p")); + try std.testing.expect(!releaseMatches("Reacher", 2022, "Reacher.S01.1080p")); + try std.testing.expect(!releaseMatches("Reacher", 2022, "Reacher.2012.1080p")); + try std.testing.expect(!releaseMatches("Reacher", 0, "Reacher.2022")); +} +const Fixture = struct { + source: enum { tmdb, torrent, music } = .tmdb, + catalog_kind: [8]u8 = @splat(0), + catalog_kind_len: usize = 0, + catalog_imdb: [16]u8 = @splat(0), + catalog_imdb_len: usize = 0, + catalog_id: i32 = 0, + year: u16 = 0, + name: [256]u8 = @splat(0), + name_len: usize = 0, + url: [64]u8 = @splat(0), + url_len: usize = 0, + score: u32 = 0, + match_pct: u8 = 100, +}; +fn fixture(source: @FieldType(Fixture, "source"), name: []const u8, year: u16, id: i32) Fixture { + var row: Fixture = .{ .source = source, .year = year, .catalog_id = id }; + @memcpy(row.name[0..name.len], name); + row.name_len = name.len; + @memcpy(row.catalog_kind[0..5], "movie"); + row.catalog_kind_len = 5; + @memcpy(row.url[0..name.len], name); + row.url_len = name.len; + return row; +} +test "projection groups releases only with one catalog and retains original filtered indexes" { + const rows = [_]Fixture{ fixture(.tmdb, "Reacher", 2022, 1), fixture(.torrent, "Reacher.2022.1080p", 0, 0), fixture(.torrent, "Reacher.S01", 0, 0) }; + const out = try std.testing.allocator.create(Projection); + defer std.testing.allocator.destroy(out); + projectInto(&rows, &.{ 0, 1, 2 }, out); + try std.testing.expectEqual(@as(usize, 2), out.count); + try std.testing.expectEqual(@as(usize, 1), out.groups[0].offer_count); + try std.testing.expectEqual(@as(u16, 1), out.groups[0].indexes[1]); + const duplicate = [_]Fixture{ rows[0], fixture(.tmdb, "Reacher", 2022, 2), rows[1] }; + projectInto(&duplicate, &.{ 0, 1, 2 }, out); + try std.testing.expectEqual(@as(usize, 3), out.count); + projectInto(&rows, &.{2}, out); + try std.testing.expectEqual(@as(usize, 2), out.groups[0].representative); +} +test "late catalogs reclaim torrent saturation without unbounding results" { + var rows: [16]Fixture = undefined; + for (&rows, 0..) |*row, i| { + row.* = fixture(.torrent, "Release", 0, 0); + row.score = @intCast(i); + } + var catalog = fixture(.tmdb, "Work", 2020, 1); + catalog.score = 45; + try std.testing.expectEqual(@as(?usize, 15), evictionIndex(&rows, catalog)); + for (rows[0..8]) |*row| row.* = catalog; + try std.testing.expect(evictionIndex(&rows, catalog) == null); +} +test "cache identity separates masks configurations and long queries without plaintext" { + var a: [80]u8 = undefined; + var b: [80]u8 = undefined; + const long_query = [_]u8{'q'} ** 255; + const first = cacheIdentity(&a, &long_query, 65535, 1).?; + try std.testing.expect(first.len < 80); + try std.testing.expect(!std.mem.eql(u8, first, cacheIdentity(&b, &long_query, 1, 1).?)); + try std.testing.expect(!std.mem.eql(u8, first, cacheIdentity(&b, &long_query, 65535, 2).?)); + try std.testing.expect(std.mem.indexOf(u8, first, "qqqq") == null); + try std.testing.expect(cacheIdentity(a[0..2], &long_query, 65535, 1) == null); +} +test "persisted search excludes account identities while retaining public catalogs and audio" { + inline for (.{ "jellyfin", "plex", "opds", "audiobooks", "plugin", "local" }) |source| { + try std.testing.expect(!cacheEligible(.{ .source = source, .url = "opaque-item-id" })); + } + try std.testing.expect(!cacheEligible(.{ .source = "music", .provider = "subsonic", .url = "opal://music/subsonic/item" })); + try std.testing.expect(!cacheEligible(.{ .source = "stremio", .url = "https://server.test/private-token/stream" })); + try std.testing.expect(cacheEligible(.{ .source = "music", .provider = "audius", .url = "opal://music/audius/track" })); + try std.testing.expect(cacheEligible(.{ .source = "music", .provider = "jiosaavn", .url = "https://public.test/track" })); + try std.testing.expect(cacheEligible(.{ .source = "stremio", .provider = "nasa", .url = "https://public.test/video" })); + try std.testing.expect(cacheEligible(.{ .source = "tmdb", .url = "opal://catalog/tv/1" })); + try std.testing.expect(!cacheEligible(.{ .source = "novels", .url = "novel|royalroad|https://user:pass@host.test/work|Book" })); +} +test "192 row admission reserves late works without growing the bounded wave" { + const rows = try std.testing.allocator.alloc(Fixture, MAX_ROWS); + defer std.testing.allocator.free(rows); + for (rows, 0..) |*row, i| { + row.* = fixture(.torrent, "Release", 0, 0); + row.score = @intCast(i); + } + var work = fixture(.tmdb, "Work", 2022, 1); + work.score = 500; + for (0..8) |i| { + work.catalog_id = @intCast(i + 1); + const victim = evictionIndex(rows, work).?; + try std.testing.expect(isRelease(rows[victim])); + rows[victim] = work; + } + var ninth = work; + ninth.score = 501; + try std.testing.expect(evictionIndex(rows, ninth) == null); + var song = fixture(.music, "Song", 0, 0); + song.score = 999; + const victim = evictionIndex(rows, song).?; + try std.testing.expect(isRelease(rows[victim])); + var catalogs: usize = 0; + for (rows) |row| if (category(row) == .movies) { + catalogs += 1; + }; + try std.testing.expectEqual(@as(usize, 8), catalogs); + try std.testing.expectEqual(@as(usize, 192), rows.len); +} +test "duplicate metadata catalogs do not count as playable offers" { + const row = fixture(.tmdb, "Work", 2022, 1); + const out = try std.testing.allocator.create(Projection); + defer std.testing.allocator.destroy(out); + projectInto(&[_]Fixture{ row, row }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 1), out.count); + try std.testing.expectEqual(@as(usize, 2), out.groups[0].count); + try std.testing.expectEqual(@as(usize, 0), out.groups[0].offer_count); +} +test "repeated availability does not inflate source count" { + const row = fixture(.music, "Song", 2022, 1); + const out = try std.testing.allocator.create(Projection); + defer std.testing.allocator.destroy(out); + projectInto(&[_]Fixture{ row, row }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 1), out.count); + try std.testing.expectEqual(@as(usize, 1), out.groups[0].offer_count); +} + +test "Reacher episode no year groups only a unique typed show and never movie" { + var show = fixture(.tmdb, "Reacher", 2022, 1); + @memcpy(show.catalog_kind[0..2], "tv"); + show.catalog_kind_len = 2; + const release = fixture(.torrent, "Reacher.S04E06.1080p", 0, 0); + const out = try std.testing.allocator.create(Projection); + defer std.testing.allocator.destroy(out); + projectInto(&[_]Fixture{ show, release }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 1), out.count); + try std.testing.expectEqual(@as(usize, 1), out.groups[0].offer_count); + var remake = show; + remake.catalog_id = 2; + projectInto(&[_]Fixture{ show, remake, release }, &.{ 0, 1, 2 }, out); + try std.testing.expectEqual(@as(usize, 3), out.count); + projectInto(&[_]Fixture{ show, remake, release }, &.{ 0, 2 }, out); + try std.testing.expectEqual(@as(usize, 2), out.count); + projectInto(&[_]Fixture{ fixture(.tmdb, "Reacher", 2022, 1), release }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 2), out.count); + try std.testing.expect(episodeMatches("Reacher", "Reacher.S04.1080p")); + try std.testing.expect(!episodeMatches("Reacher", "Jack.Reacher.S04E06")); +} +test "verified IMDb identity groups cross provider but keeps movie and TV separate" { + var a = fixture(.tmdb, "Work", 2022, 1); + var b = fixture(.music, "Work", 2022, 2); + @memcpy(a.catalog_imdb[0..7], "tt12345"); + a.catalog_imdb_len = 7; + b.catalog_imdb = a.catalog_imdb; + b.catalog_imdb_len = a.catalog_imdb_len; + const out = try std.testing.allocator.create(Projection); + defer std.testing.allocator.destroy(out); + projectInto(&[_]Fixture{ a, b }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 1), out.count); + @memcpy(b.catalog_kind[0..2], "tv"); + b.catalog_kind_len = 2; + projectInto(&[_]Fixture{ a, b }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 2), out.count); +} diff --git a/src/services/search_gallery_native_test.zig b/src/services/search_gallery_native_test.zig new file mode 100644 index 00000000..5d9b9d77 --- /dev/null +++ b/src/services/search_gallery_native_test.zig @@ -0,0 +1,146 @@ +//! Opt-in SDL pixel test. No HTTP, playback, user config, or screen recording. +//! OPAL_GALLERY_ART_DIR may contain tt2543164.jpg, tt1856101.jpg, +//! tt0816692.jpg, tt0245429.jpg. Without it, colored fixture geometry is used. +//! OPAL_GALLERY_CAPTURE_DIR optionally receives rendered PNG artifacts. +const std = @import("std"); +const dvui = @import("dvui"); +const io = @import("../core/io_global.zig"); +const workers = @import("../core/workers.zig"); +const alloc = @import("../core/alloc.zig").allocator; +const search = @import("search.zig"); +const resolver = @import("resolver.zig"); +const content = @import("search_content_pure.zig"); + +fn field(buffer: []u8, length: *usize, value: []const u8) void { + length.* = @min(buffer.len, value.len); + @memcpy(buffer[0..length.*], value[0..length.*]); +} + +fn fixtureRows() ![]resolver.ResolvedItem { + const rows = try alloc.alloc(resolver.ResolvedItem, 14); + const catalog_ids = [_]i32{ 329865, 335984, 157336, 129 }; + const titles = [_][]const u8{ "Arrival", "Blade Runner 2049", "Interstellar", "Spirited Away", "Fixture album", "Fixture podcast" }; + for (rows, 0..) |*row, index| { + row.* = .{}; + row.source = if (index < 12) .tmdb else if (index == 12) .music else .podcast; + row.catalog_id = if (index < 4) catalog_ids[index] else if (index < 12) @intCast(10000 + index) else 0; + field(&row.catalog_kind, &row.catalog_kind_len, "movie"); + var fixture_title: [64]u8 = undefined; + const title = if (index < 4) titles[index] else if (index < 12) try std.fmt.bufPrint(&fixture_title, "Offline movie fixture {d}", .{index}) else titles[index - 8]; + field(&row.name, &row.name_len, title); + field(&row.summary, &row.summary_len, "Explicit offline layout fixture: source comparison, artwork, and contextual actions. No provider availability or playback is asserted."); + field(&row.poster_url, &row.poster_url_len, "fixture://offline-art"); + row.year = 2016; + row.score = @intCast(index); + } + return rows; +} + +fn fixtureTexture(index: usize) !dvui.Texture { + if (io.getenv("OPAL_GALLERY_ART_DIR")) |directory| { + const names = [_][]const u8{ "tt2543164.jpg", "tt1856101.jpg", "tt0816692.jpg", "tt0245429.jpg" }; + const path = try std.fs.path.join(alloc, &.{ directory, names[index % names.len] }); + defer alloc.free(path); + const bytes = try io.cwdReadFileAlloc(path, alloc, 4 * 1024 * 1024); + defer alloc.free(bytes); + return try dvui.Texture.fromImageFile(path, bytes, .linear); + } + var pixels: [64 * 96 * 4]u8 = undefined; + for (0..64 * 96) |pixel| { + const y = pixel / 64; + pixels[pixel * 4] = @intCast(35 + index * 20); + pixels[pixel * 4 + 1] = @intCast(50 + y); + pixels[pixel * 4 + 2] = @intCast(130 + index * 12); + pixels[pixel * 4 + 3] = 255; + } + return try dvui.Texture.fromImageSource(.{ .pixels = .{ .rgba = &pixels, .width = 64, .height = 96, .interpolation = .linear } }); +} + +fn renderSize(width: u32, height: u32, rows: []const resolver.ResolvedItem, movies_only: bool) !void { + var backend = try dvui.backend.initWindow(.{ .io = io.io(), .allocator = alloc, .size = .{ .w = @floatFromInt(width), .h = @floatFromInt(height) }, .vsync = false, .title = "Opal offline gallery pixel regression", .hidden = true }); + defer backend.deinit(); + var window = try dvui.Window.init(@src(), alloc, backend.backend(), .{}); + defer window.deinit(); + @import("../ui/theme.zig").markUiThread(); + search.setGalleryFixtureForTest(rows, "Arrival"); + if (movies_only) search.setGalleryContentFilterForTest(.movies); + // Parent-owned workers must finish before their GPU storage is destroyed. + defer workers.beginShutdownAndDrain(800); + const projection = try alloc.create(content.Projection); + defer alloc.destroy(projection); + const indices = [_]usize{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 }; + content.projectInto(rows, &indices, projection); + for (0..if (movies_only) @as(usize, 6) else 4) |frame| { + try window.begin(@as(i128, @intCast(frame + 1)) * 16_666_667); + errdefer { + workers.beginShutdownAndDrain(800); + search.shutdown(); + search.deinitGallery(); + _ = window.end(.{}) catch null; + } + if (frame == 0) { + for (projection.groups[0..projection.count]) |group| { + const texture = try fixtureTexture(group.representative); + search.setGalleryTextureForTest(group.identity, rows[group.representative].poster_url[0..rows[group.representative].poster_url_len], texture, texture.width, texture.height); + } + } + if (movies_only and frame == 4) { + _ = try window.addEventMouseMotion(.{ .pt = .{ .x = @as(f32, @floatFromInt(width)), .y = @as(f32, @floatFromInt(height)) } }); + _ = try window.addEventMouseWheel(-600, .vertical); + } + @import("../ui/theme.zig").setTheme(); + const rect = dvui.windowRectPixels(); + try std.testing.expect(rect.w >= @as(f32, @floatFromInt(width))); + try std.testing.expect(rect.h >= @as(f32, @floatFromInt(height))); + var picture = dvui.Picture.start(rect) orelse return error.CaptureUnavailable; + var recording = true; + var picture_alive = true; + errdefer if (picture_alive) { + if (recording) picture.stop(); + picture.deinit(); + }; + search.renderGalleryForTest(); + window.endRendering(.{}); + picture.stop(); + recording = false; + if (frame == 3 or (movies_only and frame == 5)) { + var png: std.Io.Writer.Allocating = try .initCapacity(alloc, 128 * 1024); + defer png.deinit(); + try picture.png(&png.writer); + const encoded = png.written(); + try std.testing.expect(encoded.len > 1000); + try std.testing.expectEqualSlices(u8, "\x89PNG\r\n\x1a\n", encoded[0..8]); + // Assert actual GPU capture bounds, including Retina/high-DPI scale. + try std.testing.expectEqual(@as(u32, @intFromFloat(rect.w)), std.mem.readInt(u32, encoded[16..20], .big)); + try std.testing.expectEqual(@as(u32, @intFromFloat(rect.h)), std.mem.readInt(u32, encoded[20..24], .big)); + if (io.getenv("OPAL_GALLERY_CAPTURE_DIR")) |directory| { + try std.Io.Dir.cwd().createDirPath(io.io(), directory); + const name = try std.fmt.allocPrint(alloc, "gallery-{d}x{d}{s}.png", .{ width, height, if (movies_only and frame == 5) "-movies-scrolled" else if (movies_only) "-movies" else "" }); + defer alloc.free(name); + const path = try std.fs.path.join(alloc, &.{ directory, name }); + defer alloc.free(path); + try io.cwdWriteFile(.{ .sub_path = path, .data = encoded }); + } + } + picture.deinit(); + picture_alive = false; + if (frame == (if (movies_only) @as(usize, 5) else 3)) { + workers.beginShutdownAndDrain(800); + search.shutdown(); + search.deinitGallery(); + } + _ = try window.end(.{}); + } +} + +test "Native Search gallery offline SDL pixel capture" { + const rows = try fixtureRows(); + defer alloc.free(rows); + for ([_][2]u32{ .{ 1360, 1000 }, .{ 640, 800 } }) |size| { + for ([_]bool{ false, true }) |movies_only| { + workers.init(); + try renderSize(size[0], size[1], rows, movies_only); + workers.finishShutdown(); + } + } +} diff --git a/src/services/search_preview.zig b/src/services/search_preview.zig new file mode 100644 index 00000000..e26a02df --- /dev/null +++ b/src/services/search_preview.zig @@ -0,0 +1,382 @@ +//! One explicit, muted-by-default trailer player, isolated from playback/history. +//! select/start/poll/render/stop/deinit run on the UI thread. snapshot is safe +//! for other readers. Stop on query, selection, route, and overlay close; deinit +//! after the process worker barrier. Only verified TMDB records reach libmpv. +const std = @import("std"); +const dvui = @import("dvui"); +const c = @import("../core/c.zig").mpv; +const sdl = @import("../core/c.zig").sdl; +const state = @import("../core/state.zig"); +const workers = @import("../core/workers.zig"); +const allocator = @import("../core/alloc.zig").allocator; +const sync = @import("../core/sync.zig"); +const pure = @import("search_preview_pure.zig"); +pub const Kind = pure.Kind; +pub const Status = pure.Status; +pub const Failure = pure.Failure; +pub const Metadata = pure.Metadata; +pub const Lookup = struct { + metadata: Metadata = .{}, + status: Status = .failed, + failure: Failure = .transport, +}; +pub const Credentials = struct { + key: [256]u8 = @splat(0), + key_len: usize = 0, +}; +pub const Snapshot = struct { + identity: u64 = 0, + generation: u64 = 0, + status: Status = .idle, + failure: Failure = .none, + metadata: pure.Metadata = .{}, + muted: bool = true, + pub fn canStart(self: Snapshot) bool { + return (self.status == .ready or self.status == .ended or (self.status == .failed and self.failure == .playback)) and self.metadata.trailer_url_len > 0; + } + pub fn label(self: Snapshot) []const u8 { + return switch (self.status) { + .idle => "No trailer selected", + .loading => "Checking official trailers…", + .ready => "Official trailer", + .starting => "Loading trailer…", + .playing => "Trailer preview", + .ended => "Trailer ended", + .unavailable => if (self.failure == .no_credentials) "Add a TMDB key to enable trailers" else "No official trailer available", + .failed => if (self.failure == .playback) "Trailer could not play — open its verified link" else "Trailer metadata could not load", + }; + } +}; + +const Job = struct { + generation: u64, + kind: Kind, + id: i32, + key: [512]u8 = @splat(0), + key_len: usize = 0, +}; +var mutex: sync.Mutex = .{}; +var lifecycle: pure.Lifecycle = .{}; +var current: Snapshot = .{}; +var selected_kind: Kind = .movie; +var selected_id: i32 = 0; +var pending: ?Job = null; +var worker_busy: bool = false; // mutex; at most one fetch, latest pending coalesces. +var credentials: Credentials = .{}; // UI/config owner publishes; API readers copy. +var mpv: ?*c.mpv_handle = null; // UI only, never registered in state.app.players. +var renderer: ?*c.mpv_render_context = null; +var pixels: ?[]u8 = null; +var texture: ?dvui.Texture = null; +var frame_ready = false; +const frame_width: u32 = 640; +const frame_height: u32 = 360; + +pub fn snapshot() Snapshot { + mutex.lock(); + defer mutex.unlock(); + return current; +} + +/// Called by the credential owner (UI after config is ready; headless startup). +/// A reader-only lock around raw settings would not protect concurrent writers. +pub fn publishCredentials(key: []const u8) void { + mutex.lock(); + defer mutex.unlock(); + credentials = .{}; + if (key.len > credentials.key.len) return; + @memcpy(credentials.key[0..key.len], key); + credentials.key_len = key.len; +} + +pub fn copyCredentials() Credentials { + mutex.lock(); + defer mutex.unlock(); + return credentials; +} + +/// Selecting fetches metadata only. No playback or audio begins until start(). +pub fn select(identity: u64, kind: Kind, id: i32) void { + // This method is UI-only, so the editable credential is copied by its owner. + const key_len = @min(state.app.tmdb.api_key_len, state.app.tmdb.api_key.len); + publishCredentials(state.app.tmdb.api_key[0..key_len]); + mutex.lock(); + const unchanged = lifecycle.selected and lifecycle.identity == identity and selected_kind == kind and selected_id == id; + mutex.unlock(); + if (unchanged) return; + destroyPlayer(); + mutex.lock(); + const generation = lifecycle.select(identity); + selected_kind = kind; + selected_id = id; + current = .{ .identity = identity, .generation = generation, .status = .loading }; + pending = null; + if (id <= 0) { + current.status = .unavailable; + current.failure = .no_official_video; + } else if (credentials.key_len == 0) { + current.status = .unavailable; + current.failure = .no_credentials; + } else { + var job = Job{ .generation = generation, .kind = kind, .id = id, .key_len = credentials.key_len }; + @memcpy(job.key[0..job.key_len], credentials.key[0..job.key_len]); + pending = job; + } + mutex.unlock(); + launchPending(); +} + +fn launchPending() void { + mutex.lock(); + defer mutex.unlock(); + if (worker_busy or pending == null or workers.isQuitting()) return; + const job = pending.?; + pending = null; + worker_busy = true; + workers.spawn(fetchMetadata, .{job}) catch { + worker_busy = false; + if (lifecycle.accepts(job.generation)) { + current.status = .failed; + current.failure = .transport; + } + }; +} + +fn fetchMetadata(job: Job) void { + var reply: Lookup = .{}; + defer { + mutex.lock(); + worker_busy = false; + if (!workers.isQuitting() and lifecycle.accepts(job.generation)) { + current.metadata = reply.metadata; + current.failure = reply.failure; + current.status = reply.status; + } + mutex.unlock(); + state.wakeUi(); + } + reply = lookupMetadata(job.kind, job.id, job.key[0..job.key_len]); +} + +/// Independent bounded lookup for API workers. The caller owns/snapshots its +/// key; neither selected native work nor preview playback is touched here. +pub fn lookupMetadata(kind: Kind, id: i32, key: []const u8) Lookup { + if (id <= 0) return .{ .failure = .invalid_response }; + if (key.len == 0 or key.len > 256) return .{ .status = .unavailable, .failure = .no_credentials }; + for (key) |ch| if (ch <= 32 or ch == 127) return .{ .status = .unavailable, .failure = .no_credentials }; + if (workers.isQuitting()) return .{}; + var path_buf: [96]u8 = undefined; + const path = std.fmt.bufPrint(&path_buf, "/3/{s}/{d}?append_to_response=videos", .{ @tagName(kind), id }) catch return .{}; + const body = @import("tmdb_api.zig").tmdbPreviewApiOwned(path, key) orelse return .{}; + defer allocator.free(body); + const meta = pure.parseForId(allocator, body, id) catch return .{ .failure = .invalid_response }; + return .{ .metadata = meta, .status = if (meta.trailer_url_len > 0) .ready else .unavailable, .failure = if (meta.trailer_url_len > 0) .none else .no_official_video }; +} + +fn failPlayback() void { + destroyPlayer(); + mutex.lock(); + current.status = .failed; + current.failure = .playback; + mutex.unlock(); +} + +/// Explicit activation only. A separate libmpv core has no app scripts, +/// history, playlist, resume state, browser cookies, or persistent watch-later. +pub fn start() bool { + const snap = snapshot(); + if (!snap.canStart() or workers.isQuitting()) return false; + destroyPlayer(); + const handle = c.mpv_create() orelse { + failPlayback(); + return false; + }; + mpv = handle; + const opts = [_]struct { name: [*:0]const u8, value: [*:0]const u8 }{ + .{ .name = "config", .value = "no" }, + .{ .name = "terminal", .value = "no" }, + .{ .name = "load-scripts", .value = "no" }, + .{ .name = "audio-file-auto", .value = "no" }, + .{ .name = "sub-auto", .value = "no" }, + .{ .name = "vo", .value = if (@import("build_options").headless) "null" else "libmpv" }, + .{ .name = "mute", .value = "yes" }, + .{ .name = "volume", .value = "50" }, + .{ .name = "loop-file", .value = "no" }, + .{ .name = "keep-open", .value = "yes" }, + .{ .name = "idle", .value = "yes" }, + .{ .name = "save-position-on-quit", .value = "no" }, + .{ .name = "resume-playback", .value = "no" }, + .{ .name = "input-default-bindings", .value = "no" }, + .{ .name = "input-vo-keyboard", .value = "no" }, + .{ .name = "network-timeout", .value = "8" }, + .{ .name = "video-timing-offset", .value = "0" }, + .{ .name = "ytdl-format", .value = "best[height<=480]/bestvideo[height<=480]+bestaudio/best" }, + .{ .name = "ytdl-raw-options", .value = "ignore-config=,no-playlist=" }, + }; + for (opts) |opt| if (c.mpv_set_option_string(handle, opt.name, opt.value) < 0) { + failPlayback(); + return false; + }; + var script_buf: [1024]u8 = undefined; + if (@import("../player/ytdl_opts_pure.zig").buildScriptOpts(.{ .ytdl_path = @import("ytdlp.zig").getPath() orelse "yt-dlp" }, &script_buf)) |opts_z| { + _ = c.mpv_set_option_string(handle, "script-opts", opts_z.ptr); + } + if (c.mpv_initialize(handle) < 0) { + failPlayback(); + return false; + } + if (!@import("build_options").headless) { + var params = [_]c.mpv_render_param{ + .{ .type = c.MPV_RENDER_PARAM_API_TYPE, .data = @constCast(c.MPV_RENDER_API_TYPE_SW) }, + .{ .type = c.MPV_RENDER_PARAM_INVALID, .data = null }, + }; + if (c.mpv_render_context_create(&renderer, handle, ¶ms) < 0) { + failPlayback(); + return false; + } + pixels = allocator.alloc(u8, frame_width * frame_height * 4) catch { + failPlayback(); + return false; + }; + } + if (!@import("../player/player.zig").loadDetached(handle, .{ .url = snap.metadata.trailer_url[0..snap.metadata.trailer_url_len] })) { + failPlayback(); + return false; + } + mutex.lock(); + current.status = .starting; + current.failure = .none; + current.muted = true; + mutex.unlock(); + return true; +} + +pub fn setMuted(muted: bool) void { + const handle = mpv orelse return; + var value: c_int = if (muted) 1 else 0; + if (c.mpv_set_property(handle, "mute", c.MPV_FORMAT_FLAG, &value) >= 0) { + mutex.lock(); + current.muted = muted; + mutex.unlock(); + } +} + +/// Non-blocking event drain. Call each UI frame while preview is selected. +pub fn poll() void { + launchPending(); + const handle = mpv orelse return; + for (0..32) |_| { + const event = c.mpv_wait_event(handle, 0); + if (event == null or event.*.event_id == c.MPV_EVENT_NONE) break; + switch (event.*.event_id) { + c.MPV_EVENT_FILE_LOADED => { + mutex.lock(); + current.status = .playing; + mutex.unlock(); + }, + c.MPV_EVENT_END_FILE => { + const end: *const c.mpv_event_end_file = @ptrCast(@alignCast(event.*.data)); + mutex.lock(); + current.status = if (end.reason == c.MPV_END_FILE_REASON_ERROR) .failed else .ended; + current.failure = if (end.reason == c.MPV_END_FILE_REASON_ERROR) .playback else .none; + mutex.unlock(); + }, + else => {}, + } + } + // keep-open retains the last trailer frame without END_FILE on some mpv + // versions. The real EOF property, never elapsed-time guessing, ends UI state. + if (snapshot().status == .playing) { + var eof: c_int = 0; + if (c.mpv_get_property(handle, "eof-reached", c.MPV_FORMAT_FLAG, &eof) >= 0 and eof != 0) { + mutex.lock(); + current.status = .ended; + mutex.unlock(); + } + } +} + +/// Bounded 640×360 software preview; no decode/upload for invisible overlays. +/// Call inside a bounded preview panel. Muted previews do not block for A/V time. +pub fn render(src: std.builtin.SourceLocation, id: usize, width: f32, height: f32) void { + if (comptime @import("build_options").headless) return; + if (width <= 0 or height <= 0) return; + const ctx = renderer orelse return; + const buf = pixels orelse return; + const flags = c.mpv_render_context_update(ctx); + if (flags & c.MPV_RENDER_UPDATE_FRAME != 0) { + const size = [2]c_int{ frame_width, frame_height }; + const stride: usize = frame_width * 4; + const blocking: c_int = 0; + var params = [_]c.mpv_render_param{ + .{ .type = c.MPV_RENDER_PARAM_SW_SIZE, .data = @constCast(&size) }, + .{ .type = c.MPV_RENDER_PARAM_SW_FORMAT, .data = @constCast("rgb0") }, + .{ .type = c.MPV_RENDER_PARAM_SW_STRIDE, .data = @constCast(&stride) }, + .{ .type = c.MPV_RENDER_PARAM_SW_POINTER, .data = buf.ptr }, + .{ .type = c.MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, .data = @constCast(&blocking) }, + .{ .type = c.MPV_RENDER_PARAM_INVALID, .data = null }, + }; + if (c.mpv_render_context_render(ctx, ¶ms) >= 0) { + const sdl_renderer: *sdl.SDL_Renderer = @ptrCast(@alignCast(dvui.currentWindow().backend.impl.renderer)); + if (texture == null) { + if (sdl.SDL_CreateTexture(sdl_renderer, sdl.SDL_PIXELFORMAT_RGBX32, sdl.SDL_TEXTUREACCESS_STREAMING, frame_width, frame_height)) |tex| { + _ = sdl.SDL_SetTextureBlendMode(tex, sdl.SDL_BLENDMODE_NONE); + _ = sdl.SDL_SetTextureScaleMode(tex, sdl.SDL_ScaleModeLinear); + texture = .{ .ptr = tex, .width = frame_width, .height = frame_height, .format = .rgbx_32 }; + } + } + if (texture) |tex| { + const ptr: *sdl.SDL_Texture = @ptrCast(@alignCast(tex.ptr)); + frame_ready = sdl.SDL_UpdateTexture(ptr, null, buf.ptr, frame_width * 4) == 0; + } + } + } + if (frame_ready) if (texture) |tex| { + _ = dvui.image(src, .{ .source = .{ .texture = tex } }, .{ .id_extra = id, .min_size_content = .{ .w = width, .h = height }, .max_size_content = .{ .w = width, .h = height } }); + }; + const status = snapshot().status; + if (status == .playing or status == .starting) @import("../ui/components.zig").animatedRefresh(33_000); +} + +fn destroyPlayer() void { + // No render worker: all render calls and teardown belong to the UI thread. + if (renderer) |ctx| c.mpv_render_context_free(ctx); + renderer = null; + if (mpv) |ctx| c.mpv_terminate_destroy(ctx); + mpv = null; + if (pixels) |buf| allocator.free(buf); + pixels = null; + if (texture) |tex| dvui.textureDestroyLater(tex); + texture = null; + frame_ready = false; +} + +pub fn stop() void { + destroyPlayer(); + mutex.lock(); + defer mutex.unlock(); + // Route guards may call stop every frame. Once invalidated, preserve its + // generation; a still-running old worker already cannot publish into it. + if (!lifecycle.selected and pending == null and current.status == .idle) return; + lifecycle.stop(); + pending = null; + current = .{ .generation = lifecycle.generation }; +} + +/// Close/replay the selected preview without refetching verified metadata. +pub fn closePlayback() void { + destroyPlayer(); + mutex.lock(); + defer mutex.unlock(); + if (lifecycle.selected and current.metadata.trailer_url_len > 0) { + current.status = .ready; + current.failure = .none; + current.muted = true; + } +} + +pub fn deinit() void { + stop(); + mutex.lock(); + credentials = .{}; + mutex.unlock(); +} diff --git a/src/services/search_preview_pure.zig b/src/services/search_preview_pure.zig new file mode 100644 index 00000000..b45dbc41 --- /dev/null +++ b/src/services/search_preview_pure.zig @@ -0,0 +1,163 @@ +//! Verified TMDB preview metadata and generation policy; no guessed trailers. +const std = @import("std"); + +pub const Kind = enum { movie, tv }; +pub const Status = enum { idle, loading, ready, unavailable, failed, starting, playing, ended }; +pub const Failure = enum { none, no_credentials, no_official_video, transport, invalid_response, playback }; +pub const https_arguments = [_][]const u8{ "--proto", "=https", "--proto-redir", "=https", "--fail" }; +pub const Metadata = struct { + catalog_id: i32 = 0, + trailer_url: [128]u8 = @splat(0), + trailer_url_len: usize = 0, + title: [192]u8 = @splat(0), + title_len: usize = 0, + backdrop_url: [256]u8 = @splat(0), + backdrop_url_len: usize = 0, +}; + +pub const Lifecycle = struct { + generation: u64 = 0, + identity: u64 = 0, + selected: bool = false, + + pub fn select(self: *Lifecycle, identity: u64) u64 { + self.generation +%= 1; + self.identity = identity; + self.selected = true; + return self.generation; + } + pub fn stop(self: *Lifecycle) void { + if (!self.selected) return; + self.generation +%= 1; + self.selected = false; + self.identity = 0; + } + pub fn accepts(self: Lifecycle, generation: u64) bool { + return self.selected and self.generation == generation; + } +}; + +fn string(obj: std.json.Value, key: []const u8) []const u8 { + if (obj != .object) return ""; + const v = obj.object.get(key) orelse return ""; + return if (v == .string) v.string else ""; +} + +pub fn validVideoKey(key: []const u8) bool { + if (key.len != 11) return false; + for (key) |ch| if (!std.ascii.isAlphanumeric(ch) and ch != '_' and ch != '-') return false; + return true; +} + +fn validImagePath(path: []const u8) bool { + if (path.len < 2 or path.len > 192 or path[0] != '/' or std.mem.indexOf(u8, path, "..") != null) return false; + for (path[1..]) |ch| if (!std.ascii.isAlphanumeric(ch) and ch != '_' and ch != '-' and ch != '.') return false; + return true; +} + +/// An official YouTube Trailer is preferred to an official Teaser. Other sites, +/// arbitrary URLs, featurettes, and unofficial uploads are never playable here. +pub fn parse(allocator: std.mem.Allocator, body: []const u8) !Metadata { + if (body.len > 256 * 1024) return error.Oversized; + var doc = try std.json.parseFromSlice(std.json.Value, allocator, body, .{ .max_value_len = 256 * 1024 }); + defer doc.deinit(); + if (doc.value != .object) return error.InvalidResponse; + const id = doc.value.object.get("id") orelse return error.InvalidResponse; + if (id != .integer or id.integer <= 0) return error.InvalidResponse; + var result: Metadata = .{}; + result.catalog_id = std.math.cast(i32, id.integer) orelse return error.InvalidResponse; + const backdrop = string(doc.value, "backdrop_path"); + if (validImagePath(backdrop)) { + const url = std.fmt.bufPrint(&result.backdrop_url, "https://image.tmdb.org/t/p/w780{s}", .{backdrop}) catch ""; + result.backdrop_url_len = url.len; + } + const videos = doc.value.object.get("videos") orelse return error.InvalidResponse; + if (videos != .object) return error.InvalidResponse; + const rows = videos.object.get("results") orelse return error.InvalidResponse; + if (rows != .array) return error.InvalidResponse; + var best: ?std.json.Value = null; + var best_rank: u8 = 255; + for (rows.array.items) |row| { + if (row != .object) continue; + const official = row.object.get("official") orelse continue; + if (official != .bool or !official.bool or !std.mem.eql(u8, string(row, "site"), "YouTube")) continue; + const key = string(row, "key"); + if (!validVideoKey(key)) continue; + const ty = string(row, "type"); + const rank: u8 = if (std.mem.eql(u8, ty, "Trailer")) 0 else if (std.mem.eql(u8, ty, "Teaser")) 2 else continue; + const score = rank + @as(u8, if (std.mem.eql(u8, string(row, "iso_639_1"), "en")) 0 else 1); + if (best == null or score < best_rank or (score == best_rank and std.mem.order(u8, key, string(best.?, "key")) == .lt)) { + best = row; + best_rank = score; + } + } + if (best) |row| { + const url = try std.fmt.bufPrint(&result.trailer_url, "https://www.youtube.com/watch?v={s}", .{string(row, "key")}); + result.trailer_url_len = url.len; + const title = string(row, "name"); + // Labels have a fixed row; retain UTF-8 and remove ASCII control bytes. + var n = @min(title.len, result.title.len); + while (n > 0 and !std.unicode.utf8ValidateSlice(title[0..n])) n -= 1; + for (title[0..n], 0..) |ch, i| result.title[i] = if (ch < 32 or ch == 127) ' ' else ch; + result.title_len = n; + } + return result; +} + +pub fn parseForId(allocator: std.mem.Allocator, body: []const u8, expected_id: i32) !Metadata { + const meta = try parse(allocator, body); + if (expected_id <= 0 or meta.catalog_id != expected_id) return error.InvalidResponse; + return meta; +} + +test "preview selection and close reject late metadata" { + var life: Lifecycle = .{}; + const a = life.select(1); + try std.testing.expect(life.accepts(a)); + const b = life.select(2); + try std.testing.expect(!life.accepts(a)); + try std.testing.expect(life.accepts(b)); + life.stop(); + try std.testing.expect(!life.accepts(b)); + const stopped_generation = life.generation; + life.stop(); + try std.testing.expectEqual(stopped_generation, life.generation); +} + +test "verified preview transport forbids HTTP and insecure redirects" { + try std.testing.expectEqualStrings("--proto", https_arguments[0]); + try std.testing.expectEqualStrings("=https", https_arguments[1]); + try std.testing.expectEqualStrings("--proto-redir", https_arguments[2]); + try std.testing.expectEqualStrings("=https", https_arguments[3]); + try std.testing.expectEqualStrings("--fail", https_arguments[4]); +} + +test "preview is verified official typed TMDB trailer not arbitrary video" { + const body = + \\{"id":42,"backdrop_path":"/actual.jpg","videos":{"results":[ + \\{"site":"YouTube","official":false,"type":"Trailer","key":"aaaaaaaaaaa"}, + \\{"site":"YouTube","official":true,"type":"Featurette","key":"bbbbbbbbbbb"}, + \\{"site":"YouTube","official":true,"type":"Teaser","key":"ccccccccccc"}, + \\{"site":"YouTube","official":true,"type":"Trailer","iso_639_1":"en","name":"Real\ntrailer","key":"ddddddddddd"}]}} + ; + const meta = try parse(std.testing.allocator, body); + try std.testing.expectEqualStrings("https://www.youtube.com/watch?v=ddddddddddd", meta.trailer_url[0..meta.trailer_url_len]); + try std.testing.expectEqualStrings("Real trailer", meta.title[0..meta.title_len]); + try std.testing.expectEqualStrings("https://image.tmdb.org/t/p/w780/actual.jpg", meta.backdrop_url[0..meta.backdrop_url_len]); +} + +test "preview tie deterministic independent of provider result order" { + const a = try parse(std.testing.allocator, "{\"id\":1,\"videos\":{\"results\":[{\"official\":true,\"site\":\"YouTube\",\"type\":\"Trailer\",\"key\":\"bbbbbbbbbbb\"},{\"official\":true,\"site\":\"YouTube\",\"type\":\"Trailer\",\"key\":\"aaaaaaaaaaa\"}]}}"); + try std.testing.expect(std.mem.endsWith(u8, a.trailer_url[0..a.trailer_url_len], "aaaaaaaaaaa")); +} + +test "preview missing official video preserves real artwork and rejects path injection" { + const a = try parse(std.testing.allocator, "{\"id\":1,\"backdrop_path\":\"/real.png\",\"videos\":{\"results\":[]}}"); + try std.testing.expectEqual(@as(usize, 0), a.trailer_url_len); + try std.testing.expect(a.backdrop_url_len > 0); + const b = try parse(std.testing.allocator, "{\"id\":1,\"backdrop_path\":\"//evil.com/x\",\"videos\":{\"results\":[{\"official\":true,\"site\":\"YouTube\",\"type\":\"Trailer\",\"key\":\"x&v=evil123\"}]}}"); + try std.testing.expectEqual(@as(usize, 0), b.backdrop_url_len); + try std.testing.expectEqual(@as(usize, 0), b.trailer_url_len); + try std.testing.expectError(error.InvalidResponse, parse(std.testing.allocator, "{\"status_message\":\"bad key\"}")); + try std.testing.expectError(error.InvalidResponse, parseForId(std.testing.allocator, "{\"id\":2,\"videos\":{\"results\":[]}}", 1)); +} diff --git a/src/services/search_reading_pure.zig b/src/services/search_reading_pure.zig new file mode 100644 index 00000000..00942d51 --- /dev/null +++ b/src/services/search_reading_pure.zig @@ -0,0 +1,80 @@ +//! Owned, bounded independent reading listings. Reuses Browse's real DOM seam. +const std = @import("std"); +const expanded = @import("expanded_reading_pure.zig"); +const text = @import("novels_pure.zig"); +pub const Source = enum { royalroad, novelfire }; +pub const MAX_ITEMS = 12; +pub const Item = struct { + title: [256]u8 = @splat(0), + title_len: usize = 0, + url: [1024]u8 = @splat(0), + url_len: usize = 0, + cover: [512]u8 = @splat(0), + cover_len: usize = 0, +}; +pub const Result = struct { count: usize = 0, valid_listing: bool = false }; +fn coverUrl(out: []u8, base: []const u8, raw: []const u8) ?[]const u8 { + const candidate = expanded.html.sourceUrl(out, base, raw) orelse raw; + // Providers advertise real cover CDN URLs outside the listing origin. + // Preserve those HTTPS URLs without admitting scripts or user credentials. + if (candidate.len > out.len or std.mem.indexOfAny(u8, candidate, "\r\n") != null) return null; + const uri = std.Uri.parse(candidate) catch return null; + if (!std.ascii.eqlIgnoreCase(uri.scheme, "https") or uri.host == null or uri.user != null or uri.password != null) return null; + if (candidate.ptr != out.ptr) @memcpy(out[0..candidate.len], candidate); + return out[0..candidate.len]; +} +/// Only links inside the advertised work-list container are admitted. Chapter, +/// random-navigation, external-host, script and duplicate links are excluded. +pub fn parseInto(body: []const u8, base: []const u8, source: Source, out: []Item) Result { + const listing = expanded.novelListingHtml(body, @tagName(source)) orelse return .{}; + var result: Result = .{ .valid_listing = true }; + var iter = expanded.html.AnchorIter{ .html = listing, .path = if (source == .royalroad) "/fiction/" else "/book/" }; + while (iter.next()) |anchor| { + if (std.mem.indexOf(u8, anchor.url, "/chapter") != null or std.mem.endsWith(u8, anchor.url, "/random")) continue; + var item: Item = .{}; + const url = expanded.html.sourceUrl(&item.url, base, anchor.url) orelse continue; + item.url_len = url.len; + item.title_len = text.htmlToText(anchor.title, &item.title); + if (item.title_len == 0) continue; + if (coverUrl(&item.cover, base, anchor.cover)) |cover| item.cover_len = cover.len; + var duplicate = false; + for (out[0..result.count]) |*previous| { + if (!std.mem.eql(u8, previous.url[0..previous.url_len], url)) continue; + if (previous.cover_len == 0 and item.cover_len > 0) { + previous.cover = item.cover; + previous.cover_len = item.cover_len; + } + duplicate = true; + break; + } + if (duplicate) continue; + if (result.count == @min(out.len, MAX_ITEMS)) break; + out[result.count] = item; + result.count += 1; + } + return result; +} +test "independent reading list owns valid works and merges cover without chapter or foreign links" { + const html = "Outside
"; + var out: [MAX_ITEMS]Item = undefined; + const result = parseInto(html, "https://books.test", .novelfire, &out); + try std.testing.expect(result.valid_listing); + try std.testing.expectEqual(@as(usize, 1), result.count); + try std.testing.expectEqualStrings("Real & Good", out[0].title[0..out[0].title_len]); + try std.testing.expectEqualStrings("https://books.test/book/real", out[0].url[0..out[0].url_len]); + try std.testing.expectEqualStrings("https://books.test/cover.jpg", out[0].cover[0..out[0].cover_len]); + try std.testing.expect(!parseInto("Challenge", "https://books.test", .novelfire, &out).valid_listing); +} +test "reading results stay bounded and valid empty containers are not parser failures" { + var out: [1]Item = undefined; + const result = parseInto("", "https://rr.test", .royalroad, &out); + try std.testing.expectEqual(@as(usize, 1), result.count); + try std.testing.expect(parseInto("", "https://rr.test", .royalroad, &out).valid_listing); +} +test "advertised cover CDN preserved but credentials and unsafe schemes rejected" { + var out: [512]u8 = undefined; + try std.testing.expectEqualStrings("https://cdn.test/cover.jpg", coverUrl(&out, "https://books.test", "https://cdn.test/cover.jpg").?); + try std.testing.expect(coverUrl(&out, "https://books.test", "https://user:pass@cdn.test/cover.jpg") == null); + try std.testing.expect(coverUrl(&out, "https://user:pass@books.test", "/cover.jpg") == null); + try std.testing.expect(coverUrl(&out, "https://books.test", "javascript:alert(1)") == null); +} diff --git a/src/services/tmdb.zig b/src/services/tmdb.zig index 40fa9f8b..54070cc5 100644 --- a/src/services/tmdb.zig +++ b/src/services/tmdb.zig @@ -422,7 +422,7 @@ fn renderToolbar(count: usize) void { // Destinations stay visible even on narrow windows. Tooltips give each // icon its full name; the highlighted pill identifies the current view. renderSubTab(0, .Trending, "Browse", icons.tvg.lucide.compass); - renderSubTab(1, .Search, "Search", icons.tvg.lucide.search); + if (@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) renderSubTab(1, .Search, "Search", icons.tvg.lucide.search); renderSubTab(2, .Favorites, "Favorites", icons.tvg.lucide.star); renderSubTab(3, .Watchlist, "Watchlist", icons.tvg.lucide.bookmark); renderSubTab(4, .Watching, "Watching", icons.tvg.lucide.eye); @@ -431,8 +431,8 @@ fn renderToolbar(count: usize) void { // instead of hidden behind the Search tab. Typing auto-switches to the // .Search view via renderSearchInline's live-search path; clearing the // box restores Trending. - toolbarDivider(900); - renderSearchInline(); + if (@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) toolbarDivider(900); + if (@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) renderSearchInline(); // Contextual controls. switch (state.app.tmdb.view) { diff --git a/src/services/tmdb_api.zig b/src/services/tmdb_api.zig index e0007b87..0e3e0436 100644 --- a/src/services/tmdb_api.zig +++ b/src/services/tmdb_api.zig @@ -583,6 +583,10 @@ pub fn fetchPoster(item: *state.TmdbItem) void { pub var tmdb_https_blocked: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); fn curlOwnedOnce(url: []const u8, auth_header: []const u8, max_bytes: usize) ?[]u8 { + return curlOwnedPolicy(url, auth_header, max_bytes, false); +} + +fn curlOwnedPolicy(url: []const u8, auth_header: []const u8, max_bytes: usize, https_only: bool) ?[]u8 { const io_g = @import("../core/io_global.zig"); // --connect-timeout bounds a dead/slow host to 3s on connect instead of // burning the full --max-time; --max-time trimmed to 8s for interactive @@ -602,10 +606,18 @@ fn curlOwnedOnce(url: []const u8, auth_header: []const u8, max_bytes: usize) ?[] // -L is required by the public Cinemeta endpoint, which currently answers // with a 307 to its catalog shard. Without it we read the redirect text, // reject it as non-JSON, and leave the keyless gallery empty. - var child = if (auth_header.len > 0) - io_g.Child.init(&.{ "curl", "-L", "-s", "--connect-timeout", "3", "--config", "-", "--max-time", "8", "-o", path, url }, alloc) - else - io_g.Child.init(&.{ "curl", "-L", "-s", "--connect-timeout", "3", "--max-time", "8", "-o", path, url }, alloc); + var args: std.ArrayListUnmanaged([]const u8) = .empty; + defer args.deinit(alloc); + args.appendSlice(alloc, &.{ "curl", "-L", "-s", "--connect-timeout", "3", "--max-time", "8", "-o", path }) catch return null; + if (auth_header.len > 0) args.appendSlice(alloc, &.{ "--config", "-" }) catch return null; + var size_buf: [24]u8 = undefined; + if (https_only) { + args.appendSlice(alloc, &@import("search_preview_pure.zig").https_arguments) catch return null; + const size_text = std.fmt.bufPrint(&size_buf, "{d}", .{max_bytes}) catch return null; + args.appendSlice(alloc, &.{ "--max-filesize", size_text }) catch return null; + } + args.append(alloc, url) catch return null; + var child = io_g.Child.init(args.items, alloc); child.stdout_behavior = .Ignore; child.stderr_behavior = .Ignore; if (auth_header.len > 0) { @@ -637,6 +649,20 @@ fn curlOwnedOnce(url: []const u8, auth_header: []const u8, max_bytes: usize) ?[] return body; } +/// Trailer verification requires an authenticated HTTPS response. Unlike the +/// legacy browse fallback, previews never downgrade HTTP or follow HTTP redirects. +pub fn tmdbPreviewApiOwned(path: []const u8, key: []const u8) ?[]u8 { + const v4 = @import("tmdb_pure.zig").keyIsV4(key); + var url_buf: [768]u8 = undefined; + const url = if (v4) + std.fmt.bufPrint(&url_buf, "https://api.themoviedb.org{s}", .{path}) catch return null + else + std.fmt.bufPrint(&url_buf, "https://api.themoviedb.org{s}&api_key={s}", .{ path, key }) catch return null; + var auth_buf: [320]u8 = undefined; + const auth = if (v4) std.fmt.bufPrint(&auth_buf, "Authorization: Bearer {s}", .{key}) catch return null else ""; + return curlOwnedPolicy(url, auth, 256 * 1024, true); +} + /// curl `url` (HTTPS) into `buf`, with an HTTPS→HTTP fallback for SNI-blocked /// networks. CRITICAL: a response is only accepted if it looks like JSON — some /// ISPs hijack plain HTTP to api.themoviedb.org and inject an HTML block page diff --git a/src/services/transfers.zig b/src/services/transfers.zig index 3d331f1a..9225718d 100644 --- a/src/services/transfers.zig +++ b/src/services/transfers.zig @@ -114,6 +114,24 @@ var stage_h: [state.MAX_DL_HISTORY]tp.Row = undefined; // ENTRY POINT // ══════════════════════════════════════════════════════════ +var fixture_for_test = false; +pub var test_http_rects: [4]dvui.Rect = @splat(.{}); +pub var test_http_action_rects: [4]dvui.Rect = @splat(.{}); +pub var test_file_action_rects: [3]dvui.Rect = @splat(.{}); +var fixture_http: []const httpdl.engine.Snap = &.{}; +pub fn setRenderFixtureForTest(items: ?[]const tp.Row, http: []const httpdl.engine.Snap) void { + if (!@import("builtin").is_test) @compileError("Transfers fixture is test-only"); + fixture_for_test = items != null; + fixture_http = http; + row_count = if (items) |data| @min(data.len, rows.len) else 0; + if (items) |data| @memcpy(rows[0..row_count], data[0..row_count]); + for (0..row_count) |i| order[i] = @intCast(i); + filter = .all; + transfers_scroll = .{}; + expanded_key_len = 0; + row_heights = @splat(0); +} + pub fn renderTransfersContent() void { // Always runs (even while drilled into a subfolder) — torrent_poll is // side-effecting: it drives the streaming deadline window, so it must keep @@ -147,6 +165,7 @@ pub fn renderTransfersContent() void { // ══════════════════════════════════════════════════════════ fn buildSnapshot() void { + if (@import("builtin").is_test and fixture_for_test) return; const now = io_global.milliTimestamp(); if (!rows_dirty.load(.acquire) and now - last_build_ms < 500) return; last_build_ms = now; @@ -423,7 +442,7 @@ fn renderControlBar() void { if (agg.active + agg.queued > 0) { var tb: [64]u8 = undefined; var rb: [24]u8 = undefined; - const txt = std.fmt.bufPrintZ(&tb, "↓{s} · {d} active · {d} queued", .{ + const txt = std.fmt.bufPrintZ(&tb, "Down {s} · {d} active · {d} queued", .{ httpdl.dp.fmtSpeed(agg.rate, &rb), agg.active, agg.queued, }) catch ""; _ = dvui.label(@src(), "{s}", .{txt}, .{ @@ -449,7 +468,7 @@ fn renderControlBar() void { // Single source of truth: state.app.download_rate_limit, stored in BYTES/sec // (same unit as the settings + footer controls and the FFI call below). const limits = [_]i32{ 0, 1 * 1024 * 1024, 5 * 1024 * 1024, 20 * 1024 * 1024 }; - const labels = [_][]const u8{ "∞", "1MB/s", "5MB/s", "20MB/s" }; + const labels = [_][]const u8{ "Unlimited", "1MB/s", "5MB/s", "20MB/s" }; for (limits, 0..) |lim, k| { const active = state.app.download_rate_limit == lim; if (dvui.button(@src(), labels[k], .{}, .{ @@ -560,6 +579,7 @@ fn statusColor(s: tp.Status) dvui.Color { /// Draws one merged row. Returns true if it performed a removal (the caller /// must stop iterating: ids/indices are now stale). fn renderRow(r: *const tp.Row, i: usize, row_index: usize) bool { + const title_cap = tp.filenameWidth(dvui.parentGet().data().contentRect().w, 220); const rid = rowId(r); const st = tp.statusFor(r); const name = r.nameSlice(); @@ -636,6 +656,7 @@ fn renderRow(r: *const tp.Row, i: usize, row_index: usize) bool { var blk = dvui.box(@src(), .{ .dir = .vertical }, .{ .id_extra = rid, .expand = .horizontal, + .max_size_content = dvui.Options.MaxSize.width(title_cap), .gravity_y = 0.5, }); defer blk.deinit(); @@ -654,6 +675,8 @@ fn renderRow(r: *const tp.Row, i: usize, row_index: usize) bool { // gravity_y (the main axis) mis-positions it and it overlaps the meta // line stacked beneath it — same trap documented in onboarding.zig. .gravity_x = 0.0, + .max_size_content = dvui.Options.MaxSize.width(title_cap), + .font = theme.mediaTitleFont(shown_name, dvui.themeGet().font_body), .color_fill = dvui.Color{ .r = 0, .g = 0, .b = 0, .a = 0 }, .color_text = if (st == .archived or st == .paused) theme.colors.text_secondary else theme.colors.text_primary, .padding = dvui.Rect.all(0), @@ -690,7 +713,10 @@ fn renderRow(r: *const tp.Row, i: usize, row_index: usize) bool { .min_size_content = .{ .w = 0, .h = 0 }, .gravity_y = 0.5, }); - defer acts.deinit(); + defer { + if (@import("builtin").is_test and fixture_for_test and row_index < test_file_action_rects.len) test_file_action_rects[row_index] = acts.data().rect; + acts.deinit(); + } // Play / stream the torrent. if (r.hasTorrent() and r.has_metadata) { @@ -1029,7 +1055,7 @@ fn metaLine(r: *const tp.Row, st: tp.Status, buf: []u8) []const u8 { const pct: u8 = @intFromFloat(std.math.clamp(r.progress * 100.0, 0.0, 100.0)); var sz: [24]u8 = undefined; return switch (st) { - .downloading => std.fmt.bufPrint(buf, "↓{s} · {d}% · {d} seeds", .{ fmtRate(r.dl_rate, &sz), pct, r.seeds }) catch "Downloading", + .downloading => std.fmt.bufPrint(buf, "Down {s} · {d}% · {d} seeds", .{ fmtRate(r.dl_rate, &sz), pct, r.seeds }) catch "Downloading", .fetching => "Fetching metadata…", .paused => std.fmt.bufPrint(buf, "Paused · {d}%", .{pct}) catch "Paused", .errored => "Error", @@ -1088,13 +1114,21 @@ fn renderHttpRows() usize { const S = struct { var snaps: [httpdl.engine.MAX_DOWNLOADS]httpdl.engine.Snap = undefined; }; - const n = httpdl.engine.snapshot(&S.snaps); + const n = if (@import("builtin").is_test and fixture_for_test) blk: { + const count = @min(fixture_http.len, S.snaps.len); + @memcpy(S.snaps[0..count], fixture_http[0..count]); + break :blk count; + } else httpdl.engine.snapshot(&S.snaps); for (S.snaps[0..n], 0..) |*s, i| renderHttpRow(s, i); return n; } fn renderHttpRow(s: *const httpdl.engine.Snap, i: usize) void { const rid: usize = @truncate(std.hash.Wyhash.hash(s.idx + 46000, std.mem.asBytes(&s.token))); + // Reserve the status glyph and actual pause/resume/remove controls before + // measuring untrusted filenames. Horizontal boxes cannot squeeze a label. + const available_w = dvui.parentGet().data().contentRect().w; + const title_cap = tp.filenameWidth(available_w, 180); const st = s.status; const active = st == .running or st == .probing; @@ -1112,7 +1146,10 @@ fn renderHttpRow(s: *const httpdl.engine.Snap, i: usize) void { .border = .{ .x = 0, .y = 0, .w = 0, .h = 1 }, .color_border = theme.colors.border_subtle, }); - defer row.deinit(); + defer { + if (@import("builtin").is_test and fixture_for_test and i < test_http_rects.len) test_http_rects[i] = row.data().rect; + row.deinit(); + } // Status bar (colored left edge) — same visual language as torrent rows. { @@ -1142,6 +1179,7 @@ fn renderHttpRow(s: *const httpdl.engine.Snap, i: usize) void { var blk = dvui.box(@src(), .{ .dir = .vertical }, .{ .id_extra = rid, .expand = .horizontal, + .max_size_content = dvui.Options.MaxSize.width(title_cap), .gravity_y = 0.5, }); defer blk.deinit(); @@ -1151,6 +1189,8 @@ fn renderHttpRow(s: *const httpdl.engine.Snap, i: usize) void { _ = dvui.label(@src(), "{s}", .{if (shown_name.len > 0) shown_name else "(download)"}, .{ .id_extra = rid, .gravity_x = 0.0, + .max_size_content = dvui.Options.MaxSize.width(title_cap), + .font = theme.mediaTitleFont(shown_name, dvui.themeGet().font_body), .color_text = if (st == .paused or st == .queued) theme.colors.text_secondary else theme.colors.text_primary, }); @@ -1214,7 +1254,7 @@ fn renderHttpRow(s: *const httpdl.engine.Snap, i: usize) void { .probing => "Connecting…", .running => blk2: { const eta_txt: []const u8 = if (s.etaSecs()) |secs| httpdl.dp.fmtEta(secs, &eb) else "—"; - break :blk2 std.fmt.bufPrint(&meta_buf, "↓{s} · {d}% · ETA {s} · {d}× conn", .{ + break :blk2 std.fmt.bufPrint(&meta_buf, "Down {s} · {d}% · ETA {s} · {d} connections", .{ httpdl.dp.fmtSpeed(s.rate, &sp), pct, eta_txt, s.seg_count, }) catch "Downloading"; }, @@ -1229,6 +1269,7 @@ fn renderHttpRow(s: *const httpdl.engine.Snap, i: usize) void { _ = dvui.label(@src(), "{s}", .{meta}, .{ .id_extra = rid, .expand = .horizontal, + .max_size_content = dvui.Options.MaxSize.width(title_cap), .color_text = if (st == .failed) theme.colors.danger else theme.colors.text_tertiary, .margin = .{ .x = 0, .y = 2, .w = 0, .h = 0 }, }); @@ -1240,7 +1281,10 @@ fn renderHttpRow(s: *const httpdl.engine.Snap, i: usize) void { .min_size_content = .{ .w = 0, .h = 0 }, .gravity_y = 0.5, }); - defer acts.deinit(); + defer { + if (@import("builtin").is_test and fixture_for_test and i < test_http_action_rects.len) test_http_action_rects[i] = acts.data().rect; + acts.deinit(); + } // Pause / resume. if (active or st == .queued) { diff --git a/src/services/transfers_pure.zig b/src/services/transfers_pure.zig index 660815e7..deb549ea 100644 --- a/src/services/transfers_pure.zig +++ b/src/services/transfers_pure.zig @@ -666,3 +666,19 @@ test "duplicate history labels and file rows have distinct action widgets" { file.hist_idx = 10; try std.testing.expectEqual(key, widgetId(&file)); } + +/// A horizontal row must reserve its action strip before filename measurement. +/// Labels otherwise report their entire minimum width and starve later controls. +pub fn filenameWidth(row_width: f32, action_budget: f32) f32 { + const width = if (std.math.isFinite(row_width) and row_width > 0) row_width else 640; + const budget = if (std.math.isFinite(action_budget) and action_budget >= 0) action_budget else 220; + return @max(80, width - budget); +} + +test "transfer filename budget preserves action space and bounds invalid geometry" { + try std.testing.expectEqual(@as(f32, 460), filenameWidth(640, 180)); + try std.testing.expectEqual(@as(f32, 420), filenameWidth(640, 220)); + try std.testing.expectEqual(@as(f32, 1140), filenameWidth(1360, 220)); + try std.testing.expectEqual(@as(f32, 80), filenameWidth(200, 220)); + try std.testing.expectEqual(@as(f32, 420), filenameWidth(std.math.nan(f32), std.math.inf(f32))); +} diff --git a/src/services/vndb.zig b/src/services/vndb.zig index 6672efbc..351ce894 100644 --- a/src/services/vndb.zig +++ b/src/services/vndb.zig @@ -489,6 +489,7 @@ pub fn renderContent() void { } fn renderSearchBar() void { + if (!@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) return; var row = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .expand = .horizontal, .padding = .{ .x = 10, .y = 7, .w = 10, .h = 7 }, diff --git a/src/services/youtube.zig b/src/services/youtube.zig index f541bd1d..8d394c95 100644 --- a/src/services/youtube.zig +++ b/src/services/youtube.zig @@ -1540,7 +1540,7 @@ fn renderToolbar() void { }); } - renderSearchInline(); + if (@import("../ui/browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) renderSearchInline(); if (state.app.yt.is_loading.load(.acquire)) { dvui.spinner(@src(), .{ .color_text = theme.colors.accent, .min_size_content = theme.iconSize(.sm), .gravity_y = 0.5 }); diff --git a/src/ui/activity_native_test.zig b/src/ui/activity_native_test.zig new file mode 100644 index 00000000..91292361 --- /dev/null +++ b/src/ui/activity_native_test.zig @@ -0,0 +1,90 @@ +//! Offline production Queue and Downloads renderer pixels; no engine/database startup. +const std = @import("std"); +const queue = @import("../services/queue.zig"); +const transfers = @import("../services/transfers.zig"); +const tp = @import("../services/transfers_pure.zig"); +const engine = @import("../services/download_engine.zig"); +const capture = @import("native_capture.zig"); +const workers = @import("../core/workers.zig"); +const alloc = @import("../core/alloc.zig").allocator; +var queue_case = true; +var items: [8]queue.QueueItem = @splat(.{}); +var download_rows: [3]tp.Row = @splat(.{}); +var http_rows: [4]engine.Snap = @splat(.{}); +fn setup() !void { + const state = @import("../core/state.zig"); + state.app.page_shell_enabled = true; + state.app.ui_scale = 1; + state.app.router.current = if (queue_case) .queue else .downloads; + state.app.config_loaded.store(false, .release); + if (queue_case) { + const sources = [_][]const u8{ "direct", "youtube", "magnet", "m3u" }; + for (&items, 0..) |*item, i| { + item.* = .{ .id = @intCast(i + 1), .position = @intCast(i), .duration = @intCast(3600 + i * 83), .played = i == 1, .thumb_failed = true }; + const title = try std.fmt.bufPrint(&item.title, "Offline queue fixture {d} — a long movie title with commentary and multilingual audio", .{i + 1}); + item.title_len = title.len; + const source = sources[i % sources.len]; + @memcpy(item.source[0..source.len], source); + item.source_len = source.len; + const url = "https://example.invalid/offline-fixture.mp4"; + @memcpy(item.url[0..url.len], url); + item.url_len = url.len; + } + queue.setRenderFixtureForTest(&items); + } else { + for (&http_rows, 0..) |*row, i| { + row.* = .{ .idx = i, .token = @intCast(i + 1), .status = ([_]engine.Status{ .running, .paused, .failed, .done })[i], .total = 1_000_000_000, .done = if (i == 3) 1_000_000_000 else 370_000_000, .rate = if (i == 0) 1_200_000 else 0, .seg_count = 4, .seg_frac = @splat(0.37) }; + const name = try std.fmt.bufPrint(&row.name, "Offline download fixture {d} — a long video title with director commentary.mp4", .{i + 1}); + row.name_len = name.len; + if (i == 2) { + const err = "Offline transport error fixture; retry remains available"; + @memcpy(row.err[0..err.len], err); + row.err_len = err.len; + } + } + for (&download_rows, 0..) |*row, i| { + row.* = .{ .origin = if (i == 2) .history else .file, .size = 740_000_000, .hist_idx = if (i == 2) 0 else -1 }; + var buf: [160]u8 = undefined; + const name = try std.fmt.bufPrint(&buf, "Offline finished fixture {d} with a long readable name.mp4", .{i + 1}); + tp.setName(row, name); + if (i != 2) tp.setDisk(row, name); + } + transfers.setRenderFixtureForTest(&download_rows, &http_rows); + } +} +fn draw() !void { + try @import("shell.zig").render(); +} +fn cleanup() void { + queue.setRenderFixtureForTest(null); + transfers.setRenderFixtureForTest(null, &.{}); +} +test "Native activity offline SDL pixel capture" { + const logs = @import("../core/logs.zig"); + logs.logs_allocator = alloc; + defer logs.deinit(); + workers.init(); + workers.beginShutdownAndDrain(0); + defer workers.finishShutdown(); + for ([_]bool{ true, false }) |is_queue| { + if (@import("../core/io_global.zig").getenv("OPAL_ACTIVITY_CASE")) |value| { + if (!std.mem.eql(u8, value, if (is_queue) "queue" else "downloads")) continue; + } + queue_case = is_queue; + for ([_][2]u32{ .{ 1360, 1000 }, .{ 640, 800 } }) |size| { + if (@import("../core/io_global.zig").getenv("OPAL_ACTIVITY_WIDTH")) |value| { + if ((std.fmt.parseInt(u32, value, 10) catch 0) != size[0]) continue; + } + try capture.capture(size[0], size[1], if (is_queue) "queue-populated" else "downloads-populated", setup, draw, cleanup); + const rects = if (is_queue) queue.test_row_rects[0..4] else transfers.test_http_rects[0..4]; + if (!is_queue) { + for (transfers.test_http_action_rects) |rect| try std.testing.expect(rect.w >= 60); + for (transfers.test_file_action_rects, 0..) |rect, i| try std.testing.expect(rect.w >= (if (i < 2) @as(f32, 120) else 50)); + } + for (1..rects.len) |i| { + try std.testing.expect(rects[i].h > 0); + try std.testing.expect(rects[i].y >= rects[i - 1].y + rects[i - 1].h); + } + } + } +} diff --git a/src/ui/browse_layout_pure.zig b/src/ui/browse_layout_pure.zig new file mode 100644 index 00000000..d38b53a9 --- /dev/null +++ b/src/ui/browse_layout_pure.zig @@ -0,0 +1,32 @@ +//! The page shell owns the one global title-search input. Provider browsing +//! keeps local filters and configuration; legacy layouts retain local search. +const std = @import("std"); +pub fn showLocalSearch(page_shell_enabled: bool) bool { + return !page_shell_enabled; +} +pub fn toolbarHeight(body_font_size: f32) f32 { + return @max(48, body_font_size * 2 + 20); +} +test "browse toolbar height retains room for large-font controls" { + try std.testing.expectEqual(@as(f32, 48), toolbarHeight(14)); + try std.testing.expect(toolbarHeight(24) >= 24 * 2 + 20); +} +pub const ReadingGrid = struct { columns: usize, card_width: f32 }; +pub fn readingGrid(viewport_width: f32) ReadingGrid { + const width = @max(1, viewport_width); + const columns: usize = if (width >= 1060) 4 else if (width >= 760) 3 else if (width >= 500) 2 else 1; + return .{ .columns = columns, .card_width = @max(1, (width - 16) / @as(f32, @floatFromInt(columns)) - 8) }; +} +test "reading grid width reserves row padding and every card margin" { + for ([_]f32{ 320, 640, 800, 1360 }) |width| { + const grid = readingGrid(width); + try std.testing.expect(grid.card_width > 0); + try std.testing.expect(16 + @as(f32, @floatFromInt(grid.columns)) * (grid.card_width + 8) <= width + 0.01); + } + try std.testing.expectEqual(@as(usize, 1), readingGrid(320).columns); + try std.testing.expectEqual(@as(usize, 4), readingGrid(1360).columns); +} +test "page shell has one title search and legacy browse retains local search" { + try std.testing.expect(!showLocalSearch(true)); + try std.testing.expect(showLocalSearch(false)); +} diff --git a/src/ui/components.zig b/src/ui/components.zig index cdcdf60f..b396ef06 100644 --- a/src/ui/components.zig +++ b/src/ui/components.zig @@ -246,6 +246,23 @@ pub fn renderCoverSlot(src: std.builtin.SourceLocation, id_extra: usize, slot: * }); } +/// Fixed gallery geometry with a centered crop; real covers keep their aspect +/// ratio instead of being stretched to match a different content format. +pub fn galleryCoverArt(src: std.builtin.SourceLocation, id: usize, slot: *CoverSlot, url: []const u8, fallback: []const u8, radius: f32, width: f32, height: f32) void { + syncCoverSlot(slot, url); + pollCoverSlot(slot); + if (@import("search_gallery_pure.zig").shouldFetchCover(url.len > 0, slot.failed, slot.fetching, slot.pixels != null, slot.tex != null)) { + poster.fetchAsync(url, &slot.pixels, &slot.w, &slot.h, &slot.fetching); + if (slot.fetching) slot.attempted = true; + } + if (slot.url_hash == slot.desired_url_hash) if (slot.tex) |tex| { + const crop = @import("search_gallery_pure.zig").coverCrop(.{ .w = @floatFromInt(slot.w), .h = @floatFromInt(slot.h) }, .{ .w = width, .h = height }); + _ = dvui.image(src, .{ .source = .{ .texture = tex }, .uv = .{ .x = crop.x, .y = crop.y, .w = crop.w, .h = crop.h } }, .{ .id_extra = id, .min_size_content = .{ .w = width, .h = height }, .max_size_content = .{ .w = width, .h = height }, .corner_radius = dvui.Rect.all(radius) }); + return; + }; + renderCoverSlot(src, id, slot, url.len > 0, fallback, radius); +} + /// Fill the current parent with a fetched cover, an animated loading skeleton, /// or a quiet fallback icon. This keeps image lifecycle and failure latching /// identical across secondary browse pages. @@ -253,7 +270,7 @@ pub fn coverArt(src: std.builtin.SourceLocation, id_extra: usize, slot: *CoverSl syncCoverSlot(slot, url); pollCoverSlot(slot); - if (url.len > 0 and !slot.failed and !slot.fetching) { + if (@import("search_gallery_pure.zig").shouldFetchCover(url.len > 0, slot.failed, slot.fetching, slot.pixels != null, slot.tex != null)) { poster.fetchAsync(url, &slot.pixels, &slot.w, &slot.h, &slot.fetching); if (slot.fetching) slot.attempted = true; } @@ -438,6 +455,17 @@ pub fn divider() void { /// stop so keyboard users can reach and flip it (Enter/Space). /// Toggle: pill, accent_primary when on, bg_elevated when off, with a knob /// that slides between the ends on theme.motion.fast. +pub const ToggleBounds = struct { row: dvui.Rect.Physical, pill: dvui.Rect.Physical }; +var native_toggle_bounds: [64]ToggleBounds = undefined; +var native_toggle_count: usize = 0; +pub fn clearToggleBoundsForTest() void { + if (!builtin.is_test) @compileError("Native toggle bounds are test-only"); + native_toggle_count = 0; +} +pub fn toggleBoundsForTest() []const ToggleBounds { + if (!builtin.is_test) @compileError("Native toggle bounds are test-only"); + return native_toggle_bounds[0..native_toggle_count]; +} pub fn toggleRow( src: std.builtin.SourceLocation, label: []const u8, @@ -487,31 +515,35 @@ pub fn toggleRow( // Left: label + optional hint stacked vertically. { var text_col = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + // Long help text must not publish an intrinsic minimum that + // pushes the independent switch outside the available row. + .max_size_content = .{ .w = 0, .h = std.math.floatMax(f32) }, + .margin = .{ .w = tk.sp_md }, .gravity_y = 0.5, }); defer text_col.deinit(); _ = dvui.label(@src(), "{s}", .{label}, .{ + .expand = .horizontal, .color_text = tk.text_primary(), .font = fontAt(tk.fs_body), }); if (hint) |h| { if (h.len > 0) { - _ = dvui.label(@src(), "{s}", .{h}, .{ + var help = dvui.textLayout(@src(), .{ .break_lines = true }, .{ + .expand = .horizontal, + .background = false, .color_text = tk.text_tertiary(), .font = fontAt(tk.fs_small), .margin = .{ .x = 0, .y = 2, .w = 0, .h = 0 }, }); + help.addText(h, .{}); + help.deinit(); } } } - // Spacer to push the toggle to the right edge. - { - var spacer = dvui.box(@src(), .{}, .{ .expand = .horizontal }); - spacer.deinit(); - } - // Right: pill toggle. The knob's position (and the pill's fill) animate // between the two ends over theme.motion.fast; dvui keyed animations force // repaints while running, so the slide plays out even under the gated @@ -532,6 +564,10 @@ pub fn toggleRow( .padding = .{ .x = 3, .y = 3, .w = 3, .h = 3 }, }); defer pill.deinit(); + if (builtin.is_test and native_toggle_count < native_toggle_bounds.len) { + native_toggle_bounds[native_toggle_count] = .{ .row = row.data().borderRectScale().r, .pill = pill.data().borderRectScale().r }; + native_toggle_count += 1; + } // Kick a slide animation whenever the value changed since last frame; // if a slide is still in flight, start from its current position. diff --git a/src/ui/footer.zig b/src/ui/footer.zig index 96b09a1f..7e4da68f 100644 --- a/src/ui/footer.zig +++ b/src/ui/footer.zig @@ -35,6 +35,21 @@ pub const PickerKind = enum(i32) { none = -1, chapter = 0, aspect = 1, audio = 2 /// pub so input.zig's staged-Escape chain can peel an open picker popover /// (audio/sub/chapter/aspect/lang/playlist) before touching bigger surfaces. pub var open_picker: PickerKind = .none; +pub const MediaLayoutForTest = struct { + menu_count: usize = 0, + menu: [32]dvui.Rect.Physical = @splat(.{}), + dock_close: dvui.Rect.Physical = .{}, + dock_play: dvui.Rect.Physical = .{}, + player_close: dvui.Rect.Physical = .{}, + torrent: [4]dvui.Rect.Physical = @splat(.{}), +}; +var media_layout_for_test: MediaLayoutForTest = .{}; +pub fn mediaLayoutForTest() MediaLayoutForTest { + if (!@import("builtin").is_test) @compileError("Media layout capture is test-only"); + var snapshot = media_layout_for_test; + snapshot.player_close = close_button_rect; + return snapshot; +} /// On-screen rect of each picker's chip, in dvui NATURAL units, recorded as the /// chip is laid out. The drop-up panels anchor to this: they float ABOVE their @@ -95,6 +110,7 @@ pub fn pickerOpen() bool { /// whose open flag lives in AppState because its workers outlive this module. pub fn closePickers() void { open_picker = .none; + pickers.clearPlaylistSnapshot(); state.app.sub_picker_open = false; } @@ -1245,6 +1261,31 @@ fn pickerIconChip( is_active: bool, tooltip: []const u8, kind: PickerKind, +) bool { + return pickerChipAtGravity(src, id_extra, icon, chip_text, is_active, tooltip, kind, 0.5); +} + +fn pickerMenuChip( + src: std.builtin.SourceLocation, + id_extra: usize, + icon: []const u8, + chip_text: []const u8, + is_active: bool, + tooltip: []const u8, + kind: PickerKind, +) bool { + return pickerChipAtGravity(src, id_extra, icon, chip_text, is_active, tooltip, kind, 0); +} + +fn pickerChipAtGravity( + src: std.builtin.SourceLocation, + id_extra: usize, + icon: []const u8, + chip_text: []const u8, + is_active: bool, + tooltip: []const u8, + kind: PickerKind, + gravity_y: f32, ) bool { var btn = dvui.box(src, .{ .dir = .horizontal }, .{ .id_extra = id_extra, @@ -1255,10 +1296,14 @@ fn pickerIconChip( .margin = .{ .x = 2, .y = 0, .w = 0, .h = 0 }, .min_size_content = .{ .w = 0, .h = 30 }, .max_size_content = .{ .w = std.math.floatMax(f32), .h = 30 }, - .gravity_y = 0.5, + .gravity_y = gravity_y, }); const btn_rect = btn.data().borderRectScale().r; + if (@import("builtin").is_test and gravity_y == 0 and media_layout_for_test.menu_count < media_layout_for_test.menu.len) { + media_layout_for_test.menu[media_layout_for_test.menu_count] = btn_rect; + media_layout_for_test.menu_count += 1; + } // The drop-up panel floats above this chip, so remember where it landed. if (kind != .none) recordAnchor(kind, btn_rect); var hovered_signal: bool = false; @@ -1480,9 +1525,33 @@ fn currentChapterChipText(ctx: *c.mpv.mpv_handle, out_buf: []u8) struct { text: return .{ .text = s, .count = Cache.count }; } +const TorrentStatusCache = struct { + var fixture_active: bool = false; + var tid: i32 = -1; + var last_ms: i64 = 0; + var name: [512]u8 = std.mem.zeroes([512]u8); + var name_len: usize = 0; + var pct: f32 = 0.0; + var dl_rate: c_int = 0; + var seeds: c_int = 0; +}; +pub fn setTorrentFixtureForTest(name: ?[]const u8) void { + if (!@import("builtin").is_test) @compileError("Torrent fixture is test-only"); + TorrentStatusCache.fixture_active = name != null; + if (name) |title| { + TorrentStatusCache.tid = 42; + TorrentStatusCache.name_len = @min(title.len, TorrentStatusCache.name.len); + @memcpy(TorrentStatusCache.name[0..TorrentStatusCache.name_len], title[0..TorrentStatusCache.name_len]); + TorrentStatusCache.pct = 0.375; + TorrentStatusCache.dl_rate = 3670016; + TorrentStatusCache.seeds = 128; + } else TorrentStatusCache.tid = -1; +} + pub fn renderLiquidGlassOverlay() void { if (!state.app.show_cell_overlay or state.app.players.items.len <= state.app.active_player_idx) return; + const show_media_title = footer_pure.showMediaTitleInControls(@import("builtin").os.tag == .macos); const active_p = state.app.players.items[state.app.active_player_idx]; if (active_p.provider != .mpv) return; @@ -1627,19 +1696,11 @@ pub fn renderLiquidGlassOverlay() void { // Name + stats were fetched every frame (torrent_get_name + // torrent_poll IPC at ~30fps); cache at ~2Hz keyed on the torrent id // (a torrent switch invalidates immediately). - const StatusCache = struct { - var tid: i32 = -1; - var last_ms: i64 = 0; - var name: [512]u8 = std.mem.zeroes([512]u8); - var name_len: usize = 0; - var pct: f32 = 0.0; - var dl_rate: c_int = 0; - var seeds: c_int = 0; - }; - if (active_p.current_torrent_id >= 0 and (StatusCache.tid != active_p.current_torrent_id or now_ms - StatusCache.last_ms > 500)) { + const StatusCache = TorrentStatusCache; + if (!(@import("builtin").is_test and StatusCache.fixture_active) and active_p.current_torrent_id >= 0 and (StatusCache.tid != active_p.current_torrent_id or now_ms - StatusCache.last_ms > 500)) { StatusCache.tid = active_p.current_torrent_id; StatusCache.last_ms = now_ms; - var t_name: [512]u8 = undefined; + var t_name: [512]u8 = @splat(0); c.mpv.torrent_get_name(state.torrentSession(), active_p.current_torrent_id, &t_name, t_name.len); @memcpy(&StatusCache.name, &t_name); StatusCache.name_len = std.mem.indexOfScalar(u8, &t_name, 0) orelse t_name.len; @@ -2077,9 +2138,9 @@ pub fn renderLiquidGlassOverlay() void { } } // fit.volume_slider - // The filename shares the transport strip; its bounded slot cannot - // force the close button off the edge or create another row. - { + // Native macOS titlebar owns the filename. Retain only useful busy + // status here; other desktops keep a measured title in this same strip. + if (show_media_title or footer_pure.transportBusy(transport)) { // Expand into spare width without caching the label's old width as // a minimum: shrinking the window must leave the right controls room. var title_host = dvui.box(@src(), .{}, .{ @@ -2088,7 +2149,7 @@ pub fn renderLiquidGlassOverlay() void { .max_size_content = .{ .w = 0, .h = footer_pure.CONTROL_CONTENT_HEIGHT }, }); const title_width = title_host.data().contentRect().w; - const raw_title = if (bar_pt < 540 and footer_pure.transportBusy(transport)) + const raw_title = if (!show_media_title or (bar_pt < 540 and footer_pure.transportBusy(transport))) footer_pure.transportLabel(transport) else if (active_p.current_torrent_id >= 0) StatusCache.name[0..StatusCache.name_len] @@ -2101,7 +2162,7 @@ pub fn renderLiquidGlassOverlay() void { var title_copy: [512]u8 = undefined; const safe_title = @import("../core/text.zig").safeUtf8Buf(raw_title, &title_copy); var clipped: [515]u8 = undefined; - const font = dvui.themeGet().font_body; + const font = theme.mediaTitleFont(safe_title, dvui.themeGet().font_body); var title_end: usize = safe_title.len; if (font.textSizeEx(safe_title, .{}).w > title_width) { const ellipsis_width = font.textSizeEx("…", .{}).w; @@ -2111,12 +2172,16 @@ pub fn renderLiquidGlassOverlay() void { var title_wd: dvui.WidgetData = undefined; _ = dvui.label(@src(), "{s}", .{label}, .{ .data_out = &title_wd, + .font = font, .color_text = player_text_muted, .gravity_y = 0.5, .gravity_x = 0.5, }); components.tip(@src(), title_wd, safe_title); title_host.deinit(); + } else { + var space = dvui.box(@src(), .{}, .{ .expand = .horizontal }); + space.deinit(); } // Audio. { @@ -2172,6 +2237,7 @@ pub fn renderLiquidGlassOverlay() void { // Secondary controls and transfer actions live above the single strip. if (open_picker == .more) { + if (@import("builtin").is_test) media_layout_for_test.menu_count = 0; var open = true; var fw = pickers.beginDropUp(@src(), .more, 330, 420, &open); defer fw.deinit(); @@ -2180,22 +2246,22 @@ pub fn renderLiquidGlassOverlay() void { defer options_scroll.deinit(); const tv_lib = @import("../services/tv_library.zig"); if (tv_lib.playingEpisode()) { - if (pickerIconChip(@src(), 717, icons.tvg.lucide.@"chevron-first", "Previous episode", tv_lib.neighborEpisode(-1) != null, "Previous episode", .none)) { + if (pickerMenuChip(@src(), 717, icons.tvg.lucide.@"chevron-first", "Previous episode", tv_lib.neighborEpisode(-1) != null, "Previous episode", .none)) { if (tv_lib.neighborEpisode(-1) != null) tv_lib.playNeighborEpisode(-1); } - if (pickerIconChip(@src(), 718, icons.tvg.lucide.@"chevron-last", "Next episode", tv_lib.neighborEpisode(1) != null, "Next episode", .none)) { + if (pickerMenuChip(@src(), 718, icons.tvg.lucide.@"chevron-last", "Next episode", tv_lib.neighborEpisode(1) != null, "Next episode", .none)) { if (tv_lib.neighborEpisode(1) != null) tv_lib.playNeighborEpisode(1); } } if (playback.playlist_count > 1) { - if (pickerIconChip(@src(), 719, icons.tvg.lucide.@"skip-back", "Previous track", playback.playlist_pos > 0, "Previous track", .none)) _ = c.mpv.mpv_command_string(active_p.mpv_ctx, "playlist-prev"); - if (pickerIconChip(@src(), 720, icons.tvg.lucide.@"skip-forward", "Next track", playback.playlist_pos + 1 < playback.playlist_count, "Next track", .none)) _ = c.mpv.mpv_command_string(active_p.mpv_ctx, "playlist-next"); + if (pickerMenuChip(@src(), 719, icons.tvg.lucide.@"skip-back", "Previous track", playback.playlist_pos > 0, "Previous track", .none)) _ = c.mpv.mpv_command_string(active_p.mpv_ctx, "playlist-prev"); + if (pickerMenuChip(@src(), 720, icons.tvg.lucide.@"skip-forward", "Next track", playback.playlist_pos + 1 < playback.playlist_count, "Next track", .none)) _ = c.mpv.mpv_command_string(active_p.mpv_ctx, "playlist-next"); } if (anime_skip.currentSkippable()) |skp| { - if (pickerIconChip(@src(), 721, icons.tvg.lucide.@"skip-forward", anime_skip_pure.skipButtonLabel(skp.category), true, "Skip this segment", .none)) anime_skip.skipNow(); + if (pickerMenuChip(@src(), 721, icons.tvg.lucide.@"skip-forward", anime_skip_pure.skipButtonLabel(skp.category), true, "Skip this segment", .none)) anime_skip.skipNow(); } - if (pickerIconChip(@src(), 713, icons.tvg.lucide.@"maximize-2", "Fullscreen", false, "Toggle fullscreen", .none)) { + if (pickerMenuChip(@src(), 713, icons.tvg.lucide.@"maximize-2", "Fullscreen", false, "Toggle fullscreen", .none)) { if (state.app.fullscreen_player_idx != null) state.app.fullscreen_player_idx = null else state.app.fullscreen_player_idx = state.app.active_player_idx; closePickers(); } @@ -2203,13 +2269,13 @@ pub fn renderLiquidGlassOverlay() void { const current_picture = av_pure.picturePresetFromInt(state.app.picture_preset); var picture_label_buf: [64]u8 = undefined; const picture_label = std.fmt.bufPrint(&picture_label_buf, "Picture: {s}", .{av_pure.pictureLabel(current_picture)}) catch "Picture preset"; - if (pickerIconChip(@src(), 714, icons.tvg.lucide.sparkles, picture_label, current_picture != .auto, "Cycle picture preset", .none)) { + if (pickerMenuChip(@src(), 714, icons.tvg.lucide.sparkles, picture_label, current_picture != .auto, "Cycle picture preset", .none)) { state.app.picture_preset = (state.app.picture_preset + 1) % 6; for (state.app.players.items) |p| player.applyPicturePreset(p); state.markConfigDirty(); } - if (pickerIconChip(@src(), 715, icons.tvg.lucide.rewind, "Back 10 seconds", false, "Seek back", .none)) _ = c.mpv.mpv_command_string(active_p.mpv_ctx, "seek -10"); - if (pickerIconChip(@src(), 716, icons.tvg.lucide.@"fast-forward", "Forward 10 seconds", false, "Seek forward", .none)) _ = c.mpv.mpv_command_string(active_p.mpv_ctx, "seek 10"); + if (pickerMenuChip(@src(), 715, icons.tvg.lucide.rewind, "Back 10 seconds", false, "Seek back", .none)) _ = c.mpv.mpv_command_string(active_p.mpv_ctx, "seek -10"); + if (pickerMenuChip(@src(), 716, icons.tvg.lucide.@"fast-forward", "Forward 10 seconds", false, "Seek forward", .none)) _ = c.mpv.mpv_command_string(active_p.mpv_ctx, "seek 10"); // Both URL probes are case-insensitive substring searches over the loaded // URL, and both results are only consumed by the quality chip below. Test @@ -2227,7 +2293,7 @@ pub fn renderLiquidGlassOverlay() void { std.fmt.bufPrint(&quality_buf, "{d}p", .{active_p.youtube_active_height}) catch "Video" else "Audio"; - if (pickerIconChip(@src(), 711, icons.tvg.lucide.monitor, quality, true, "YouTube stream quality", .quality)) { + if (pickerMenuChip(@src(), 711, icons.tvg.lucide.monitor, quality, true, "YouTube stream quality", .quality)) { togglePicker(.quality); } } @@ -2238,7 +2304,7 @@ pub fn renderLiquidGlassOverlay() void { { const ar_text = currentAspectChipText(active_p.mpv_ctx); const ar_active = !std.mem.eql(u8, ar_text, "Auto"); - if (pickerIconChip(@src(), 700, icons.tvg.lucide.ratio, ar_text, ar_active, "Framing & aspect", .aspect)) { + if (pickerMenuChip(@src(), 700, icons.tvg.lucide.ratio, ar_text, ar_active, "Framing & aspect", .aspect)) { togglePicker(.aspect); } } @@ -2248,7 +2314,7 @@ pub fn renderLiquidGlassOverlay() void { var chp_buf: [16]u8 = undefined; const chp = currentChapterChipText(active_p.mpv_ctx, &chp_buf); if (chp.count > 1) { - if (pickerIconChip(@src(), 701, icons.tvg.lucide.bookmark, chp.text, true, "Chapters", .chapter)) { + if (pickerMenuChip(@src(), 701, icons.tvg.lucide.bookmark, chp.text, true, "Chapters", .chapter)) { togglePicker(.chapter); } } @@ -2258,18 +2324,22 @@ pub fn renderLiquidGlassOverlay() void { // pinned instead of "auto"). { const dev_pinned = currentAudioDevicePinned(active_p.mpv_ctx); - if (pickerIconChip(@src(), 708, icons.tvg.lucide.speaker, "Output device", dev_pinned, "Audio output device", .audio_device)) { + if (pickerMenuChip(@src(), 708, icons.tvg.lucide.speaker, "Output device", dev_pinned, "Audio output device", .audio_device)) { togglePicker(.audio_device); } } + if (active_p.current_torrent_id < 0 and playback.playlist_count > 1) { + if (pickerMenuChip(@src(), 707, icons.tvg.lucide.list, "Playlist", true, "Choose a queued track", .playlist)) togglePicker(.playlist); + } + // Files (torrent multi-file playlist). if (active_p.current_torrent_id >= 0) { const file_count = c.mpv.torrent_get_file_count(state.torrentSession(), active_p.current_torrent_id); if (file_count > 1) { var f_buf: [16]u8 = undefined; const f_chip = std.fmt.bufPrint(&f_buf, "{d}", .{file_count}) catch ""; - if (pickerIconChip(@src(), 706, icons.tvg.lucide.list, f_chip, true, "Files in torrent", .playlist)) { + if (pickerMenuChip(@src(), 706, icons.tvg.lucide.list, f_chip, true, "Files in torrent", .playlist)) { togglePicker(.playlist); } } @@ -2292,28 +2362,32 @@ pub fn renderLiquidGlassOverlay() void { // mid-codepoint) — validate before dvui (matches grid.zig). var info_title: [515]u8 = undefined; const safe_name = @import("../core/text.zig").safeUtf8(name_slice); + const info_font = theme.mediaTitleFont(safe_name, dvui.themeGet().font_body); + var info_wd: dvui.WidgetData = undefined; var name_end: usize = 0; - _ = dvui.themeGet().font_body.textSizeEx(safe_name, .{ .max_width = 270, .end_idx = &name_end }); + _ = info_font.textSizeEx(safe_name, .{ .max_width = 270, .end_idx = &name_end }); _ = dvui.label(@src(), "{s}", .{footer_pure.compactTitle(&info_title, safe_name, name_end)}, .{ + .data_out = &info_wd, + .font = info_font, .color_text = player_text_muted, - .gravity_y = 0.5, + .gravity_y = 0, }); - - { - var spacer = dvui.box(@src(), .{}, .{ .expand = .horizontal }); - spacer.deinit(); - } + if (@import("builtin").is_test) media_layout_for_test.torrent[0] = info_wd.borderRectScale().r; var stat_buf: [80]u8 = undefined; if (std.fmt.bufPrintZ(&stat_buf, "{d:.1}% \xC2\xB7 {d:.1} MB/s \xC2\xB7 {d} seeds", .{ pct * 100.0, rate_mb, seeds })) |st| { _ = dvui.label(@src(), "{s}", .{st}, .{ + .data_out = &info_wd, .color_text = player_text_muted, - .gravity_y = 0.5, + .gravity_y = 0, }); + if (@import("builtin").is_test) media_layout_for_test.torrent[1] = info_wd.borderRectScale().r; } else |_| {} // Speed-limit cycle — also demoted. { + var action = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .min_size_content = .{ .w = 0, .h = 26 } }); + defer action.deinit(); const limit = state.app.download_rate_limit; var lim_buf: [48]u8 = undefined; const lim_label = if (limit == 0) @@ -2323,6 +2397,7 @@ pub fn renderLiquidGlassOverlay() void { }; if (dvui.button(@src(), lim_label, .{}, .{ .id_extra = 200, + .data_out = &info_wd, .color_fill = playerControlFill(false), .color_text = player_text_muted, .border = dvui.Rect.all(0), @@ -2342,6 +2417,7 @@ pub fn renderLiquidGlassOverlay() void { state.app.download_rate_limit = limits[next_idx]; c.mpv.torrent_set_download_limit(state.torrentSession(), state.app.download_rate_limit); } + if (@import("builtin").is_test) media_layout_for_test.torrent[2] = action.data().borderRectScale().r; } // Stop + delete — two-step confirm (same component as other destructive @@ -2350,6 +2426,9 @@ pub fn renderLiquidGlassOverlay() void { // needed at teardown, and torrent_remove() invalidates it, so we grab it // just before removing. { + var action = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .min_size_content = .{ .w = 0, .h = 26 } }); + defer action.deinit(); + if (@import("builtin").is_test) media_layout_for_test.torrent[3] = action.data().borderRectScale().r; if (components.confirmDangerButton(@src(), "Delete", 201)) { const tid = active_p.current_torrent_id; var del_path: [512]u8 = undefined; @@ -2419,6 +2498,7 @@ fn activeMediaPlayer() ?*player.MediaPlayer { /// The ~100px now-playing bar. Caller guarantees `p` is the active mpv player. fn renderNowPlayingBar(p: *player.MediaPlayer) void { + const show_media_title = footer_pure.showMediaTitleInControls(@import("builtin").os.tag == .macos); const text = @import("../core/text.zig"); const queue = @import("../services/queue.zig"); const transparent = dvui.Color{ .r = 0, .g = 0, .b = 0, .a = 0 }; @@ -2441,8 +2521,8 @@ fn renderNowPlayingBar(p: *player.MediaPlayer) void { .color_fill = theme.colors.bg_elevated, .color_border = theme.colors.border_subtle, .border = .{ .x = 0, .y = 1, .w = 0, .h = 0 }, - .min_size_content = .{ .w = 0, .h = 78 }, - .max_size_content = .{ .w = 0, .h = 78 }, + .min_size_content = .{ .w = 0, .h = 86 }, + .max_size_content = .{ .w = 0, .h = 86 }, }); defer bar.deinit(); @@ -2471,12 +2551,12 @@ fn renderNowPlayingBar(p: *player.MediaPlayer) void { var wd: dvui.WidgetData = undefined; - // ── Left: glyph + now-playing title (ellipsized, capped width) ── - { + // ── Other desktops retain context; macOS uses the native titlebar. ── + if (show_media_title) { var left = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5, .min_size_content = .{ .w = side_w, .h = 0 }, - .max_size_content = .{ .w = side_w, .h = 0 }, + .max_size_content = .{ .w = side_w, .h = 48 }, .corner_radius = dvui.Rect.all(theme.radius.sm), }); defer left.deinit(); @@ -2532,10 +2612,16 @@ fn renderNowPlayingBar(p: *player.MediaPlayer) void { // mutated bytes mid-frame. Snapshot a stable copy first. var nt_buf: [128]u8 = undefined; var title = text.safeUtf8Buf(raw_title, &nt_buf); - if (title.len > 42) title = text.safeUtf8(title[0..42]); // re-trim to a codepoint boundary (on the copy) + const title_width = @max(@as(f32, 0), side_w - 42); + const font = theme.mediaTitleFont(title, dvui.themeGet().font_body); + var end: usize = title.len; + _ = font.textSizeEx(title, .{ .max_width = @max(0, title_width - font.textSizeEx("…", .{}).w), .end_idx = &end }); + var clipped: [128]u8 = undefined; + title = footer_pure.compactTitle(&clipped, title, end); _ = dvui.label(@src(), "{s}", .{title}, .{ + .font = font, .color_text = theme.colors.text_primary, - .gravity_y = 0.5, + .gravity_y = 0, }); // Subtitle (show name / station codec·bitrate·country) — secondary tone, @@ -2543,11 +2629,15 @@ fn renderNowPlayingBar(p: *player.MediaPlayer) void { if (p.np_subtitle_len > 0) { var st_buf: [192]u8 = undefined; var sub = text.safeUtf8Buf(p.np_subtitle[0..p.np_subtitle_len], &st_buf); - if (sub.len > 46) sub = text.safeUtf8(sub[0..46]); + const sub_font = theme.mediaTitleFont(sub, dvui.themeGet().font_body.withSize(11)); + var sub_end: usize = sub.len; + _ = sub_font.textSizeEx(sub, .{ .max_width = @max(0, title_width - sub_font.textSizeEx("…", .{}).w), .end_idx = &sub_end }); + var sub_clip: [192]u8 = undefined; + sub = footer_pure.compactTitle(&sub_clip, sub, sub_end); _ = dvui.label(@src(), "{s}", .{sub}, .{ .color_text = theme.colors.text_secondary, - .font = dvui.themeGet().font_body.withSize(11), - .gravity_y = 0.5, + .font = sub_font, + .gravity_y = 0, }); } } @@ -2556,12 +2646,12 @@ fn renderNowPlayingBar(p: *player.MediaPlayer) void { { var center = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5, - .gravity_x = 0.5, - .expand = .horizontal, + .gravity_x = 0, + .expand = if (show_media_title) .horizontal else .none, }); defer center.deinit(); - { + if (show_media_title) { var sp = dvui.box(@src(), .{}, .{ .expand = .horizontal }); sp.deinit(); } @@ -2594,7 +2684,7 @@ fn renderNowPlayingBar(p: *player.MediaPlayer) void { .border = dvui.Rect.all(0), .corner_radius = dvui.Rect.all(theme.radius.pill), .gravity_y = 0.5, - .padding = .{ .x = 10, .y = 10, .w = 10, .h = 10 }, + .padding = .{ .x = 6, .y = 4, .w = 6, .h = 4 }, .min_size_content = .{ .w = 40, .h = 40 }, .max_size_content = .{ .w = 40, .h = 40 }, .margin = .{ .x = theme.spacing.sm, .y = 0, .w = theme.spacing.sm, .h = 0 }, @@ -2602,6 +2692,7 @@ fn renderNowPlayingBar(p: *player.MediaPlayer) void { p.togglePause(); } components.tip(@src(), wd, "Play/Pause"); + if (@import("builtin").is_test) media_layout_for_test.dock_play = wd.borderRectScale().r; if (show_skip) { // Next — plays the next unplayed queue item. @@ -2621,7 +2712,7 @@ fn renderNowPlayingBar(p: *player.MediaPlayer) void { components.tip(@src(), wd, "Next (from queue)"); } - { + if (show_media_title) { var sp = dvui.box(@src(), .{}, .{ .expand = .horizontal }); sp.deinit(); } @@ -2629,10 +2720,15 @@ fn renderNowPlayingBar(p: *player.MediaPlayer) void { // ── Right: volume, queue, open-player, close. Matches the left zone width // so the transport remains centered in the whole window. ── + const dock_transport = footer_pure.transportState(p.is_loading, playback.paused, playback.paused_for_cache); + if (!show_media_title and footer_pure.transportBusy(dock_transport)) { + _ = dvui.label(@src(), "{s}", .{footer_pure.transportLabel(dock_transport)}, .{ .color_text = theme.colors.text_secondary, .gravity_y = 0.5 }); + } var right = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5, - .min_size_content = .{ .w = side_w, .h = 0 }, - .max_size_content = .{ .w = side_w, .h = 0 }, + .expand = if (show_media_title) .none else .horizontal, + .min_size_content = .{ .w = if (show_media_title) side_w else 0, .h = 0 }, + .max_size_content = .{ .w = if (show_media_title) side_w else std.math.floatMax(f32), .h = 48 }, }); defer right.deinit(); { @@ -2709,6 +2805,7 @@ fn renderNowPlayingBar(p: *player.MediaPlayer) void { state.app.pending_remove_player_idx = @as(i32, @intCast(state.app.active_player_idx)); } components.tip(@src(), wd, "Stop and close player"); + if (@import("builtin").is_test) media_layout_for_test.dock_close = wd.borderRectScale().r; } /// The thin torrent-activity strip — unchanged from the original tray. Rendered diff --git a/src/ui/footer_pure.zig b/src/ui/footer_pure.zig index 1d3697a9..913409d0 100644 --- a/src/ui/footer_pure.zig +++ b/src/ui/footer_pure.zig @@ -303,6 +303,19 @@ pub fn compactTitle(out: []u8, title: []const u8, measured_end: usize) []const u return out[0 .. end + 3]; } +pub fn playlistIndexForId(ids: []const i64, wanted: i64) ?usize { + if (wanted < 0) return null; + for (ids, 0..) |id, index| if (id == wanted) return index; + return null; +} + +test "playlist action follows stable entry identity after reorder and rejects replacement" { + try std.testing.expectEqual(@as(?usize, 1), playlistIndexForId(&.{ 12, 41, 73 }, 41)); + try std.testing.expectEqual(@as(?usize, 0), playlistIndexForId(&.{ 41, 73, 12 }, 41)); + try std.testing.expect(playlistIndexForId(&.{ 12, 99, 73 }, 41) == null); + try std.testing.expect(playlistIndexForId(&.{ -1, 0 }, -1) == null); +} + // ══════════════════════════════════════════════════════════════════ // Tests // ══════════════════════════════════════════════════════════════════ @@ -671,3 +684,51 @@ test "filename fallback excludes URI authority credentials and handles Windows" try expect(showTorrentActivity(true, 7, 8)); try expect(showTorrentActivity(false, 7, 7)); } + +/// Select a CJK-capable face for Han, kana and Hangul media metadata. +/// Invalid UTF-8 is rejected; callers sanitize worker-owned text first. +pub fn needsCjkFont(text: []const u8) bool { + const view = std.unicode.Utf8View.init(text) catch return false; + var it = view.iterator(); + while (it.nextCodepoint()) |cp| { + if ((cp >= 0x2e80 and cp <= 0x9fff) or + (cp >= 0xac00 and cp <= 0xd7af) or + (cp >= 0xf900 and cp <= 0xfaff) or + (cp >= 0xff00 and cp <= 0xffef) or + (cp >= 0x1100 and cp <= 0x11ff) or + (cp >= 0x20000 and cp <= 0x3134f)) return true; + } + return false; +} +test "media CJK font selection preserves Latin typography and covers Han kana Hangul" { + try std.testing.expect(!needsCjkFont("A long English title — café")); + try std.testing.expect(!needsCjkFont("Привет κόσμος")); + try std.testing.expect(needsCjkFont("A mixed title 日本語")); + try std.testing.expect(needsCjkFont("カタカナ ひらがな")); + try std.testing.expect(needsCjkFont("한국어")); + try std.testing.expect(needsCjkFont("漢字")); + try std.testing.expect(!needsCjkFont("\xff")); +} + +/// DVUI row identity follows the mpv entry rather than its current position. +pub fn playlistWidgetKey(id: i64) ?usize { + if (id < 0) return null; + return @truncate(@as(u64, @intCast(id))); +} +test "playlist widget keys retain entry identity including zero and reject missing IDs" { + try std.testing.expectEqual(@as(?usize, 0), playlistWidgetKey(0)); + try std.testing.expectEqual(@as(?usize, 41), playlistWidgetKey(41)); + try std.testing.expect(playlistWidgetKey(-1) == null); + const before = [_]i64{ 12, 41, 73 }; + const after = [_]i64{ 41, 73, 12 }; + try std.testing.expectEqual(playlistWidgetKey(before[1]), playlistWidgetKey(after[0])); +} + +/// macOS already presents the loaded title in its native window titlebar. +pub fn showMediaTitleInControls(is_macos: bool) bool { + return !is_macos; +} +test "macOS controls omit duplicate window title and other desktops retain context" { + try std.testing.expect(!showMediaTitleInControls(true)); + try std.testing.expect(showMediaTitleInControls(false)); +} diff --git a/src/ui/home.zig b/src/ui/home.zig index 6aecf13c..8f13ffdf 100644 --- a/src/ui/home.zig +++ b/src/ui/home.zig @@ -951,6 +951,10 @@ fn openLibItem(item: *const library_pure.LibraryItem) void { state.app.router.navigate(.browse); }, .novels => @import("../services/novels.zig").openDeepLink(link), + .search => { + @import("../services/search.zig").submitQuery(title); + state.app.router.navigate(.search); + }, .comics => @import("../services/comics.zig").openDeepLink(link), .podcast => @import("../services/podcasts.zig").openDeepLink(link), else => browser.resumePlayback(link), @@ -1086,6 +1090,7 @@ fn renderLibraryItemsRail(items: []const library_pure.LibraryItem, heading: []co .comics => "Comic", .novels => "Read", .iptv => "Live", + .search => "Saved title", .other => "Continue", }; _ = dvui.label(@src(), "{s}", .{if (revealed) kind_label else "Private"}, .{ diff --git a/src/ui/jellyfin_ui.zig b/src/ui/jellyfin_ui.zig index b8802d2d..2a5e4871 100644 --- a/src/ui/jellyfin_ui.zig +++ b/src/ui/jellyfin_ui.zig @@ -4,6 +4,7 @@ const icons = @import("icons"); const theme = @import("theme.zig"); const components = @import("components.zig"); const jf = @import("../services/jellyfin.zig"); +const state = @import("../core/state.zig"); const safeUtf8 = @import("../core/text.zig").safeUtf8; const poster_util = @import("../core/poster.zig"); @@ -731,29 +732,31 @@ fn renderSearch() void { jf.goToLibraries(); } - var te = dvui.textEntry(@src(), .{ - .text = .{ .buffer = &search_buf }, - }, .{ - .expand = .horizontal, - .background = true, - .color_fill = theme.colors.bg_surface, - .color_border = theme.colors.border_subtle, - .border = dvui.Rect.all(1), - .corner_radius = theme.dims.rad_sm, - .padding = .{ .x = 8, .y = 6, .w = 8, .h = 6 }, - .margin = .{ .x = 8, .y = 0, .w = 8, .h = 0 }, - }); - const search_enter = te.enter_pressed; - te.deinit(); + if (@import("browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled)) { + var te = dvui.textEntry(@src(), .{ + .text = .{ .buffer = &search_buf }, + }, .{ + .expand = .horizontal, + .background = true, + .color_fill = theme.colors.bg_surface, + .color_border = theme.colors.border_subtle, + .border = dvui.Rect.all(1), + .corner_radius = theme.dims.rad_sm, + .padding = .{ .x = 8, .y = 6, .w = 8, .h = 6 }, + .margin = .{ .x = 8, .y = 0, .w = 8, .h = 0 }, + }); + const search_enter = te.enter_pressed; + te.deinit(); - const clicked_search = dvui.buttonIcon(@src(), "search", icons.tvg.lucide.search, .{}, .{}, .{ - .color_fill = theme.colors.accent, - .color_text = theme.colors.text_on_accent, - .padding = dvui.Rect.all(theme.radius.md), - .corner_radius = theme.dims.rad_sm, - }); - if (clicked_search or search_enter) { - jf.searchFor(std.mem.sliceTo(&search_buf, 0)); + const clicked_search = dvui.buttonIcon(@src(), "search", icons.tvg.lucide.search, .{}, .{}, .{ + .color_fill = theme.colors.accent, + .color_text = theme.colors.text_on_accent, + .padding = dvui.Rect.all(theme.radius.md), + .corner_radius = theme.dims.rad_sm, + }); + if (clicked_search or search_enter) { + jf.searchFor(std.mem.sliceTo(&search_buf, 0)); + } } } diff --git a/src/ui/local_library_layout_pure.zig b/src/ui/local_library_layout_pure.zig new file mode 100644 index 00000000..ccc8b8f5 --- /dev/null +++ b/src/ui/local_library_layout_pure.zig @@ -0,0 +1,29 @@ +//! Responsive local-library forms; no UI or database dependencies. +const std = @import("std"); + +pub fn stackFormControls(available_width: f32) bool { + // Folder paths and metadata titles need usable fields beside their actions. + return !std.math.isFinite(available_width) or available_width < 620; +} + +pub fn emptyHint(query_length: usize, duplicates_only: bool, roots: usize) []const u8 { + if (query_length > 0) return "No files match this search."; + if (duplicates_only) return "No likely duplicates found."; + if (roots == 0) return "Add a media folder, then scan to see your files here."; + return "No files indexed yet. Scan your media folders to refresh."; +} + +test "Local library folder and editor controls stack in compact windows" { + try std.testing.expect(stackFormControls(584)); // 640px shell minus panel padding. + try std.testing.expect(stackFormControls(619)); + try std.testing.expect(!stackFormControls(620)); + try std.testing.expect(!stackFormControls(1200)); + try std.testing.expect(stackFormControls(std.math.nan(f32))); +} + +test "Local library empty states give the next useful action" { + try std.testing.expectEqualStrings("No files match this search.", emptyHint(3, false, 0)); + try std.testing.expectEqualStrings("No likely duplicates found.", emptyHint(0, true, 2)); + try std.testing.expectEqualStrings("Add a media folder, then scan to see your files here.", emptyHint(0, false, 0)); + try std.testing.expectEqualStrings("No files indexed yet. Scan your media folders to refresh.", emptyHint(0, false, 1)); +} diff --git a/src/ui/local_library_ui.zig b/src/ui/local_library_ui.zig index 12f13538..72af32a7 100644 --- a/src/ui/local_library_ui.zig +++ b/src/ui/local_library_ui.zig @@ -4,6 +4,52 @@ const dvui = @import("dvui"); const theme = @import("theme.zig"); const state = @import("../core/state.zig"); const library = @import("../services/local_library.zig"); +const layout = @import("local_library_layout_pure.zig"); + +var test_rows: [3]dvui.Rect.Physical = undefined; +var test_editor_fields: [2]dvui.Rect.Physical = undefined; +pub fn editorFieldsForTest() [2]dvui.Rect.Physical { + if (!@import("builtin").is_test) @compileError("Native editor snapshot is test-only"); + return test_editor_fields; +} +var fixture_items: ?[]const library.Item = null; +var fixture_roots: ?[]const library.Root = null; +pub fn layoutRowsForTest() [3]dvui.Rect.Physical { + if (!@import("builtin").is_test) @compileError("Native layout snapshot is test-only"); + return test_rows; +} + +pub fn setFixtureForTest(items: ?[]const library.Item, roots: ?[]const library.Root, editor: ?library.Item) void { + if (!@import("builtin").is_test) @compileError("Native local-library fixture is test-only"); + fixture_items = items; + fixture_roots = roots; + edit_id = 0; + if (editor) |item| { + edit_id = item.id; + @memset(&edit_title, 0); + @memset(&edit_kind, 0); + @memcpy(edit_title[0..item.title_len], item.title[0..item.title_len]); + @memcpy(edit_kind[0..item.kind_len], item.kind[0..item.kind_len]); + } +} + +fn loadRoots(out: []library.Root) usize { + if (@import("builtin").is_test) if (fixture_roots) |rows| { + const count = @min(out.len, rows.len); + @memcpy(out[0..count], rows[0..count]); + return count; + }; + return library.listRoots(out); +} + +fn loadItems(query: []const u8, out: []library.Item) usize { + if (@import("builtin").is_test) if (fixture_items) |rows| { + const count = @min(out.len, rows.len); + @memcpy(out[0..count], rows[0..count]); + return count; + }; + return library.search(query, duplicates_only, out); +} var query_buf: [256]u8 = std.mem.zeroes([256]u8); var root_buf: [1024]u8 = std.mem.zeroes([1024]u8); @@ -24,7 +70,6 @@ pub fn render() void { defer panel.deinit(); var header = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer header.deinit(); _ = dvui.label(@src(), "Local library", .{}, .{ .expand = .horizontal, .font = dvui.themeGet().font_heading, @@ -34,9 +79,14 @@ pub fn render() void { .color_fill = theme.colors.bg_elevated, .color_text = theme.colors.text_secondary, }) and !library.scanning.load(.acquire)) library.scanAsync(); + if (@import("builtin").is_test) test_rows[0] = header.data().borderRectScale().r; + header.deinit(); - var root_controls = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer root_controls.deinit(); + const stacked = layout.stackFormControls(panel.data().contentRect().w); + var root_controls = dvui.box(@src(), .{ .dir = if (stacked) .vertical else .horizontal }, .{ + .expand = .horizontal, + .margin = .{ .x = 0, .y = theme.spacing.sm, .w = 0, .h = theme.spacing.sm }, + }); var root_entry = dvui.textEntry(@src(), .{ .text = .{ .buffer = &root_buf }, .placeholder = "Add a media folder path", @@ -54,9 +104,11 @@ pub fn render() void { state.showToast("Media folder added"); } else state.showToast("Folder is unavailable"); } + if (@import("builtin").is_test) test_rows[1] = root_controls.data().borderRectScale().r; + root_controls.deinit(); var roots: [library.MAX_ROOTS]library.Root = undefined; - const root_count = library.listRoots(&roots); + const root_count = loadRoots(&roots); for (roots[0..root_count], 0..) |*root, index| { var root_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = index, .expand = .horizontal }); defer root_row.deinit(); @@ -72,7 +124,6 @@ pub fn render() void { } var controls = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); - defer controls.deinit(); var search = dvui.textEntry(@src(), .{ .text = .{ .buffer = &query_buf }, .placeholder = "Search indexed files…", @@ -87,10 +138,12 @@ pub fn render() void { .color_fill = theme.colors.bg_elevated, .color_text = if (duplicates_only) theme.colors.accent else theme.colors.text_secondary, })) duplicates_only = !duplicates_only; + if (@import("builtin").is_test) test_rows[2] = controls.data().borderRectScale().r; + controls.deinit(); var items: [library.MAX_RESULTS]library.Item = undefined; const query = std.mem.sliceTo(&query_buf, 0); - const count = library.search(query, duplicates_only, &items); + const count = loadItems(query, &items); for (items[0..count], 0..) |*item, index| { var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = index, @@ -120,31 +173,43 @@ pub fn render() void { } } if (count == 0) - _ = dvui.label(@src(), "No indexed files match. Scan after changing the download folder.", .{}, .{ .color_text = theme.colors.text_tertiary }); + _ = dvui.labelNoFmt(@src(), layout.emptyHint(query.len, duplicates_only, root_count), .{}, .{ + .expand = .horizontal, + .margin = .{ .x = 0, .y = theme.spacing.sm, .w = 0, .h = 0 }, + .color_text = theme.colors.text_tertiary, + }); if (edit_id > 0) renderEditor(); } fn renderEditor() void { - var editor = dvui.box(@src(), .{ .dir = .horizontal }, .{ + const stacked = layout.stackFormControls(dvui.parentGet().data().contentRect().w); + var editor = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .background = true, .color_fill = theme.colors.bg_elevated, .padding = dvui.Rect.all(theme.spacing.sm), }); defer editor.deinit(); + var fields = dvui.box(@src(), .{ .dir = if (stacked) .vertical else .horizontal }, .{ .expand = .horizontal }); var title = dvui.textEntry(@src(), .{ .text = .{ .buffer = &edit_title }, .placeholder = "Corrected title" }, .{ .expand = .horizontal, .color_fill = theme.colors.bg_app, .color_text = theme.colors.text_primary, }); + if (@import("builtin").is_test) test_editor_fields[0] = title.data().borderRectScale().r; title.deinit(); var kind = dvui.textEntry(@src(), .{ .text = .{ .buffer = &edit_kind }, .placeholder = "movie / tv / music / audiobook / other" }, .{ - .min_size_content = .{ .w = 220, .h = 0 }, + .expand = if (stacked) .horizontal else .none, + .min_size_content = .{ .w = 220, .h = dvui.themeGet().font_body.sizeM(1, 1).h }, .color_fill = theme.colors.bg_app, .color_text = theme.colors.text_primary, }); + if (@import("builtin").is_test) test_editor_fields[1] = kind.data().borderRectScale().r; kind.deinit(); + fields.deinit(); + var actions = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); + defer actions.deinit(); if (dvui.button(@src(), "Save", .{}, .{ .color_fill = theme.colors.accent, .color_text = theme.colors.text_on_accent })) { if (library.correct(edit_id, std.mem.sliceTo(&edit_title, 0), std.mem.sliceTo(&edit_kind, 0))) { state.showToast("Metadata saved"); diff --git a/src/ui/media_native_test.zig b/src/ui/media_native_test.zig new file mode 100644 index 00000000..61ff09ab --- /dev/null +++ b/src/ui/media_native_test.zig @@ -0,0 +1,229 @@ +//! Explicit offline native media layout fixtures. No profile/provider loading. +const std = @import("std"); +const dvui = @import("dvui"); +const capture = @import("native_capture.zig"); +const state = @import("../core/state.zig"); +const allocator = @import("../core/alloc.zig").allocator; +const c = @import("../core/c.zig").mpv; +const player = @import("../player/player.zig"); +const footer = @import("footer.zig"); +const temp = @import("../core/secure_temp.zig"); +const clock = @import("../core/io_global.zig"); +var workspace: ?temp.Workspace = null; +var audio_path: [1024]u8 = undefined; +const theme = @import("theme.zig"); +var fixture_player: ?*player.MediaPlayer = null; +var draw_count: usize = 0; +var mode: enum { dock, player, more, playlist, torrent } = .dock; + +fn field(buffer: []u8, len: *usize, value: []const u8) void { + len.* = @min(buffer.len, value.len); + @memcpy(buffer[0..len.*], value[0..len.*]); +} + +fn setup() !void { + draw_count = 0; + const handle = c.mpv_create() orelse return error.MpvUnavailable; + errdefer c.mpv_terminate_destroy(handle); + for ([_][2][*:0]const u8{ .{ "config", "no" }, .{ "load-scripts", "no" }, .{ "vo", "null" }, .{ "ao", "null" }, .{ "terminal", "no" }, .{ "idle", "yes" } }) |option| { + if (c.mpv_set_option_string(handle, option[0], option[1]) < 0) return error.MpvOption; + } + if (c.mpv_initialize(handle) < 0) return error.MpvInitialize; + workspace = try temp.Workspace.create("media-layout"); + var wav: [32044]u8 = @splat(0); + @memcpy(wav[0..4], "RIFF"); + std.mem.writeInt(u32, wav[4..8], 32036, .little); + @memcpy(wav[8..16], "WAVEfmt "); + std.mem.writeInt(u32, wav[16..20], 16, .little); + std.mem.writeInt(u16, wav[20..22], 1, .little); + std.mem.writeInt(u16, wav[22..24], 1, .little); + std.mem.writeInt(u32, wav[24..28], 8000, .little); + std.mem.writeInt(u32, wav[28..32], 16000, .little); + std.mem.writeInt(u16, wav[32..34], 2, .little); + std.mem.writeInt(u16, wav[34..36], 16, .little); + @memcpy(wav[36..40], "data"); + std.mem.writeInt(u32, wav[40..44], 32000, .little); + const path = try workspace.?.writeFile("offline-audio.wav", &wav, &audio_path); + var flag: c_int = 1; + try std.testing.expect(c.mpv_set_property(handle, "pause", c.MPV_FORMAT_FLAG, &flag) >= 0); + try std.testing.expect(c.mpv_set_property(handle, "mute", c.MPV_FORMAT_FLAG, &flag) >= 0); + try std.testing.expect(player.loadDetached(handle, .{ .url = path, .media_title = "Offline audio fixture" })); + const deadline = clock.milliTimestamp() + 2000; + var loaded = false; + while (clock.milliTimestamp() < deadline) { + const event = c.mpv_wait_event(handle, 0.01); + if (event.*.event_id == c.MPV_EVENT_FILE_LOADED) { + loaded = true; + break; + } + } + try std.testing.expect(loaded); + var duration: f64 = 0; + try std.testing.expect(c.mpv_get_property(handle, "duration", c.MPV_FORMAT_DOUBLE, &duration) >= 0); + try std.testing.expectApproxEqAbs(@as(f64, 2), duration, 0.1); + for ([_][*:0]const u8{ "pause", "mute" }) |property| { + flag = 0; + try std.testing.expect(c.mpv_get_property(handle, property, c.MPV_FORMAT_FLAG, &flag) >= 0); + try std.testing.expectEqual(@as(c_int, 1), flag); + } + try std.testing.expect(c.mpv_command_string(handle, "seek 0.5 absolute+exact") >= 0); + var position: f64 = 0; + const seek_deadline = clock.milliTimestamp() + 1000; + while (clock.milliTimestamp() < seek_deadline) { + _ = c.mpv_wait_event(handle, 0.01); + _ = c.mpv_get_property(handle, "time-pos", c.MPV_FORMAT_DOUBLE, &position); + if (position >= 0.4 and position <= 0.7) break; + } + try std.testing.expect(position >= 0.4 and position <= 0.7); + for (0..2) |_| try std.testing.expect(player.loadDetached(handle, .{ .url = path, .mode = .append, .media_title = "Offline queued audio" })); + var count: i64 = 0; + try std.testing.expect(c.mpv_get_property(handle, "playlist-count", c.MPV_FORMAT_INT64, &count) >= 0); + try std.testing.expectEqual(@as(i64, 3), count); + var playlist: c.mpv_node = undefined; + try std.testing.expect(c.mpv_get_property(handle, "playlist", c.MPV_FORMAT_NODE, &playlist) >= 0); + var target_id: i64 = -1; + const target = playlist.u.list.*.values[1].u.list.*; + for (0..@intCast(target.num)) |i| { + if (std.mem.eql(u8, std.mem.span(target.keys[i]), "id")) target_id = target.values[i].u.int64; + } + c.mpv_free_node_contents(&playlist); + try std.testing.expect(target_id >= 0); + try std.testing.expect(c.mpv_command_string(handle, "playlist-move 1 0") >= 0); + try std.testing.expect(@import("pickers.zig").playPlaylistEntry(handle, target_id)); + var selected: i64 = -1; + try std.testing.expect(c.mpv_get_property(handle, "playlist-pos", c.MPV_FORMAT_INT64, &selected) >= 0); + try std.testing.expectEqual(@as(i64, 0), selected); + try std.testing.expect(!@import("pickers.zig").playPlaylistEntry(handle, std.math.maxInt(i64))); + // Playlist selection loads asynchronously; titlebar checks require the + // selected track's actual FILE_LOADED event rather than an interim title. + if (mode == .torrent) { + const selection_deadline = clock.milliTimestamp() + 2000; + var selection_loaded = false; + while (clock.milliTimestamp() < selection_deadline) { + const event = c.mpv_wait_event(handle, 0.01); + if (event.*.event_id == c.MPV_EVENT_FILE_LOADED) { + selection_loaded = true; + break; + } + } + try std.testing.expect(selection_loaded); + } + const p = try allocator.create(player.MediaPlayer); + errdefer allocator.destroy(p); + @memset(std.mem.asBytes(p), 0); + p.mpv_ctx = handle; + p.provider = .mpv; + p.current_torrent_id = if (mode == .torrent) 42 else -1; + if (mode == .torrent) footer.setTorrentFixtureForTest("An exceptionally long transfer filename — Complete.Series.S01E01.2160p.REMUX.Multi.Language.日本語.mkv"); + p.has_metadata = true; + p.cached_vid_no = true; + p.cached_paused = true; + p.cached_duration = 7265; + p.last_seen_pos = 3651; + p.cached_volume = 75; + p.cached_speed = 1; + p.cached_playlist_count = 3; + p.cached_playlist_pos = 0; + field(&p.current_url, &p.current_url_len, path); + if (mode == .torrent) { + try std.testing.expect(state.torrentSession() == null); + p.current_torrent_id = -1; + var baseline: [256]u8 = undefined; + const baseline_len = p.getMediaTitle(&baseline); + try std.testing.expect(baseline_len > 0); + p.current_torrent_id = 42; + var null_session_title: [256]u8 = undefined; + const title_len = p.getMediaTitle(&null_session_title); + try std.testing.expectEqualSlices(u8, baseline[0..baseline_len], null_session_title[0..title_len]); + var empty_name: [256]u8 = @splat(0); + c.torrent_get_name(null, 42, &empty_name, empty_name.len); + try std.testing.expectEqual(@as(u8, 0), empty_name[0]); + try std.testing.expectEqual(@as(c_int, 0), c.torrent_get_file_count(null, 42)); + } + + field(&p.np_title, &p.np_title_len, "An exceptionally long audiobook episode title with international text 日本語"); + field(&p.np_subtitle, &p.np_subtitle_len, "A very long publisher and series subtitle that must stay clear of transport controls"); + state.app.ui_scale = 1; + state.app.active_player_idx = 0; + state.app.show_cell_overlay = true; + state.app.router.navigate(if (mode == .dock) .home else .player); + try state.app.players.append(allocator, p); + fixture_player = p; + footer.closePickers(); +} + +fn cleanup() void { + footer.closePickers(); + footer.setTorrentFixtureForTest(null); + if (fixture_player) |p| { + c.mpv_terminate_destroy(p.mpv_ctx); + allocator.destroy(p); + } + fixture_player = null; + if (workspace) |*w| w.cleanup(); + workspace = null; + state.app.players.clearRetainingCapacity(); + state.app.players.deinit(allocator); + state.app.players = .empty; +} + +fn draw() !void { + draw_count += 1; + var panel = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .background = true, .color_fill = theme.colors.bg_app }); + defer panel.deinit(); + _ = dvui.label(@src(), "{s}", .{if (mode == .torrent) "Synthetic cached transfer UI fixture — no torrent session" else "Offline media layout fixture"}, .{ .color_text = theme.colors.text_secondary, .padding = dvui.Rect.all(16) }); + var space = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + if (mode != .dock) { + if (mode == .more or mode == .torrent) footer.open_picker = .more; + if (mode == .playlist) footer.open_picker = .playlist; + footer.renderLiquidGlassOverlay(); + } + space.deinit(); + if (mode == .dock) footer.renderGlobalBottomTray(); + const layout = footer.mediaLayoutForTest(); + if (mode == .dock and draw_count >= 8) { + try std.testing.expect(layout.dock_close.w > 0); + try std.testing.expect(layout.dock_close.x >= layout.dock_play.x + layout.dock_play.w); + try std.testing.expect(layout.dock_close.y + layout.dock_close.h <= dvui.windowRectPixels().h + 1); + try std.testing.expect(layout.dock_close.x + layout.dock_close.w <= dvui.windowRectPixels().w + 1); + } + if ((mode == .player or mode == .torrent) and draw_count >= 8) { + const rect = layout.player_close; + try std.testing.expect(rect.w > 0 and rect.h > 0); + try std.testing.expect(rect.x + rect.w <= dvui.windowRectPixels().w + 1); + try std.testing.expect(rect.y + rect.h <= dvui.windowRectPixels().h + 1); + } + if (mode == .torrent and draw_count >= 8) { + const rows = layout.torrent; + for (rows) |rect| { + try std.testing.expect(rect.w > 0 and rect.h > 0); + try std.testing.expect(rect.x + rect.w <= dvui.windowRectPixels().w + 1); + try std.testing.expect(rect.y + rect.h <= dvui.windowRectPixels().h + 1); + } + for (rows[1..], rows[0..3]) |current, previous| try std.testing.expect(current.y >= previous.y + previous.h - 1); + } + if (mode == .more) { + try std.testing.expect(layout.menu_count > 3); + for (layout.menu[1..layout.menu_count], layout.menu[0 .. layout.menu_count - 1]) |current, previous| { + try std.testing.expect(current.y >= previous.y + previous.h - 1); + } + } +} + +test "Native media offline SDL fixture" { + for ([_][2]u32{ .{ 1360, 850 }, .{ 640, 800 } }) |size| { + for ([_]@TypeOf(mode){ .dock, .player, .more, .playlist, .torrent }) |view| { + if (clock.getenv("OPAL_MEDIA_CASE")) |only| { + var cases = std.mem.splitScalar(u8, only, ','); + var selected = false; + while (cases.next()) |name| if (std.mem.eql(u8, name, @tagName(view))) { + selected = true; + break; + }; + if (!selected) continue; + } + mode = view; + try capture.capture(size[0], size[1], @tagName(view), setup, draw, cleanup); + } + } +} diff --git a/src/ui/metadata_dialog.zig b/src/ui/metadata_dialog.zig index 0064ea77..21746fd4 100644 --- a/src/ui/metadata_dialog.zig +++ b/src/ui/metadata_dialog.zig @@ -7,43 +7,62 @@ const icons = @import("icons"); const TRANSPARENT: dvui.Color = .{ .r = 0, .g = 0, .b = 0, .a = 0 }; +pub const FileFixture = struct { name: []const u8, size: u64 }; +pub const Fixture = struct { name: []const u8, files: []const FileFixture }; +var fixture_for_test: ?Fixture = null; +pub var test_dialog_rect: dvui.Rect = .{}; +pub var test_row_rects: [4]dvui.Rect = @splat(.{}); +pub var test_row_count: usize = 0; +pub var test_waiting_rect: dvui.Rect = .{}; +pub var test_footer_rect: dvui.Rect = .{}; +pub fn setFixtureForTest(value: ?Fixture) void { + if (!@import("builtin").is_test) @compileError("Metadata fixture is test-only"); + fixture_for_test = value; +} +fn fixture() ?Fixture { + return if (@import("builtin").is_test) fixture_for_test else null; +} pub fn renderMetadataDialog() void { if (state.app.pending_magnet_tid < 0) return; const dialog_size = theme.fitWindowSize(.{ .w = 600, .h = 400 }, .{ .w = 260, .h = 180 }); - // Full screen blocker to disable click-through - var backdrop = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = true, - .color_fill = theme.colors.overlay, - }); - defer backdrop.deinit(); - - // True modal: no border — separated by the elevated fill + a single soft - // shadow (the one legitimate shadow per the calm rules). - var win = dvui.box(@src(), .{ .dir = .vertical }, .{ - .background = true, + var open = true; + var win = dvui.floatingWindow(@src(), .{ + .modal = true, + .center_on = dvui.windowRect(), + .window_avoid = .none, + .open_flag = &open, + .resize = .none, + }, .{ + .min_size_content = dialog_size, + .max_size_content = dvui.Options.MaxSize.size(dialog_size), .color_fill = theme.colors.bg_elevated, .corner_radius = theme.dims.rad_lg, - .margin = dvui.Rect.all(theme.spacing.xl), - .padding = dvui.Rect.all(theme.spacing.md), - .min_size_content = dialog_size, - .expand = .both, - .box_shadow = .{ - .color = theme.colors.overlay, - .offset = .{ .x = 0, .y = 6 }, - .fade = 28, - }, }); defer win.deinit(); - - var col = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + win.autoPosition(); + win.autoSize(); + if (@import("builtin").is_test) { + test_dialog_rect = win.data().rect; + test_row_count = 0; + } + win.dragAreaSet(dvui.windowHeader("Torrent files", "", &open)); + if (!open) { + c.mpv.torrent_remove(state.torrentSession(), state.app.pending_magnet_tid); + state.app.pending_magnet_tid = -1; + return; + } + var col = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .{ .x = theme.spacing.md, .y = 6, .w = theme.spacing.md, .h = 6 } }); defer col.deinit(); if (!state.app.pending_has_metadata) { - _ = dvui.label(@src(), "Fetching Torrent Metadata...", .{}, .{ .expand = .both, .color_text = theme.colors.text_primary, .gravity_x = 0.5, .gravity_y = 0.5 }); + var waiting = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + _ = dvui.label(@src(), "Fetching Torrent Metadata...", .{}, .{ .expand = .horizontal, .color_text = theme.colors.text_primary, .margin = dvui.Rect.all(theme.spacing.md) }); + if (@import("builtin").is_test) test_waiting_rect = waiting.data().rect; + waiting.deinit(); var footer = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .gravity_x = 1.0 }); + if (@import("builtin").is_test) test_footer_rect = footer.data().rect; defer footer.deinit(); // Ghost Cancel — text-only danger, no resting fill. if (dvui.button(@src(), "Cancel", .{}, .{ .color_fill = TRANSPARENT, .color_text = theme.colors.danger, .padding = theme.dims.pad_sm })) { @@ -54,17 +73,23 @@ pub fn renderMetadataDialog() void { } var t_name: [256]u8 = undefined; - c.mpv.torrent_get_name(state.torrentSession(), state.app.pending_magnet_tid, &t_name, 256); + if (fixture()) |data| { + @memset(&t_name, 0); + const copied = @min(data.name.len, t_name.len - 1); + @memcpy(t_name[0..copied], data.name[0..copied]); + } else c.mpv.torrent_get_name(state.torrentSession(), state.app.pending_magnet_tid, &t_name, 256); const name_len = std.mem.indexOfScalar(u8, &t_name, 0) orelse 255; _ = dvui.label(@src(), "Pre-Download Filter", .{}, .{ .color_text = theme.colors.text_primary }); - _ = dvui.label(@src(), "{s}", .{@import("../core/text.zig").safeUtf8(t_name[0..name_len])}, .{ .color_text = theme.colors.text_secondary, .margin = .{ .x = 0, .y = 0, .w = 0, .h = theme.spacing.md } }); + var title = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false }); + title.addText(@import("../core/text.zig").safeUtf8(t_name[0..name_len]), .{ .color_text = theme.colors.text_secondary }); + title.deinit(); - var scroll = dvui.scrollArea(@src(), .{}, .{ .expand = .both, .background = true, .color_fill = theme.colors.bg_surface }); + var scroll = dvui.scrollArea(@src(), .{ .horizontal = .none }, .{ .expand = .both, .min_size_content = .{ .w = 0, .h = 0 }, .max_size_content = dvui.Options.MaxSize.height(@max(80, dialog_size.h - 180)), .background = true, .color_fill = theme.colors.bg_surface }); var f_list = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .padding = theme.dims.pad_sm }); - const f_count = c.mpv.torrent_get_file_count(state.torrentSession(), state.app.pending_magnet_tid); + const f_count: i32 = if (fixture()) |data| @intCast(@min(data.files.len, std.math.maxInt(i32))) else c.mpv.torrent_get_file_count(state.torrentSession(), state.app.pending_magnet_tid); // f_count is untrusted (.torrent metadata). Clamp to the fixed-size // pending_files_selection buffer so the index below can never go OOB. const shown = @min(f_count, @as(@TypeOf(f_count), @intCast(state.app.pending_files_selection.len))); @@ -72,20 +97,33 @@ pub fn renderMetadataDialog() void { var i: i32 = 0; while (i < shown) : (i += 1) { var f_name: [256]u8 = undefined; - c.mpv.torrent_get_file_name(state.torrentSession(), state.app.pending_magnet_tid, i, &f_name, 256); + if (fixture()) |data| { + @memset(&f_name, 0); + const name = data.files[@intCast(i)].name; + const copied = @min(name.len, f_name.len - 1); + @memcpy(f_name[0..copied], name[0..copied]); + } else c.mpv.torrent_get_file_name(state.torrentSession(), state.app.pending_magnet_tid, i, &f_name, 256); const f_len = std.mem.indexOfScalar(u8, &f_name, 0) orelse 255; const safe_name = @import("../core/text.zig").safeUtf8(f_name[0..f_len]); - const sz = c.mpv.torrent_get_file_size(state.torrentSession(), state.app.pending_magnet_tid, i); + const sz: u64 = if (fixture()) |data| data.files[@intCast(i)].size else @intCast(@max(0, c.mpv.torrent_get_file_size(state.torrentSession(), state.app.pending_magnet_tid, i))); const sz_mb = @as(f64, @floatFromInt(sz)) / (1024.0 * 1024.0); - var f_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .x = 0, .y = 0, .w = 0, .h = 4 }, .gravity_y = 0.5 }); + var f_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = @intCast(i), .expand = .horizontal, .margin = .{ .x = 0, .y = 0, .w = 0, .h = 8 } }); + if (@import("builtin").is_test) { + if (test_row_count < test_row_rects.len) { + test_row_rects[test_row_count] = f_row.data().rect; + test_row_count += 1; + } + } const selected = &state.app.pending_files_selection[@as(usize, @intCast(i))]; - _ = dvui.checkbox(@src(), selected, "", .{}); + _ = dvui.checkbox(@src(), selected, "", .{ .color_fill = if (selected.*) theme.colors.accent else theme.colors.bg_surface, .color_border = theme.colors.border_subtle, .color_text = theme.colors.text_on_accent }); var f_buf: [300]u8 = undefined; if (std.fmt.bufPrintZ(&f_buf, "{s} ({d:.1} MB)", .{ safe_name, sz_mb })) |n| { - _ = dvui.label(@src(), "{s}", .{n}, .{ .color_text = theme.colors.text_primary, .expand = .horizontal }); + var text = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false }); + text.addText(n, .{ .color_text = theme.colors.text_primary, .font = theme.mediaTitleFont(safe_name, dvui.themeGet().font_body) }); + text.deinit(); } else |_| {} f_row.deinit(); @@ -94,20 +132,17 @@ pub fn renderMetadataDialog() void { f_list.deinit(); scroll.deinit(); - var footer = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .x = 0, .y = theme.spacing.md, .w = 0, .h = 0 } }); + var footer = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .min_size_content = .{ .w = 0, .h = 44 }, .margin = .{ .x = 0, .y = theme.spacing.md, .w = 0, .h = 0 } }); defer footer.deinit(); - // Quiet instruction line — not a resting warning hue. - _ = dvui.label(@src(), "Select files to allocate your disk space.", .{}, .{ .color_text = theme.colors.text_tertiary, .expand = .horizontal, .gravity_y = 0.5 }); - // Ghost Cancel — text-only danger. - if (dvui.button(@src(), "Cancel", .{}, .{ .color_fill = TRANSPARENT, .color_text = theme.colors.danger, .padding = theme.dims.pad_md })) { + if (dvui.button(@src(), "Cancel", .{}, .{ .color_fill = TRANSPARENT, .color_text = theme.colors.danger, .padding = .{ .x = theme.spacing.md, .y = 6, .w = theme.spacing.md, .h = 6 } })) { c.mpv.torrent_remove(state.torrentSession(), state.app.pending_magnet_tid); state.app.pending_magnet_tid = -1; } // Primary action — the single accent affordance of the dialog. - if (dvui.button(@src(), "Start Download", .{}, .{ .color_fill = theme.colors.accent, .color_text = theme.colors.text_on_accent, .padding = theme.dims.pad_md, .margin = .{ .x = theme.spacing.sm, .y = 0, .w = 0, .h = 0 } })) { + if (dvui.button(@src(), "Start Download", .{}, .{ .color_fill = theme.colors.accent, .color_text = theme.colors.text_on_accent, .padding = .{ .x = theme.spacing.md, .y = 6, .w = theme.spacing.md, .h = 6 }, .margin = .{ .x = theme.spacing.sm, .y = 0, .w = 0, .h = 0 } })) { var fi: i32 = 0; const shown_dl = @min(f_count, @as(@TypeOf(f_count), @intCast(state.app.pending_files_selection.len))); while (fi < shown_dl) : (fi += 1) { diff --git a/src/ui/modals_native_test.zig b/src/ui/modals_native_test.zig new file mode 100644 index 00000000..fbe18325 --- /dev/null +++ b/src/ui/modals_native_test.zig @@ -0,0 +1,88 @@ +//! Real global-modal pixels. Static renderer fixtures; no file I/O or downloads. +const std = @import("std"); +const dvui = @import("dvui"); +const state = @import("../core/state.zig"); +const alloc = @import("../core/alloc.zig").allocator; +const io = @import("../core/io_global.zig"); +const workers = @import("../core/workers.zig"); +const capture = @import("native_capture.zig"); +const metadata = @import("metadata_dialog.zig"); +const Case = enum { onboarding, workspace_save, workspace_load_empty, workspace_load_populated, metadata_waiting, metadata_populated }; +var current: Case = .onboarding; +const files = [_]metadata.FileFixture{ + .{ .name = "Offline.fixture.with.a.very.long.title.2160p.multilingual.audio.and.director.commentary.mkv", .size = 3_000_000_000 }, + .{ .name = "Subtitles/Offline.fixture.English.SDH.srt", .size = 64_000 }, + .{ .name = "Subtitles/Offline.fixture.日本語.srt", .size = 80_000 }, + .{ .name = "Extras/Offline.fixture.deleted.scenes.and.interviews.with.a.long.filename.mp4", .size = 400_000_000 }, +}; +fn setup() !void { + state.app.ws_save_open = current == .workspace_save; + state.app.ws_load_open = current == .workspace_load_empty or current == .workspace_load_populated; + state.app.ws_count = if (current == .workspace_load_populated) 16 else 0; + for (0..state.app.ws_count) |index| { + @memset(&state.app.ws_names[index], 0); + const name = try std.fmt.bufPrint(&state.app.ws_names[index], "Offline workspace fixture {d} — a long saved session name", .{index}); + state.app.ws_name_lens[index] = name.len; + } + @memset(&state.app.ws_name_input, 0); + const save_name = "Offline fixture — evening movies and long session name"; + @memcpy(state.app.ws_name_input[0..save_name.len], save_name); + state.app.onboarded = current != .onboarding; + state.app.is_headless = false; + state.app.config_loaded.store(true, .release); + state.app.pending_magnet_tid = if (current == .metadata_waiting or current == .metadata_populated) 0 else -1; + state.app.pending_has_metadata = current == .metadata_populated; + @memset(&state.app.pending_files_selection, true); + metadata.setFixtureForTest(.{ .name = "Offline torrent metadata fixture with a long multilingual release title", .files = &files }); +} +fn draw() !void { + var background = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .background = true, .color_fill = @import("theme.zig").colors.bg_app }); + defer background.deinit(); + _ = dvui.label(@src(), "Offline global modal renderer fixture — no download or workspace file operation", .{}, .{ .color_text = @import("theme.zig").colors.text_secondary }); + switch (current) { + .onboarding => @import("onboarding.zig").render(), + .workspace_save, .workspace_load_empty, .workspace_load_populated => @import("ui.zig").renderWorkspaceModals(), + .metadata_waiting, .metadata_populated => metadata.renderMetadataDialog(), + } +} +fn cleanup() void { + metadata.setFixtureForTest(null); + state.app.ws_save_open = false; + state.app.ws_load_open = false; + state.app.ws_count = 0; + state.app.pending_magnet_tid = -1; + state.app.config_loaded.store(false, .release); +} +test "Native global modals offline SDL pixel capture" { + const logs = @import("../core/logs.zig"); + logs.logs_allocator = alloc; + defer logs.deinit(); + workers.init(); + workers.beginShutdownAndDrain(0); + defer workers.finishShutdown(); + for (std.meta.tags(Case)) |case| { + const name = @tagName(case); + if (io.getenv("OPAL_MODAL_CASE")) |filter| if (!std.mem.eql(u8, name, filter)) continue; + current = case; + for ([_][2]u32{ .{ 1360, 1000 }, .{ 640, 800 } }) |size| { + try capture.capture(size[0], size[1], name, setup, draw, cleanup); + if (case == .metadata_populated or case == .metadata_waiting) { + const rect = metadata.test_dialog_rect; + try std.testing.expect(rect.w > 0 and rect.w <= 608); + try std.testing.expect(rect.h > 0 and rect.h <= 408); + try std.testing.expectApproxEqAbs(@as(f32, @floatFromInt(size[0])) / 2, rect.x + rect.w / 2, 1); + try std.testing.expectApproxEqAbs(@as(f32, @floatFromInt(size[1])) / 2, rect.y + rect.h / 2, 1); + if (case == .metadata_waiting) { + try std.testing.expect(metadata.test_footer_rect.y >= metadata.test_waiting_rect.y + metadata.test_waiting_rect.h); + } + if (case == .metadata_populated) { + try std.testing.expectEqual(files.len, metadata.test_row_count); + for (1..metadata.test_row_count) |i| { + const previous = metadata.test_row_rects[i - 1]; + try std.testing.expect(metadata.test_row_rects[i].y >= previous.y + previous.h); + } + } + } + } + } +} diff --git a/src/ui/native_capture.zig b/src/ui/native_capture.zig new file mode 100644 index 00000000..aa8dccfe --- /dev/null +++ b/src/ui/native_capture.zig @@ -0,0 +1,72 @@ +//! Test-only native GPU capture: hidden SDL window, no screen-recording API. +const std = @import("std"); +const dvui = @import("dvui"); +const io = @import("../core/io_global.zig"); +const alloc = @import("../core/alloc.zig").allocator; +pub const Draw = *const fn () anyerror!void; +pub fn capture(width: u32, height: u32, name: []const u8, setup: ?Draw, draw: Draw, cleanup: ?*const fn () void) !void { + if (!@import("builtin").is_test) @compileError("Native capture is test-only"); + var backend = try dvui.backend.initWindow(.{ .io = io.io(), .allocator = alloc, .size = .{ .w = @floatFromInt(width), .h = @floatFromInt(height) }, .vsync = false, .title = "Opal isolated native layout fixture", .hidden = true }); + defer backend.deinit(); + var window = try dvui.Window.init(@src(), alloc, backend.backend(), .{}); + defer window.deinit(); + @import("theme.zig").markUiThread(); + if (setup) |init| try init(); + var cleaned = false; + defer if (!cleaned) { + if (cleanup) |done| done(); + }; + for (0..8) |frame| { + try window.begin(@as(i128, @intCast(frame + 1)) * 16_666_667); + errdefer _ = window.end(.{}) catch null; + errdefer if (!cleaned) { + if (cleanup) |done| done(); + cleaned = true; + }; + @import("theme.zig").setTheme(); + const rect = dvui.windowRectPixels(); + var picture = dvui.Picture.start(rect) orelse return error.CaptureUnavailable; + var recording = true; + var picture_alive = true; + errdefer if (picture_alive) { + if (recording) picture.stop(); + picture.deinit(); + }; + try draw(); + window.endRendering(.{}); + picture.stop(); + recording = false; + if (frame == 7) { + var png: std.Io.Writer.Allocating = try .initCapacity(alloc, 128 * 1024); + defer png.deinit(); + // Screenshots are review artifacts; avoid expensive compression + // across the complete route matrix while preserving exact pixels. + const compression = dvui.c.stbi_write_png_compression_level; + dvui.c.stbi_write_png_compression_level = 0; + defer dvui.c.stbi_write_png_compression_level = compression; + try picture.png(&png.writer); + const encoded = png.written(); + try std.testing.expect(encoded.len > 1000); + try std.testing.expectEqualSlices(u8, "\x89PNG\r\n\x1a\n", encoded[0..8]); + try std.testing.expectEqual(@as(u32, @intFromFloat(rect.w)), std.mem.readInt(u32, encoded[16..20], .big)); + try std.testing.expectEqual(@as(u32, @intFromFloat(rect.h)), std.mem.readInt(u32, encoded[20..24], .big)); + if (io.getenv("OPAL_NATIVE_CAPTURE_DIR")) |directory| { + try std.Io.Dir.cwd().createDirPath(io.io(), directory); + const filename = try std.fmt.allocPrint(alloc, "{s}-{d}x{d}.png", .{ name, width, height }); + defer alloc.free(filename); + const path = try std.fs.path.join(alloc, &.{ directory, filename }); + defer alloc.free(path); + try io.cwdWriteFile(.{ .sub_path = path, .data = encoded }); + } + } + picture.deinit(); + picture_alive = false; + if (frame == 7) { + // GPU textures belong to the active DVUI frame, not just the + // backend window. Destroy fixture resources before Window.end. + if (cleanup) |done| done(); + cleaned = true; + } + _ = try window.end(.{}); + } +} diff --git a/src/ui/pickers.zig b/src/ui/pickers.zig index e99f4781..c78b11db 100644 --- a/src/ui/pickers.zig +++ b/src/ui/pickers.zig @@ -194,7 +194,7 @@ pub fn pickerOption( _ = dvui.label(@src(), "{s}", .{label}, .{ .id_extra = id_extra, .color_text = theme.colors.text_primary, - .font = dvui.themeGet().font_body.withSize(theme.font_size.small), + .font = theme.mediaTitleFont(label, dvui.themeGet().font_body.withSize(theme.font_size.small)), .gravity_y = 0.5, }); { @@ -632,10 +632,99 @@ pub fn renderLangPickerPopover() void { if (!open) footer.open_picker = .none; } +const PlaylistEntry = struct { id: i64 = -1, label: [256]u8 = @splat(0), len: usize = 0 }; +var playlist_entries: [128]PlaylistEntry = @splat(.{}); +var playlist_len: usize = 0; +var playlist_ctx: ?*c.mpv.mpv_handle = null; +var playlist_updated: i64 = 0; +var playlist_load_serial: u64 = 0; + +pub fn clearPlaylistSnapshot() void { + playlist_ctx = null; + playlist_len = 0; + playlist_load_serial = 0; +} + +fn refreshPlaylist(ctx: *c.mpv.mpv_handle) void { + playlist_ctx = ctx; + playlist_updated = @import("../core/io_global.zig").monotonicMilliTimestamp(); + playlist_len = 0; + var count: i64 = 0; + if (c.mpv.mpv_get_property(ctx, "playlist-count", c.mpv.MPV_FORMAT_INT64, &count) < 0 or count > 1024) return; + var node: c.mpv.mpv_node = undefined; + if (c.mpv.mpv_get_property(ctx, "playlist", c.mpv.MPV_FORMAT_NODE, &node) < 0) return; + defer c.mpv.mpv_free_node_contents(&node); + if (node.format != c.mpv.MPV_FORMAT_NODE_ARRAY or node.u.list == null) return; + const list = node.u.list.*; + for (list.values[0..@intCast(@max(0, list.num))]) |entry| { + if (playlist_len == playlist_entries.len) break; + if (entry.format != c.mpv.MPV_FORMAT_NODE_MAP or entry.u.list == null) continue; + var row: PlaylistEntry = .{}; + var filename: []const u8 = "Audio track"; + var title: []const u8 = ""; + const map = entry.u.list.*; + for (0..@intCast(@max(0, map.num))) |i| { + const key = std.mem.span(map.keys[i]); + const value = map.values[i]; + if (std.mem.eql(u8, key, "id") and value.format == c.mpv.MPV_FORMAT_INT64) row.id = value.u.int64; + if (value.format == c.mpv.MPV_FORMAT_STRING and value.u.string != null) { + if (std.mem.eql(u8, key, "filename")) filename = std.mem.span(value.u.string); + if (std.mem.eql(u8, key, "title")) title = std.mem.span(value.u.string); + } + } + const label = if (title.len > 0) title else @import("footer_pure.zig").mediaFilename(filename); + const safe = @import("footer_pure.zig").compactTitle(&row.label, label, @min(label.len, row.label.len - 4)); + row.len = safe.len; + playlist_entries[playlist_len] = row; + playlist_len += 1; + } +} + +pub fn playPlaylistEntry(ctx: *c.mpv.mpv_handle, id: i64) bool { + refreshPlaylist(ctx); + var ids: [128]i64 = undefined; + for (playlist_entries[0..playlist_len], 0..) |entry, i| ids[i] = entry.id; + const index = @import("footer_pure.zig").playlistIndexForId(ids[0..playlist_len], id) orelse return false; + var position: i64 = @intCast(index); + return c.mpv.mpv_set_property(ctx, "playlist-pos", c.mpv.MPV_FORMAT_INT64, &position) >= 0; +} + +fn renderMpvPlaylist(p: *player.MediaPlayer) void { + const playback = p.playbackSnapshot(); + const now = @import("../core/io_global.zig").monotonicMilliTimestamp(); + if (playlist_ctx != p.mpv_ctx or playlist_load_serial != p.load_serial or now - playlist_updated > 400) { + refreshPlaylist(p.mpv_ctx); + playlist_load_serial = p.load_serial; + } + var open = true; + var fw = beginDropUp(@src(), .playlist, 460, 342, &open); + defer fw.deinit(); + dropUpTitle(@src(), icons.tvg.lucide.list, "Playlist"); + var scroll = dvui.scrollArea(@src(), .{}, .{ .expand = .both, .background = false }); + defer scroll.deinit(); + if (playlist_len == 0) _ = dvui.label(@src(), "Playlist unavailable or exceeds 1024 tracks", .{}, .{ .color_text = theme.colors.text_secondary, .font = dvui.themeGet().font_body.withSize(theme.font_size.small) }); + for (playlist_entries[0..playlist_len], 0..) |entry, i| { + const widget_key = @import("footer_pure.zig").playlistWidgetKey(entry.id) orelse continue; + const title = entry.label[0..entry.len]; + const font = theme.mediaTitleFont(title, dvui.themeGet().font_body.withSize(theme.font_size.small)); + var end: usize = title.len; + _ = font.textSizeEx(title, .{ .max_width = 370 - font.textSizeEx("…", .{}).w, .end_idx = &end }); + var clipped: [256]u8 = undefined; + const label = @import("footer_pure.zig").compactTitle(&clipped, title, end); + if (pickerOption(@src(), widget_key, icons.tvg.lucide.music, label, @as(i64, @intCast(i)) == playback.playlist_pos)) { + _ = playPlaylistEntry(p.mpv_ctx, entry.id); + footer.closePickers(); + break; + } + } + if (playback.playlist_count > 128) _ = dvui.label(@src(), "Showing first 128 tracks", .{}, .{ .color_text = theme.colors.text_secondary, .font = dvui.themeGet().font_body.withSize(theme.font_size.small) }); + if (!open) footer.closePickers(); +} + pub fn renderPlaylistPickerPopover(active_p: *player.MediaPlayer) void { if (footer.open_picker != .playlist) return; if (active_p.current_torrent_id < 0) { - footer.open_picker = .none; + renderMpvPlaylist(active_p); return; } const file_count = c.mpv.torrent_get_file_count(state.torrentSession(), active_p.current_torrent_id); diff --git a/src/ui/podcasts_ui.zig b/src/ui/podcasts_ui.zig index a3b75a1e..6bdfe192 100644 --- a/src/ui/podcasts_ui.zig +++ b/src/ui/podcasts_ui.zig @@ -66,12 +66,13 @@ fn renderSearchBar() void { const layout_w = @import("../core/scale_pure.zig").layoutUnits(dvui.windowRect().w, state.app.ui_scale); const search_w = @max(150, @min(280, layout_w - 280)); - const entered = components.toolbarSearch(@src(), &state.app.podcasts.search_buf, "Search podcasts or paste an RSS feed URL…", search_w); - const go = components.toolbarGo(@src(), "Search"); + const local_search = @import("browse_layout_pure.zig").showLocalSearch(state.app.page_shell_enabled); + const entered = components.toolbarSearch(@src(), &state.app.podcasts.search_buf, if (local_search) "Search podcasts or paste an RSS feed URL…" else "RSS feed URL…", search_w); + const go = components.toolbarGo(@src(), if (local_search) "Search" else "Add feed"); if (entered or go) { const q = std.mem.sliceTo(&state.app.podcasts.search_buf, 0); - if (q.len > 0) searchPodcasts(q); + if (q.len > 0 and (local_search or std.mem.startsWith(u8, q, "https://") or std.mem.startsWith(u8, q, "http://"))) searchPodcasts(q); } if (state.app.podcasts.is_loading.load(.acquire)) { diff --git a/src/ui/search_gallery_pure.zig b/src/ui/search_gallery_pure.zig new file mode 100644 index 00000000..07427eae --- /dev/null +++ b/src/ui/search_gallery_pure.zig @@ -0,0 +1,107 @@ +//! Geometry policy shared by the native search gallery and its regression tests. +const std = @import("std"); +pub const Shape = enum { portrait, square, landscape }; +pub const Size = struct { w: f32, h: f32 }; +pub const Crop = struct { x: f32 = 0, y: f32 = 0, w: f32 = 1, h: f32 = 1 }; +pub fn shouldFetchCover(has_url: bool, failed: bool, fetching: bool, has_pixels: bool, has_texture: bool) bool { + return has_url and !failed and !fetching and !has_pixels and !has_texture; +} +pub fn coverCrop(image: Size, target: Size) Crop { + if (image.w <= 0 or image.h <= 0 or target.w <= 0 or target.h <= 0) return .{}; + const source_ratio = image.w / image.h; + const target_ratio = target.w / target.h; + if (source_ratio > target_ratio) { + const width = target_ratio / source_ratio; + return .{ .x = (1 - width) / 2, .w = width }; + } + const height = source_ratio / target_ratio; + return .{ .y = (1 - height) / 2, .h = height }; +} +pub fn cardSize(shape: Shape, viewport_width: f32) Size { + const compact = viewport_width < 640; + const width: f32 = switch (shape) { + .portrait => if (compact) 128 else 164, + .square => if (compact) 148 else 180, + .landscape => if (compact) 224 else 272, + }; + return .{ .w = width, .h = switch (shape) { + .portrait => width * 1.5, + .square => width, + .landscape => width * 9 / 16, + } }; +} +pub fn stackedHero(width: f32) bool { + return width < 760; +} +pub fn heroCopyGravity(width: f32) f32 { + // Fractional gravity in a vertical Box positions a child as an overlay. + return if (stackedHero(width)) 0 else 0.5; +} +pub fn shelfHeight(art_height: f32, body_height: f32) f32 { + // Two title lines, a metadata line, button, and widget margins/padding. + return art_height + body_height * 4 + 64; +} +pub fn heroArtSize(width: f32, backdrop: bool) Size { + const w = if (backdrop) @min(560, @max(200, if (stackedHero(width)) width - 24 else width * 0.49)) else @min(184, @max(112, width * 0.2)); + return .{ .w = w, .h = if (backdrop) w * 9 / 16 else w * 1.5 }; +} +pub fn retainSelection(identities: []const u64, selected: u64) ?usize { + if (identities.len == 0) return null; + for (identities, 0..) |identity, index| if (identity == selected and selected != 0) return index; + return 0; +} +pub fn shouldFeature(query: []const u8, title: []const u8, has_art: bool, selected: bool) bool { + if (selected) return true; + if (!has_art) return false; + return std.ascii.eqlIgnoreCase(std.mem.trim(u8, query, " \t\r\n"), std.mem.trim(u8, title, " \t\r\n")); +} +test "search cards retain meaningful aspect ratios at narrow and wide widths" { + for ([_]f32{ 320, 640, 1440 }) |width| { + const portrait = cardSize(.portrait, width); + const square = cardSize(.square, width); + const video = cardSize(.landscape, width); + try std.testing.expectApproxEqAbs(@as(f32, 1.5), portrait.h / portrait.w, 0.001); + try std.testing.expectEqual(square.w, square.h); + try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 9.0), video.w / video.h, 0.001); + } +} +test "search hero stacks on narrow windows without expanding portrait artwork" { + try std.testing.expect(stackedHero(600)); + try std.testing.expect(!stackedHero(1000)); + try std.testing.expect(heroArtSize(1440, false).w <= 184); + try std.testing.expect(heroArtSize(320, true).w <= 320); + try std.testing.expectEqual(@as(f32, 0), heroCopyGravity(640)); + try std.testing.expectEqual(@as(f32, 0.5), heroCopyGravity(1360)); +} +test "search shelf reserves complete card actions instead of clipping Details" { + for ([_]f32{ 14, 20, 30 }) |body_height| { + const art = cardSize(.portrait, 1360); + try std.testing.expect(shelfHeight(art.h, body_height) >= art.h + body_height * 4 + 48); + } +} +test "progressive search publications preserve selected content by identity" { + try std.testing.expectEqual(@as(?usize, 2), retainSelection(&.{ 1, 9, 5 }, 5)); + try std.testing.expectEqual(@as(?usize, 0), retainSelection(&.{ 1, 9 }, 5)); + try std.testing.expectEqual(@as(?usize, null), retainSelection(&.{}, 5)); +} +test "broad query does not invent a confident featured match" { + try std.testing.expect(!shouldFeature("space", "Space Jam", true, false)); + try std.testing.expect(shouldFeature(" Reacher ", "reacher", true, false)); + try std.testing.expect(!shouldFeature("Reacher", "Reacher", false, false)); + try std.testing.expect(shouldFeature("space", "Space Jam", false, true)); +} +test "search artwork crops centrally instead of stretching square covers" { + const crop = coverCrop(.{ .w = 400, .h = 400 }, .{ .w = 160, .h = 240 }); + try std.testing.expectApproxEqAbs(@as(f32, 2.0 / 3.0), crop.w, 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 1.0 / 6.0), crop.x, 0.001); + const wide = coverCrop(.{ .w = 100, .h = 150 }, .{ .w = 272, .h = 153 }); + try std.testing.expect(wide.h < 1 and wide.y > 0); + try std.testing.expectEqual(Crop{}, coverCrop(.{ .w = 0, .h = 0 }, .{ .w = 1, .h = 1 })); +} +test "ready artwork must not refetch every rendered frame" { + try std.testing.expect(shouldFetchCover(true, false, false, false, false)); + try std.testing.expect(!shouldFetchCover(true, false, false, false, true)); + try std.testing.expect(!shouldFetchCover(true, false, false, true, false)); + try std.testing.expect(!shouldFetchCover(true, false, true, false, false)); + try std.testing.expect(!shouldFetchCover(true, true, false, false, false)); +} diff --git a/src/ui/settings.zig b/src/ui/settings.zig index 6499723e..9723cc76 100644 --- a/src/ui/settings.zig +++ b/src/ui/settings.zig @@ -248,7 +248,7 @@ fn sectionMatchesSearch(tab: state.SettingsTab) bool { if (search_len == 0) return true; const sections: []const []const u8 = switch (tab) { .General => &.{ "Interface", "Behavior", "TMDB", "Theme", "Scale", "Grid", "NSFW", "Seek Sync", "API Key", "Plugins", "Sources", "Content plugins" }, - .Playback => &.{ "Video Processing", "Audio Equalizer", "Playback Extras", "External VLC", "Prefetch", "Passthrough", "Exclusive", "Audio Output", "Device", "Streaming", "Shortcuts", "Filters", "Capture", "Hardware", "Decode", "Deband", "Interpolation", "Brightness", "Contrast", "Saturation", "Gamma", "Screenshot", "Auto-advance", "Resume" }, + .Playback => &.{ "Video Processing", "Audio Equalizer", "Playback Extras", "Prefetch", "Passthrough", "Exclusive", "Audio Output", "Device", "Streaming", "Shortcuts", "Filters", "Capture", "Hardware", "Decode", "Deband", "Interpolation", "Brightness", "Contrast", "Saturation", "Gamma", "Screenshot", "Auto-advance", "Resume" }, .About => &.{ "About", "Version", "Update", "Credits", "License", "Donate", "Sponsors", "Links", "TMDB" }, .Subtitles => &.{ "OpenSubtitles", "Subdl", "Language", "Search", "API Key", "Font", "Delay", "Whisper" }, .Network => &.{ "Torrent", "Memory", "RAM", "Buffer", "Streaming", "Download", "Trackers", "Proxy", "Speed", "Limit", "Port", "Browser", "Engine", "Camoufox", "CloakBrowser", "Audiobookshelf", "Audiobook", "OPDS", "Reading", "Komga", "Kavita", "Calibre" }, @@ -1192,7 +1192,7 @@ fn renderGeneralTab() void { settingRow("UI Scale", 100, @src()); { const scales = [_]f32{ 0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4, 1.6, 2.0 }; - const scale_labels = [_][]const u8{ "Auto ≥1.0x", "0.6x", "0.7x", "0.8x", "0.9x", "1.0x", "1.2x", "1.4x", "1.6x", "2.0x" }; + const scale_labels = [_][]const u8{ "Auto (1.0x+)", "0.6x", "0.7x", "0.8x", "0.9x", "1.0x", "1.2x", "1.4x", "1.6x", "2.0x" }; // Index 0 = Auto; manual values are offset by one. var sel: usize = 0; if (!state.app.ui_scale_auto) { @@ -1718,38 +1718,6 @@ fn renderPlaybackTab() void { state.showToast("Applies to the next file you open"); } } - // External VLC is an opt-in handoff; embedded playback stays untouched. - sectionHeader("External VLC", "Open the current file or direct stream in a separate VLC window", 251, @src()); - { - const external = @import("../services/external_player.zig"); - const has_player = state.app.active_player_idx < state.app.players.items.len; - const media = if (has_player) state.app.players.items[state.app.active_player_idx] else null; - const url = if (media) |p| p.current_url[0..p.current_url_len] else ""; - if (external.playable(url)) { - if (dvui.button(@src(), "Open current media in VLC", .{}, .{ - .color_fill = theme.colors.bg_elevated, - .color_text = theme.colors.accent, - .corner_radius = theme.dims.rad_sm, - .padding = .{ .x = theme.spacing.md, .y = theme.spacing.xs, .w = theme.spacing.md, .h = theme.spacing.xs }, - })) { - if (!external.launch(url)) state.showToastTyped("Could not queue VLC launch · see Logs", .err); - } - } else { - _ = dvui.label(@src(), "Play a file or direct stream to open it in VLC; internal Opal sources cannot be handed off.", .{}, .{ .color_text = theme.colors.text_secondary }); - } - const outcome = external.status(); - if (outcome != .idle) { - _ = dvui.label(@src(), "VLC: {s}", .{switch (outcome) { - .launching => "Starting…", - .opened => "Opened externally", - .failed => "Could not open · see Logs", - .idle => unreachable, - }}, .{ .color_text = if (outcome == .failed) theme.colors.danger else theme.colors.text_secondary }); - if (outcome == .launching) components.pollRefresh(100_000); - } - _ = dvui.label(@src(), "Requires VLC installed (standard Windows, macOS or Linux location, or on PATH). Direct URLs and files only; streams requiring Opal's private headers cannot be transferred. Local torrent streams require Opal to remain open.", .{}, .{ .color_text = theme.colors.text_tertiary }); - } - // ── Audio Output ── sectionHeader("Audio Output", "Route sound to a specific output device", 26, @src()); { diff --git a/src/ui/shell.zig b/src/ui/shell.zig index 60e3cc79..2cb52a4f 100644 --- a/src/ui/shell.zig +++ b/src/ui/shell.zig @@ -358,15 +358,6 @@ fn renderPlayerTopNav() void { spacer.deinit(); if (state.app.active_player_idx < state.app.players.items.len) { const p = state.app.players.items[state.app.active_player_idx]; - const external = @import("../services/external_player.zig"); - const url = p.current_url[0..p.current_url_len]; - if (external.playable(url) and components.iconButtonOverlay(@src(), icons.tvg.lucide.@"external-link", "Open in VLC", false, true)) { - if (external.launch(url)) { - state.showToast("Opening in VLC…"); - } else { - state.showToastTyped("Could not open in VLC · see Logs", .err); - } - } // The transport footer is intentionally absent before the first frame, // so its normal Close action does not exist while a URL is resolving. // Keep cancellation in the player chrome where it remains visible and @@ -600,11 +591,9 @@ fn closeOverflowMenu() void { } /// One query entry, with identical routing for Enter and the Search button. -/// Browse queries stay contextual; All content queries use the shared resolver. +/// Every ordinary query uses the shared all-content resolver. fn omnibox(narrow: bool) void { - const placeholder: []const u8 = if (state.app.router.current == .browse) - "Search this source or paste a link…" - else if (narrow) + const placeholder: []const u8 = if (narrow) "Search, ask, paste…" else "Search everything, ask, or paste a link…"; @@ -672,10 +661,8 @@ fn omnibox(narrow: bool) void { }, .open => openOmniboxTarget(text), .search => { - if (!browser.searchCurrentBrowse(text)) { - search_mod.submitQuery(text); - state.app.router.navigate(.search); - } + search_mod.submitQuery(text); + state.app.router.navigate(.search); }, } } diff --git a/src/ui/shell_native_test.zig b/src/ui/shell_native_test.zig new file mode 100644 index 00000000..178daf81 --- /dev/null +++ b/src/ui/shell_native_test.zig @@ -0,0 +1,269 @@ +//! Actual full-shell pixels with explicit offline fixtures and isolated HOME. +const std = @import("std"); +const dvui = @import("dvui"); +const state = @import("../core/state.zig"); +const router = @import("../core/router.zig"); +const theme = @import("theme.zig"); +const alloc = @import("../core/alloc.zig").allocator; +const search = @import("../services/search.zig"); +const resolver = @import("../services/resolver.zig"); +const capture = @import("native_capture.zig"); +const Case = struct { name: []const u8, route: router.Route, tab: state.DrawerTab = .TMDB, settings: state.SettingsTab = .General, plugin: router.PluginTab = .sources }; +var current: Case = undefined; +var first_frame = true; +var preset: theme.ThemePreset = .midnight; +var fixture_rows: ?[]resolver.ResolvedItem = null; +const library = @import("../services/local_library.zig"); +const local_ui = @import("local_library_ui.zig"); +var local_items: [3]library.Item = undefined; +var local_roots: [1]library.Root = undefined; +var frames: usize = 0; +var captured: usize = 0; +fn captureCase(size: [2]u32) !void { + const io = @import("../core/io_global.zig"); + if (io.getenv("OPAL_SHELL_CASE") orelse io.getenv("OPAL_NATIVE_CASE")) |filter| { + var names = std.mem.splitScalar(u8, filter, ','); + var selected = false; + while (names.next()) |name| { + if (name.len > 0 and std.mem.startsWith(u8, current.name, name)) selected = true; + } + if (!selected) return; + } + try capture.capture(size[0], size[1], current.name, setup, draw, cleanup); + captured += 1; +} +fn field(buffer: []u8, len: *usize, value: []const u8) void { + len.* = @min(buffer.len, value.len); + @memcpy(buffer[0..len.*], value[0..len.*]); +} +fn setup() !void { + theme.active_preset = preset; + state.app.page_shell_enabled = true; + state.app.ui_scale = 1; + state.app.router.current = current.route; + state.app.browse_source = current.tab; + state.app.settings_tab = current.settings; + state.app.plugin_tab = current.plugin; + state.app.config_loaded.store(current.route == .settings, .release); + state.app.content_cache_enabled = false; + state.app.taste_enabled = false; + + first_frame = true; + frames = 0; + if (current.route == .watching) { + for (&local_items, 0..) |*item, i| { + item.* = .{ .id = @intCast(i + 1), .size = 2_400_000_000 }; + field(&item.title, &item.title_len, "A long offline library title — episode with multilingual subtitles and director commentary"); + field(&item.path, &item.path_len, "/offline-fixture/long-folder-name/representative-episode-with-a-long-filename.mkv"); + field(&item.kind, &item.kind_len, "video"); + } + local_roots[0] = .{ .id = 1 }; + field(&local_roots[0].path, &local_roots[0].path_len, "/offline-fixture/representative-library-root-with-a-long-folder-name"); + local_ui.setFixtureForTest(&local_items, &local_roots, local_items[0]); + } + if (current.route == .search) { + const rows = try alloc.alloc(resolver.ResolvedItem, 8); + fixture_rows = rows; + const names = [_][]const u8{ "Reacher", "千と千尋の神隠し — Spirited Away", "Offline anime fixture", "Offline comic fixture", "Offline book fixture", "Offline song fixture", "Offline podcast fixture", "Unmatched release fixture" }; + for (rows, 0..) |*row, i| { + row.* = .{}; + row.source = switch (i) { + 0, 1 => .tmdb, + 2 => .anime, + 3 => .comics, + 4 => .novels, + 5 => .music, + 6 => .podcast, + else => .torrent, + }; + row.catalog_id = if (i < 2) @intCast(i + 1) else 0; + field(&row.catalog_kind, &row.catalog_kind_len, if (i == 0) "tv" else "movie"); + field(&row.name, &row.name_len, names[i]); + field(&row.summary, &row.summary_len, "Offline layout fixture. Covers and content are representative geometry; provider availability and playback are not asserted."); + field(&row.url, &row.url_len, names[i]); + field(&row.poster_url, &row.poster_url_len, "fixture://offline-cover"); + row.match_pct = 100; + row.year = 2022; + } + resolver.clearResults(); + resolver.results_mutex.lock(); + @memcpy(resolver.results[0..rows.len], rows); + resolver.result_count = rows.len; + field(&resolver.resolver_query, &resolver.resolver_query_len, "Offline layout fixtures"); + resolver.results_mutex.unlock(); + search.setUniversalQuery("Offline layout fixtures"); + } else @memset(&state.app.magnet_buf, 0); +} +fn texture(index: usize) !dvui.Texture { + var rgba: [64 * 96 * 4]u8 = undefined; + for (0..64 * 96) |p| { + rgba[p * 4] = @intCast(35 + index * 15); + rgba[p * 4 + 1] = @intCast(40 + p / 64); + rgba[p * 4 + 2] = @intCast(100 + index * 12); + rgba[p * 4 + 3] = 255; + } + return dvui.Texture.fromImageSource(.{ .pixels = .{ .rgba = &rgba, .width = 64, .height = 96, .interpolation = .linear } }); +} +fn draw() !void { + @import("components.zig").clearToggleBoundsForTest(); + if (first_frame and current.route == .browse and current.tab == .Anime) { + var items: [8]state.AnimeResult = undefined; + for (&items, 0..) |*item, i| { + item.* = .{ .anilist_id = @intCast(i + 1), .year = 2025, .score = 8.8, .episodes = 24, .poster_attempted = true }; + field(&item.name, &item.name_len, "A long anime title — The Extraordinary Adventure Beyond the Horizon"); + field(&item.atype, &item.atype_len, "TV"); + item.poster_tex = try texture(i); + } + @import("../services/anime.zig").setNativeFixtureForTest(&items); + } + if (first_frame and current.route == .browse and (current.tab == .Comics or current.tab == .Novels)) { + var textures: [8]dvui.Texture = undefined; + for (&textures, 0..) |*art, i| art.* = try texture(i); + if (current.tab == .Comics) @import("../services/comics.zig").setNativeFixtureForTest(&textures) else @import("../services/novels.zig").setNativeFixtureForTest(&textures); + } + if (first_frame and (current.route == .home or (current.route == .browse and current.tab == .TMDB))) { + state.app.tmdb.loaded_once = true; + state.app.tmdb.view = .Search; + for (0..8) |i| { + var item: state.TmdbItem = .{ .id = @intCast(i + 1), .rating = 8.7, .poster_attempted = true }; + field(&item.title, &item.title_len, "A long cinema title — The Extraordinary Journey Beyond the Horizon"); + field(&item.year, &item.year_len, "2025"); + field(&item.media_type, &item.media_type_len, "movie"); + field(&item.overview, &item.overview_len, "Offline visual fixture for title clipping, poster proportions and card actions."); + item.poster_tex = try texture(i); + if (current.route == .home) try state.app.tmdb.watchlist.append(alloc, item) else try state.app.tmdb.results.append(alloc, item); + } + } + if (first_frame and current.route == .browse and current.tab == .YouTube) { + state.app.yt.loaded_once = true; + state.app.yt.last_fetch_s = 9_999_999_999; + for (0..8) |i| { + var item: state.YtItem = .{ .duration = 3661, .views = 1234567, .thumb_attempted = true }; + field(&item.title, &item.title_len, "A long video title — Exploring the world through cinema and music"); + field(&item.uploader, &item.uploader_len, "Offline fixture channel"); + item.thumb_tex = try texture(i); + try state.app.yt.results.append(alloc, item); + } + } + if (first_frame and current.route == .search) { + // Only explicit fixtures receive test textures; no artwork worker runs. + const rows = fixture_rows orelse return error.MissingFixture; + // Fixture identities are copied by the production view cache; expose + // only its test snapshot here rather than a different renderer. + for (rows, 0..) |*row, i| { + const art = try texture(i); + search.setGalleryTextureForTest(resolver.content.identity(row.*), row.poster_url[0..row.poster_url_len], art, art.width, art.height); + } + } + first_frame = false; + try @import("shell.zig").render(); + frames += 1; + if (current.route == .settings and frames >= 4) { + try std.testing.expect(state.app.config_loaded.load(.acquire)); + const toggles = @import("components.zig").toggleBoundsForTest(); + if (current.settings == .General) try std.testing.expect(toggles.len > 0); + for (toggles) |bounds| { + try std.testing.expect(bounds.pill.w > 0 and bounds.pill.h > 0); + try std.testing.expect(bounds.pill.x >= bounds.row.x and bounds.pill.x + bounds.pill.w <= bounds.row.x + bounds.row.w + 1); + } + } + if (current.route == .browse and current.tab == .Anime and frames >= 4) { + const toolbar = @import("../services/anime.zig").nativeToolbarRectForTest(); + const height = @import("browse_layout_pure.zig").toolbarHeight(dvui.themeGet().font_body.size); + try std.testing.expect(toolbar.h > 0 and toolbar.h <= height * dvui.windowNaturalScale() + 1); + } + if (current.route == .browse and current.tab == .Novels and frames >= 4) { + const viewport = dvui.windowRectPixels(); + for (@import("../services/novels.zig").nativeCardRectsForTest()) |card| { + try std.testing.expect(card.w > 0 and card.h > 0); + try std.testing.expect(card.x >= viewport.x and card.x + card.w <= viewport.x + viewport.w + 1); + } + } + if (current.route == .watching and frames >= 4) { + const rows = local_ui.layoutRowsForTest(); + try std.testing.expect(rows[0].y + rows[0].h <= rows[1].y + 1); + try std.testing.expect(rows[1].y + rows[1].h <= rows[2].y + 1); + const editor = local_ui.editorFieldsForTest(); + try std.testing.expect(editor[0].h > 0 and editor[1].h > 0); + try std.testing.expect(@abs(editor[0].h - editor[1].h) <= 1); + } +} +fn cleanup() void { + search.shutdown(); + search.deinitGallery(); + if (fixture_rows) |rows| alloc.free(rows); + fixture_rows = null; + resolver.clearResults(); + local_ui.setFixtureForTest(null, null, null); + @import("../services/tmdb.zig").freeImageBuffers(); + for (state.app.tmdb.results.items) |item| if (item.poster_tex) |art| dvui.textureDestroyLater(art); + state.app.tmdb.results.deinit(alloc); + state.app.tmdb.results = .empty; + for (state.app.tmdb.watchlist.items) |item| if (item.poster_tex) |art| dvui.textureDestroyLater(art); + state.app.tmdb.watchlist.deinit(alloc); + state.app.tmdb.watchlist = .empty; + for (state.app.yt.results.items) |item| { + if (item.thumb_tex) |art| dvui.textureDestroyLater(art); + if (item.thumb_pixels) |pixels| alloc.free(pixels); + } + state.app.yt.results.deinit(alloc); + state.app.yt.results = .empty; + for (state.app.anime.results[0..state.app.anime.result_count]) |*item| { + if (item.poster_tex) |art| dvui.textureDestroyLater(art); + item.poster_tex = null; + } + @import("../services/anime.zig").setNativeFixtureForTest(&.{}); + @import("../services/comics.zig").setNativeFixtureForTest(&.{}); + @import("../services/novels.zig").setNativeFixtureForTest(&.{}); +} +test "Native shell offline route pixel capture" { + captured = 0; + const workers = @import("../core/workers.zig"); + @import("../core/logs.zig").logs_allocator = alloc; + defer @import("../core/logs.zig").deinit(); + workers.init(); + // Render-time initial loads may be requested but are not admitted. This + // harness performs no provider requests, background bootstrap or playback. + workers.beginShutdownAndDrain(0); + defer workers.finishShutdown(); + const cases = [_]Case{ + .{ .name = "home", .route = .home }, .{ .name = "search", .route = .search }, + .{ .name = "watching", .route = .watching }, .{ .name = "downloads", .route = .downloads }, + .{ .name = "queue", .route = .queue }, .{ .name = "history", .route = .history }, + .{ .name = "assistant", .route = .assistant }, .{ .name = "system", .route = .system }, + .{ .name = "player-empty", .route = .player }, + }; + const browse = [_]state.DrawerTab{ .TMDB, .YouTube, .Iptv, .Anime, .Podcasts, .Radio, .Music, .Comics, .Web, .RSS, .Jellyfin, .Plex, .Audiobooks, .Opds, .Novels, .Vndb, .Drama }; + for ([_][2]u32{ .{ 1360, 1000 }, .{ 640, 800 } }) |size| { + preset = .midnight; + for (cases) |item| { + current = item; + try captureCase(size); + } + for (browse) |tab| { + var name: [64]u8 = undefined; + current = .{ .name = try std.fmt.bufPrint(&name, "browse-{s}", .{@tagName(tab)}), .route = .browse, .tab = tab }; + try captureCase(size); + } + inline for (std.meta.fields(state.SettingsTab)) |entry| { + var name: [64]u8 = undefined; + current = .{ .name = try std.fmt.bufPrint(&name, "settings-{s}", .{entry.name}), .route = .settings, .settings = @enumFromInt(entry.value) }; + try captureCase(size); + } + for (router.PLUGIN_TABS) |tab| { + var name: [64]u8 = undefined; + current = .{ .name = try std.fmt.bufPrint(&name, "plugins-{s}", .{@tagName(tab)}), .route = .plugins, .plugin = tab }; + try captureCase(size); + } + for ([_]theme.ThemePreset{ .ember, .nord }) |alternate| { + preset = alternate; + for ([_]Case{ cases[0], cases[1], .{ .name = "browse-TMDB", .route = .browse }, .{ .name = "settings-General", .route = .settings } }) |item| { + var name: [96]u8 = undefined; + current = item; + current.name = try std.fmt.bufPrint(&name, "{s}-{s}", .{ item.name, @tagName(alternate) }); + try captureCase(size); + } + } + } + try std.testing.expect(captured > 0); +} diff --git a/src/ui/theme.zig b/src/ui/theme.zig index 6f04c9f7..faca53d0 100644 --- a/src/ui/theme.zig +++ b/src/ui/theme.zig @@ -57,6 +57,9 @@ pub const ThemeColors = struct { }; // ── Theme Presets ── +// Small captions share the text tiers with labels and fields. Keep each tier +// readable on the brightest opaque surface; raised controls must not erase +// metadata. The feature gate checks every shipped text/surface pair. const midnight_colors = ThemeColors{ .bg_deep = .{ .r = 10, .g = 10, .b = 15, .a = 255 }, @@ -73,7 +76,7 @@ const midnight_colors = ThemeColors{ .accent_glow = .{ .r = 93, .g = 208, .b = 255, .a = 51 }, .text_primary = .{ .r = 250, .g = 250, .b = 250, .a = 255 }, .text_secondary = .{ .r = 168, .g = 168, .b = 178, .a = 255 }, - .text_tertiary = .{ .r = 110, .g = 110, .b = 120, .a = 255 }, + .text_tertiary = .{ .r = 134, .g = 134, .b = 142, .a = 255 }, .text_on_accent = .{ .r = 10, .g = 10, .b = 15, .a = 255 }, .danger = .{ .r = 255, .g = 107, .b = 138, .a = 255 }, .success = .{ .r = 93, .g = 255, .b = 161, .a = 255 }, @@ -95,7 +98,7 @@ const abyss_colors = ThemeColors{ .accent_glow = .{ .r = 50, .g = 160, .b = 110, .a = 51 }, .text_primary = .{ .r = 220, .g = 220, .b = 220, .a = 255 }, .text_secondary = .{ .r = 140, .g = 140, .b = 140, .a = 255 }, - .text_tertiary = .{ .r = 90, .g = 90, .b = 90, .a = 255 }, + .text_tertiary = .{ .r = 126, .g = 126, .b = 126, .a = 255 }, .text_on_accent = .{ .r = 0, .g = 0, .b = 0, .a = 255 }, .danger = .{ .r = 175, .g = 65, .b = 65, .a = 255 }, .success = .{ .r = 50, .g = 155, .b = 95, .a = 255 }, @@ -117,7 +120,7 @@ const phantom_colors = ThemeColors{ .accent_glow = .{ .r = 125, .g = 110, .b = 185, .a = 51 }, .text_primary = .{ .r = 230, .g = 226, .b = 240, .a = 255 }, .text_secondary = .{ .r = 165, .g = 155, .b = 185, .a = 255 }, - .text_tertiary = .{ .r = 115, .g = 105, .b = 140, .a = 255 }, + .text_tertiary = .{ .r = 141, .g = 133, .b = 161, .a = 255 }, .text_on_accent = .{ .r = 10, .g = 6, .b = 18, .a = 255 }, .danger = .{ .r = 185, .g = 95, .b = 110, .a = 255 }, .success = .{ .r = 65, .g = 165, .b = 105, .a = 255 }, @@ -138,8 +141,8 @@ const nord_colors = ThemeColors{ .accent_dim = .{ .r = 110, .g = 150, .b = 165, .a = 64 }, .accent_glow = .{ .r = 110, .g = 150, .b = 165, .a = 51 }, .text_primary = .{ .r = 229, .g = 233, .b = 240, .a = 255 }, - .text_secondary = .{ .r = 170, .g = 178, .b = 192, .a = 255 }, - .text_tertiary = .{ .r = 130, .g = 138, .b = 150, .a = 255 }, + .text_secondary = .{ .r = 220, .g = 224, .b = 232, .a = 255 }, + .text_tertiary = .{ .r = 209, .g = 212, .b = 216, .a = 255 }, .text_on_accent = .{ .r = 36, .g = 42, .b = 54, .a = 255 }, .danger = .{ .r = 160, .g = 85, .b = 95, .a = 255 }, .success = .{ .r = 130, .g = 155, .b = 115, .a = 255 }, @@ -161,7 +164,7 @@ const solarized_colors = ThemeColors{ .accent_glow = .{ .r = 160, .g = 85, .b = 50, .a = 51 }, .text_primary = .{ .r = 238, .g = 232, .b = 213, .a = 255 }, .text_secondary = .{ .r = 165, .g = 175, .b = 175, .a = 255 }, - .text_tertiary = .{ .r = 110, .g = 125, .b = 130, .a = 255 }, + .text_tertiary = .{ .r = 161, .g = 171, .b = 174, .a = 255 }, .text_on_accent = .{ .r = 0, .g = 36, .b = 46, .a = 255 }, .danger = .{ .r = 170, .g = 65, .b = 60, .a = 255 }, .success = .{ .r = 110, .g = 130, .b = 50, .a = 255 }, @@ -183,7 +186,7 @@ const rose_colors = ThemeColors{ .accent_glow = .{ .r = 175, .g = 100, .b = 140, .a = 51 }, .text_primary = .{ .r = 240, .g = 230, .b = 238, .a = 255 }, .text_secondary = .{ .r = 170, .g = 140, .b = 158, .a = 255 }, - .text_tertiary = .{ .r = 120, .g = 90, .b = 105, .a = 255 }, + .text_tertiary = .{ .r = 153, .g = 130, .b = 141, .a = 255 }, .text_on_accent = .{ .r = 12, .g = 6, .b = 12, .a = 255 }, .danger = .{ .r = 185, .g = 75, .b = 75, .a = 255 }, .success = .{ .r = 90, .g = 170, .b = 140, .a = 255 }, @@ -205,7 +208,7 @@ const ember_colors = ThemeColors{ .accent_glow = .{ .r = 185, .g = 120, .b = 65, .a = 51 }, .text_primary = .{ .r = 240, .g = 228, .b = 210, .a = 255 }, .text_secondary = .{ .r = 175, .g = 150, .b = 120, .a = 255 }, - .text_tertiary = .{ .r = 125, .g = 105, .b = 78, .a = 255 }, + .text_tertiary = .{ .r = 154, .g = 139, .b = 118, .a = 255 }, .text_on_accent = .{ .r = 14, .g = 10, .b = 6, .a = 255 }, .danger = .{ .r = 185, .g = 95, .b = 95, .a = 255 }, .success = .{ .r = 65, .g = 165, .b = 105, .a = 255 }, @@ -360,6 +363,7 @@ pub const font_size = struct { // than a synthetic weight. const app_font_family = "Noto Sans"; const app_font_sources = [_]dvui.Font.Source{ + .{ .family = dvui.Font.array("Noto Sans CJK"), .bytes = @embedFile("../assets/fonts/NotoSansKR-Regular.ttf") }, .{ .family = dvui.Font.array(app_font_family), .bytes = @embedFile("../assets/fonts/NotoSans-Regular.ttf"), @@ -371,6 +375,12 @@ const app_font_sources = [_]dvui.Font.Source{ }, }; +/// Keep theme metrics/colors; use the lazy CJK face only for matching titles. +pub fn mediaTitleFont(text: []const u8, base: dvui.Font) dvui.Font { + if (!@import("footer_pure.zig").needsCjkFont(text)) return base; + return base.withFamily("Noto Sans CJK").withWeight(.normal); +} + // ── Dimensions (legacy dvui.Rect helpers) ── pub const dims = struct { diff --git a/tests/features/test_library.py b/tests/features/test_library.py index 60333b1c..2c2412b5 100644 --- a/tests/features/test_library.py +++ b/tests/features/test_library.py @@ -220,7 +220,7 @@ def movie_preferences_persist(): "movie detail rating": "personalRatingOptions" in web and "Rated ${rating.value} / 10" in web, "mutations use POST helper": "apiMutation('/library/item/action?kind=movie" in web, "favorite can reopen": "opal://search/{s}" in remote and '"opal://search/"' in browser - and "triggerSearch(query)" in browser, + and "submitQuery(query)" in browser, } missing = [name for name, ok in checks.items() if not ok] if missing: diff --git a/tests/features/test_page_shell2.py b/tests/features/test_page_shell2.py index 3da96ff4..7c699de9 100644 --- a/tests/features/test_page_shell2.py +++ b/tests/features/test_page_shell2.py @@ -547,7 +547,8 @@ def test_auto_scale_floor_and_persistence(): ), "both values persist": 'setKey("ui_scale",' in cfg and 'setKey("ui_scale_auto",' in cfg, - "Auto is segment zero": '"Auto ≥1.0x"' in sg + # The ASCII label avoids a missing comparison glyph in the UI font. + "Auto is segment zero": 'const scale_labels = [_][]const u8{ "Auto (1.0x+)"' in sg and "if (clicked == 0)" in sg, "manual indices are offset": "sel = idx + 1" in sg and "scales[clicked - 1]" in sg, diff --git a/tests/features/test_player.py b/tests/features/test_player.py index 6825ee7a..844fff29 100644 --- a/tests/features/test_player.py +++ b/tests/features/test_player.py @@ -4,6 +4,25 @@ from .harness import * # noqa: F401,F403 import os, sys, subprocess, sqlite3, socket, time, json # noqa: F401 +@test("Playback stays in Opal without external player handoffs", "Player") +def test_no_external_player_handoff(): + obsolete = os.path.join(PROJECT_DIR, "src/services/external_player.zig") + if os.path.exists(obsolete): + return "fail", "External player launch service must not be shipped" + offenders = [] + forbidden = ("Open in VLC", "Open current media in VLC", "External VLC", + "external_player.zig", "vlc://", "Open in IINA", "iina://") + for directory in ("src", "web"): + for root, _, files in os.walk(os.path.join(PROJECT_DIR, directory)): + for name in files: + if not name.endswith((".zig", ".js", ".html")): + continue + path = os.path.join(root, name) + text = open(path, encoding="utf-8").read() + if any(marker.lower() in text.lower() for marker in forbidden): + offenders.append(os.path.relpath(path, PROJECT_DIR)) + return ("fail", ", ".join(offenders)) if offenders else ("pass", "Native and web playback have no external-player launch path") + @test("Typed Playback Load Seam", "Player") def test_typed_playback_load_seam(): player = _src("src/player/player.zig") @@ -1836,7 +1855,10 @@ def test_compact_native_playback_strip(): 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, + "native title policy used": "showMediaTitleInControls(@import(\"builtin\").os.tag == .macos)" in ft and "if (show_media_title or footer_pure.transportBusy(transport))" in controls, + "busy status preserved": "footer_pure.transportLabel(transport)" in controls and "dock_transport" in ft, + "macOS title policy regression": "macOS controls omit duplicate window title" in fp, + "other desktops retain measured title": "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, @@ -1851,4 +1873,4 @@ def test_compact_native_playback_strip(): "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") + return ("fail", ", ".join(bad)) if bad else ("pass", "40pt strip omits duplicate macOS title while retaining busy status, subtitle options and confirmed transfer actions") diff --git a/tests/features/test_search_v2.py b/tests/features/test_search_v2.py index f8b43caf..1f601386 100644 --- a/tests/features/test_search_v2.py +++ b/tests/features/test_search_v2.py @@ -45,14 +45,15 @@ def test_search_v2_resolver_projection(): "real torrent properties retained": all(field in projection for field in (".quality", ".seeds", ".leech", ".size_bytes", ".provider")), "personal library sources": all(source in projection for source in (".local", ".jellyfin", ".plex")), "catalog and reader classifications": "search_view.kindFor(source" in projection and all(source in pure for source in (".tmdb", ".anime", ".comics", ".novels", ".opds", ".vndb", ".audiobooks")), - "provider metadata survives cache": "search:v8:" in resolver and "w.blob(it.provider.name())" in resolver + "provider metadata survives cache": "search:v10:" in _src("src/services/search_content_pure.zig") + and "content.cacheIdentity(" in resolver and "w.blob(it.provider.name())" in resolver and "search_view.Provider.init(r.blob()" in resolver, "actual torrent adapter providers": all(f'Provider.init("{name}")' in resolver for name in ("rss", "yts", "eztv")) and "Provider.init(eng_name)" in resolver and "Provider.init(src_id)" in resolver, }, "Owned resolver snapshots preserve stable actions, content policy and actual torrent metadata") -@test("Search v2 native view is compact virtualized and snapshot-owned", "Search") +@test("Search v2 native view uses artwork shelves and owned snapshots", "Search") def test_search_v2_native_view(): search = _src("src/services/search.zig") shell = _src("src/ui/shell.zig") @@ -87,9 +88,11 @@ def test_search_v2_native_view(): "sort popup closes on selection": "popup.close();" in _between(search, "fn searchSelectImpl(", "const CONTENT_LABELS"), "compact sort popup": "searchSelectImpl(91300, &SORT_LABELS, @intFromEnum(view_sort), dense)" in controls and "&SORT_LABELS" in controls and "components.segment(" not in controls and "searchSelect(91300" not in renderer, - "filtered totals": "shown of {d} loaded" in renderer and "view_cache.count" in renderer and "view_cache.loaded" in renderer, - "visible range only": "search_view.visibleRange(" in renderer and "for (range.start..range.end)" in renderer, - "keyboard traversal retained": "keyboardLayoutMode(" in renderer and ".tab" in renderer, + "filtered content totals": "{d} titles · {d} other releases" in renderer and "galleryGroupVisible(group)" in renderer, + "visible artwork only": "art.data().visible()" in renderer and "components.galleryCoverArt(" in renderer, + "keyboard details action retained": 'actionButton(@src(), "Details"' in renderer, + "shared content grouping": "search_content.projectInto(" in renderer and "galleryGroupVisible(" in renderer, + "global search regardless browse route": "browser.searchCurrentBrowse(text)" not in omnibox and "search_mod.submitQuery(text)" in omnibox, "snapshot-owned action": "view_cache.rows.?[action.idx]" in renderer and "resolver.playResolvedItem(item)" in renderer, "safe torrent queue": "torrent_risk_pure.zig" in renderer and "risk.risk == .block" in renderer and "addToQueue(" in renderer, "stable result widget identities": "resolver.actionKey(item)" in row and ".id_extra = row_key" in row, @@ -158,3 +161,34 @@ def test_search_shell_dense_and_query_ownership(): "compact input avoids inherited button and field margins": ".margin = dvui.Rect.all(0)" in icons and ".margin = dvui.Rect.all(0)" in omnibox, }, "Dense icons keep named menus; query aliases are owned and Clear cancels both pipelines") + + +@test("Visual search content grouping previews and stable artwork lifetimes", "Search") +def test_visual_search_gallery(): + search = _src("src/services/search.zig") + grouping = _src("src/services/search_content_pure.zig") + preview = _src("src/services/search_preview.zig") + pure_preview = _src("src/services/search_preview_pure.zig") + main = _src("src/main.zig") + build = _src("build.zig") + return _checked({ + "bounded grouped content projection": "pub fn projectInto(" in grouping and "pub fn evictionIndex(" in grouping, + "artwork card detail and hero": all(name in search for name in ("renderGalleryCard", "renderGalleryHero", "galleryCategoryLabel", "Available sources")), + "stable poster worker addresses": "gallery_covers: [196]GalleryCover" in search and "!entry.slot.fetching" in search, + "progressive selection identity": "gallery_layout.retainSelection(" in search, + "explicit isolated preview": "preview.start()" in search and "preview.select(" in search and "state.app.players.append" not in preview, + "hidden or empty feature stops preview": 'view_cache.count == 0) @import("search_preview.zig").stop()' in search + and 'else @import("search_preview.zig").stop()' in search, + "filtered categories use wrapping native grid": "if (view_filters.content != .all)" in search + and "var grid = dvui.flexbox" in search, + "preview shares typed load transport": "loadDetached(handle" in preview and '"loadfile"' not in preview, + "verified embed allowed by production CSP": "frame-src 'self' https://www.youtube-nocookie.com" in _src("src/services/remote_static.zig"), + "preview stop before worker barrier": main.index("search.shutdown();") < main.index("workers.beginShutdownAndDrain(800)"), + "artwork cleanup after worker barrier": main.index("workers.beginShutdownAndDrain(800)") < main.index("search.deinitGallery();"), + "verified preview parser on production path": "pure.parseForId(" in preview and "pub fn parseForId(" in pure_preview, + "ready artwork fetch decisions use tested policy": "shouldFetchCover(" in _src("src/ui/components.zig"), + "saved titles reopen universal query": '.search =>' in _src("src/ui/home.zig") and 'submitQuery(query)' in _src("src/services/browser.zig"), + "saved native title uses portable search link": '"opal://search/{s}"' in search + and 'item.poster_url[0..item.poster_url_len], reopen)' in search, + "regressions registered": all(f'b.path("{file}")' in build for file in ("src/ui/search_gallery_pure.zig", "src/services/search_content_pure.zig", "src/services/search_preview_pure.zig")), + }, "Content shelves use stable artwork, explicit isolated previews and strict grouped sources") diff --git a/tests/features/test_source_layer.py b/tests/features/test_source_layer.py index 60b49410..a12a71ac 100644 --- a/tests/features/test_source_layer.py +++ b/tests/features/test_source_layer.py @@ -719,7 +719,8 @@ def test_resolver_sink(): "sink carries a query": "query: []const u8 = \"\"," in rv, # Real consumer — the sink is not a speculative abstraction. "warm exists": "pub fn warmQuery(" in rv, - "warm writes to the cache": "content_cache.put(cacheKey(&key_buf, query)" in rv, + "warm writes to the scoped cache": "content_cache.put(cacheKey(&key_buf, query, cache_scope)" in rv + and "current_scope.fingerprint != cache_scope.fingerprint" in rv, # Narrow on purpose: speculative work must not cost a full search. "warm is narrow": "resolveEztv(qbuf, query.len)" in rv and "resolveTorznab(qbuf" in rv, # Superseded-wave guard applies to the live path only; a warm has no wave. diff --git a/tests/features/test_stability.py b/tests/features/test_stability.py index 4e67f31d..e016e135 100644 --- a/tests/features/test_stability.py +++ b/tests/features/test_stability.py @@ -5,6 +5,24 @@ import os, sys, subprocess, sqlite3, socket, time, json # noqa: F401 +@test("macOS idle desktop pumps pending close signals", "Stability") +def test_native_idle_signal_pump(): + main = _src("src/main.zig") + frame = _between(main, "fn appFrame()", "fn renderHudOverlay()") + pure = _src("src/core/event_loop_pure.zig") + checks = { + "shipped bounded policy": "nativeSignalPumpMicros(builtin.os.tag)" in frame, + "frame timer rearms only when due": "dvui.timerDoneOrNone(signal_tick)" in frame + and "dvui.timer(signal_tick, interval)" in frame, + "macOS only, one second": "os == .macos" in pure and "1_000_000 else null" in pure, + "unit gate registration": 'b.path("src/core/event_loop_pure.zig")' in _src("build.zig"), + "actual isolated signal regression": "os.kill(owned[0], signal.SIGTERM)" in + _src("tests/test_native_shutdown_live.py"), + } + missing = [name for name, good in checks.items() if not good] + return ("fail", ", ".join(missing)) if missing else ("pass", "idle macOS event wait is bounded; native live shutdown test provided") + + @test("Render hot paths are event-fed; dropped paths are backgrounded", "Stability") def test_render_hot_paths_and_drop_ingest(): player = _src("src/player/player.zig") @@ -267,7 +285,9 @@ def test_smoothness_repaint(): return "fail", "display-rate playback timer still duplicates frame callbacks" if "dvui.refresh(null" in settings or "dvui.refresh(null" in fileassoc: return "fail", "settings worker status still drives an unbounded repaint loop" - if settings.count("components.pollRefresh(") < 13 or "components.pollRefresh(" not in fileassoc: + # Twelve remaining status paths use bounded polling. The former thirteenth + # path belonged to the removed external-player launch service. + if settings.count("components.pollRefresh(") < 12 or "components.pollRefresh(" not in fileassoc: return "fail", "settings worker status is not covered by bounded polling" if "dvui_win" not in ps or "dvui.refresh(win" not in ps: return "fail", "poster worker does not wake the UI after decode" @@ -649,3 +669,30 @@ def test_keyboard_close_uses_normal_teardown_and_unmaps_first(): if missing: return "fail", "keyboard close regression: " + ", ".join(missing) return "pass", "Ctrl+W/Ctrl+Q use normal teardown; native surface unmaps first" + + +@test("Native capture targets isolate every fixture profile", "Stability") +def test_native_capture_profile_isolation(): + build = _src("build.zig") + start = build.find("const native_capture_runs =") + end = build.find('\n }', build.find("for (native_capture_runs)", start)) + scope = build[start:end] if start >= 0 and end > start else "" + runs = ("run_gallery_tests", "run_media_tests", "run_shell_tests", + "run_modal_tests", "run_activity_tests") + fields = ("HOME", "USERPROFILE", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", + "XDG_DATA_HOME", "APPDATA", "LOCALAPPDATA") + checks = { + "all native fixture runners covered": all(f".run = {run}" in scope for run in runs), + "private cache profiles": "native-fixture-profiles" in build and + "std.fs.path.resolve" in scope and "fixture.name" in scope, + "profile environment assigned": all( + f'fixture.run.setEnvironmentVariable("{field}"' in scope for field in fields), + "network worker admission disabled": all( + "beginShutdownAndDrain" in _src(path) for path in ( + "src/ui/shell_native_test.zig", "src/ui/modals_native_test.zig", + "src/ui/activity_native_test.zig")), + } + missing = [label for label, okay in checks.items() if not okay] + if missing: + return "fail", "native fixture isolation: " + ", ".join(missing) + return "pass", "all five native capture runners use private cache HOME/XDG profiles" diff --git a/tests/features/test_theming.py b/tests/features/test_theming.py index ba3958d5..56830a11 100644 --- a/tests/features/test_theming.py +++ b/tests/features/test_theming.py @@ -3,6 +3,36 @@ shared @test decorator, helpers, and run_all().""" from .harness import * # noqa: F401,F403 import os, sys, subprocess, sqlite3, socket, time, json # noqa: F401 +import re + + +@test("Small theme text stays readable on raised surfaces", "Theming") +def test_theme_caption_contrast(): + # Check the shipped tokens directly: captions use these opaque colors, so + # testing a second copied palette would miss future theme regressions. + with open(os.path.join(PROJECT_DIR, "src/ui/theme.zig")) as f: + content = f.read() + + def luminance(rgb): + def linear(value): + value /= 255 + return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 + return sum(linear(v) * weight for v, weight in zip(rgb, (0.2126, 0.7152, 0.0722))) + + palettes = re.findall(r"const (\w+)_colors = ThemeColors\{(.*?)\n\};", content, re.S) + if len(palettes) != 7: + return "fail", f"Expected seven shipped palettes, found {len(palettes)}" + failures = [] + for name, body in palettes: + colors = {key: tuple(map(int, values)) for key, *values in re.findall( + r"\.(\w+) = \.\{ \.r = (\d+), \.g = (\d+), \.b = (\d+)", body)} + for text in ("text_primary", "text_secondary", "text_tertiary"): + for background in ("bg_app", "bg_surface", "bg_hover", "bg_elevated"): + foreground_lum, background_lum = luminance(colors[text]), luminance(colors[background]) + ratio = (max(foreground_lum, background_lum) + 0.05) / (min(foreground_lum, background_lum) + 0.05) + if ratio < 4.5: + failures.append(f"{name}.{text}/{background}: {ratio:.2f}:1") + return ("fail", "; ".join(failures)) if failures else ("pass", "84 shipped text/surface pairs meet 4.5:1") @test("Theme Presets Defined", "Theming") def test_theme_presets(): diff --git a/tests/features/test_universal_search.py b/tests/features/test_universal_search.py index 368e0f22..8dd7c424 100644 --- a/tests/features/test_universal_search.py +++ b/tests/features/test_universal_search.py @@ -50,9 +50,10 @@ def test_universal_search_fanout(): "live tv uses tab-independent entry": "iptv.searchInto(" in res and "searchIptv(" not in res, "live tv carries play hints": "item.http_ua" in res and "item.http_referrer" in res, "play hints survive the cache": res.count("http_ua") >= 5, - "routes through pure parsers": all( - m in res for m in ("music_jiosaavn_pure.zig", "radio_pure.zig", "podcasts_pure.zig") - ), + "routes through pure parsers": all(m in res for m in ("music_subsonic.zig", "radio_pure.zig", "podcasts_pure.zig")) + and all(m in _src("src/services/music_subsonic.zig") for m in ( + "music_jiosaavn_pure.zig", "js_pure.parseSongValue(", "pure.parseSongValue(", + "jf_pure.parseSongValue(", "px_pure.parseSongValue(", "au_pure.parseTrack(")), "workers spawned": all( f"if (sourceOn(.{b})) Spawn.go(resolve" in res for b in ("livetv", "music", "radio", "podcast") ), @@ -83,12 +84,18 @@ def test_universal_search_fanout(): ), # ── Result cap widened so late finishers aren't starved ── - "MAX_RESULTS constant": "pub const MAX_RESULTS: usize = 96;" in res, + "MAX_RESULTS constant": "pub const MAX_RESULTS: usize = content.MAX_ROWS;" in res + and "pub const MAX_ROWS: usize = 192;" in _src("src/services/search_content_pure.zig"), "no stale 64 cap": "result_count >= 64" not in res, "cache blob sized for fallback candidates": "SEARCH_BLOB_CAP: usize = MAX_RESULTS * @sizeOf(ResolvedItem)" in res, # ── Playback routing ── - "music/radio play direct": ".youtube, .stremio, .local, .music, .radio => {" in res, + "public video/radio play direct": ".youtube, .stremio, .local, .radio => {" in res, + "private music preserves connection identity": ".music => {" in res + and 'std.mem.startsWith(u8, url, "opal://music/")' in res + and "playUniversalRoute(url" in res, + "private music classified as library": '.music => std.mem.startsWith(u8, url, "opal://music/")' in res, + "podcasts resolve exact feed": "podcasts.searchPodcasts(item.url[0..item.url_len])" in res, "live tv replays its headers": ".livetv => {" in res and "loadContentDirectMetaHeaders(" in res and "originFromReferer(" in res, "podcast opens its tab": ".podcast => {" in res and "state.navigateToTab(.Podcasts)" in res, "plex resumes with alternate": ".plex => @import(\"plex.zig\").playSearchItem(" in res @@ -139,7 +146,7 @@ def test_universal_search_fanout(): missing = [k for k, ok in checks.items() if not ok] if missing: return "fail", "Fan-out incomplete: " + ", ".join(missing) - return "pass", "Universal search reaches Plex/live TV/music/radio/podcasts; cap 96; personal libraries rank first" + return "pass", "Universal search reaches Plex/live TV/music/radio/podcasts; cap 192; personal libraries rank first" @test("Synced lyrics (lrclib)", "Audio") diff --git a/tests/features/test_web_shell.py b/tests/features/test_web_shell.py index 23d8cf77..04fc4ed5 100644 --- a/tests/features/test_web_shell.py +++ b/tests/features/test_web_shell.py @@ -190,7 +190,7 @@ def test_web_discovery_and_party_workflows(): checks = { "merged search": "function renderUnifiedResults(" in ui and "api('/unified_search?q='" in ui and "api('/unified_search')" in ui, - "opaque source actions": "r.key" in ui and "r.queueable" in ui + "opaque source actions": "r.key" in ui and ("r.queueable" in ui or "row.queueable" in ui or "variant.queueable" in ui) and "apiMutation('/unified_search/' + action" in ui and "resolver.resolve(dq" in remote and "resolver.lockRemoteSnapshot()" in remote diff --git a/tests/features/test_web_ui.py b/tests/features/test_web_ui.py index 50b67902..b2b499bd 100644 --- a/tests/features/test_web_ui.py +++ b/tests/features/test_web_ui.py @@ -1146,7 +1146,7 @@ def test_torrent_files_and_hls(): "hls feature-tested": "window.Hls" in ui and "function openHls(" in ui, "hls loaded from vendor": 'src="vendor/hls.min.js"' in ui, # 543KB inlined would swamp the page — index.html must stay small. - "hls not inlined": len(ui) < 400_000, + "hls not inlined": len(_src("web/index.html")) < 400_000, "vendor file present": os.path.exists(os.path.join(PROJECT_DIR, "web/vendor/hls.min.js")), "bundled into the app": 'cp -R "$ROOT/web/."' in _src("scripts/build-app.sh"), # A live shim must be torn down or it keeps fetching segments. diff --git a/tests/test_content_maturity_live.py b/tests/test_content_maturity_live.py index 703ca7e8..d96957bb 100644 --- a/tests/test_content_maturity_live.py +++ b/tests/test_content_maturity_live.py @@ -180,6 +180,30 @@ def test_opds_advertised_authenticated_search_is_independent(self): rendered = json.dumps(result) self.assertNotIn('local-only',rendered) self.assertNotIn('fixture:local-only@',rendered) + def test_search_cancel_and_preview_bind_to_current_generation(self): + before = self.api('unified_search') + generation = before['generation'] + self.api('unified_search/cancel?generation='+str(generation), status=405) + self.api('unified_search/cancel', 'POST', status=400) + self.api('unified_search/cancel?generation='+str(generation), 'POST') + after = self.api('unified_search') + self.assertEqual(after['generation'], (generation+1) % (2**32)) + self.assertFalse(after['loading']) + self.api('unified_search/cancel?generation='+str(generation), 'POST', status=409) + self.assertEqual(after['generation'], self.api('unified_search')['generation']) + self.api('unified_search/preview', status=400) + self.api(f'unified_search/preview?generation={generation}&key=abc', status=409) + self.api(f"unified_search/preview?generation={after['generation']}&key=abc", status=404) + self.api(f"unified_search/preview?generation={after['generation']}&key=abc", 'POST', status=405) + self.assertNotIn('trailer_url', self.api('status')) + + def test_saved_search_title_uses_stable_library_identity(self): + identity = 'search&id=fixture-work' + self.api('library/item/action?kind='+identity+'&action=favorite&enabled=true&title=Fixture%20work', 'POST') + self.assertTrue(self.api('library/item?kind='+identity)['favorite']) + self.api('library/item/action?kind='+identity+'&action=favorite&enabled=false', 'POST') + self.assertFalse(self.api('library/item?kind='+identity)['favorite']) + def open_audiobook(self): self.api('abs/login','POST',{'server':self.fixture.base,'user':'fixture','pass':'local-only'}) self.wait('abs',lambda d:d['connected'] and not d['loading'] and len(d['libraries'])==1) diff --git a/tests/test_native_shutdown_live.py b/tests/test_native_shutdown_live.py new file mode 100644 index 00000000..f87de9b6 --- /dev/null +++ b/tests/test_native_shutdown_live.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Opt-in macOS native idle shutdown regression; fresh HOME/XDG and unused port. + +Build native first, then run with --binary zig-out/bin/opal. --launcher can +instead name a prebuilt zig-build-run wrapper, exercising its parent exit too. +No existing Opal listener or user profile is accessed. +""" +import argparse +import os +from pathlib import Path +import signal +import sqlite3 +import subprocess +import sys +import time +import unittest + +import test_setup_token_live as harness + + +@unittest.skipUnless(sys.platform == "darwin", "macOS SDL2 idle event-pump regression") +class NativeShutdownLive(unittest.TestCase): + def test_sigterm_closes_idle_desktop_and_its_launcher(self): + opal = harness.IsolatedOpal(self) + self.addCleanup(opal.stop) + profile = opal.config_root / "opal" + profile.mkdir(parents=True) + with sqlite3.connect(profile / "opal.db") as connection: + connection.execute("CREATE TABLE config(key TEXT PRIMARY KEY,value TEXT)") + connection.executemany("INSERT INTO config VALUES(?,?)", [ + ("web_port", str(harness.PORT)), ("web_bind", "loopback"), + ("web_remote", "1"), ("search_sources", "0"), + ("auto_download_subs", "0"), ("dpi_bypass_enabled", "0"), + ]) + token = opal.start() + account = harness.register("native-idle-fixture", host=opal.loopback_authority, + setup_token=token) + self.assertEqual(account.status, 200) + status = harness.request("GET", "/api/status", host=opal.loopback_authority, + extra_headers=(("Cookie", account.session_cookie()),)) + self.assertEqual(status.status, 200) + # Let the initial layout/animation settle into the native event wait. + time.sleep(4) + self.assertIsNone(opal.process.poll()) + owned = [] + for line in subprocess.check_output(["ps", "-axo", "pid=,pgid=,comm="], text=True).splitlines(): + fields = line.strip().split(None, 2) + if len(fields) == 3 and int(fields[1]) == opal.process.pid and fields[2].endswith("/opal"): + owned.append(int(fields[0])) + self.assertEqual(len(owned), 1, "exact native PID missing from owned process group") + os.kill(owned[0], signal.SIGTERM) + try: + result = opal.process.wait(timeout=15) + except subprocess.TimeoutExpired: + self.fail("idle desktop did not process SIGTERM within 15 seconds\n" + opal.safe_log()) + self.assertEqual(result, 0, opal.safe_log()) + for failure in ("file_hash FileNotFound", "forcing process exit", "memory address"): + self.assertNotIn(failure, opal.safe_log()) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + launch = parser.add_mutually_exclusive_group(required=True) + launch.add_argument("--binary", type=Path) + launch.add_argument("--launcher", type=Path) + parser.add_argument("--port", type=int, default=41749) + args = parser.parse_args() + if not 1 <= args.port <= 65535: + parser.error("--port must be between 1 and 65535") + harness.PORT = args.port + harness.BINARY = (args.binary or args.launcher).resolve() + if not harness.BINARY.is_file(): + parser.error("native binary/launcher must exist") + result = unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(NativeShutdownLive)) + raise SystemExit(0 if result.wasSuccessful() else 1) diff --git a/tests/test_web_lifecycle.mjs b/tests/test_web_lifecycle.mjs index cd9be9c7..a73eb17b 100644 --- a/tests/test_web_lifecycle.mjs +++ b/tests/test_web_lifecycle.mjs @@ -871,3 +871,286 @@ test('offline shell includes every script loaded by the production document', () const scripts = [...html.matchAll(/',summary:'