From 1e75f554f0dadef5cd393fbf7c7993bc95dbc101 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 12 Oct 2026 12:00:56 +0200 Subject: [PATCH] fix: allow compact mode while media is loading --- Checks/automatic-quality-playback.swift | 71 ++++++++++++++++++++----- Checks/automatic-quality.py | 4 ++ Checks/system-playback.swift | 18 +++++++ README.md | 6 +++ Sources/App/CompactWindow.swift | 5 +- Sources/App/TerebiApp.swift | 14 ++--- 6 files changed, 93 insertions(+), 25 deletions(-) diff --git a/Checks/automatic-quality-playback.swift b/Checks/automatic-quality-playback.swift index 67a5ab5..4e7edd1 100644 --- a/Checks/automatic-quality-playback.swift +++ b/Checks/automatic-quality-playback.swift @@ -36,12 +36,66 @@ struct AutomaticQualityPlaybackChecks { let model = PlayerModel(defaults: defaults, qualityClock: { clock.now }) defer { model.shutdown() } model.setVolume(0) + // Control only the external extractor; media decoding and all model transitions stay real. + guard let controlPath = ProcessInfo.processInfo.environment["TEREBI_QUALITY_CONTROL"] else { + require(false, "isolated extraction controls are available") + return + } + let control = URL(fileURLWithPath: controlPath, isDirectory: true) + func mark(_ name: String) { + require(FileManager.default.createFile(atPath: control.appendingPathComponent(name).path, + contents: Data()), "publish extraction control " + name) + } + func marked(_ name: String) -> Bool { + FileManager.default.fileExists(atPath: control.appendingPathComponent(name).path) + } require(!model.automaticYouTubeQuality, "automatic quality is opt-in") + let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + model.attachWindow(window) + let normalFrame = window.frame + let compactKey = try! KeyChord("p") + mark("gate-open") model.openYouTube("https://www.youtube.com/watch?v=aqz-KE-bpKQ") + until(model, "initial YouTube resolution reaches the controlled extraction gate") { + model.resolvingYouTube && marked("entered-open") + } + model.handleKey(compactKey) + require(model.isCompact, "compact shortcut enters while the YouTube URL is unresolved") + until(model, "compact mode remains usable across empty playback polls while resolving") { + model.isCompact && !model.compactWindow.isTransitioning + } + require(window.level == .floating && window.frame.width < normalFrame.width, + "unresolved video has a usable compact window") + model.handleKey(compactKey) + until(model, "compact shortcut restores while YouTube resolution is still pending") { + !model.isCompact && !model.compactWindow.isTransitioning + } + require(window.frame == normalFrame && model.resolvingYouTube, + "resolution continues while restoring the original normal window") + require(model.perform(.enterCompact), "compact control action is available during resolution") + mark("release-open") + try! FileManager.default.removeItem(at: control.appendingPathComponent("gate-open")) until(model, "default selects real 1080p and runs out of the gated network prefix") { model.youtubeHeight == 1080 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING && model.state.position > 0.5 && model.state.buffering } + require(model.isCompact, "loading completion preserves the requested compact mode") + until(model, "decoded metadata updates compact geometry") { + !model.compactWindow.isTransitioning && + abs(window.frame.width / window.frame.height - model.state.video_aspect_ratio) < 0.01 + } + model.handleKey(compactKey) + until(model, "compact shortcut restores during network buffering") { + !model.isCompact && !model.compactWindow.isTransitioning + } + model.handleKey(compactKey) + until(model, "compact shortcut re-enters during network buffering") { + model.isCompact && !model.compactWindow.isTransitioning + } clock.now = 2000 polled(model) require(model.youtubeHeight == 1080 && !model.resolvingYouTube, @@ -75,19 +129,6 @@ struct AutomaticQualityPlaybackChecks { qualityClock: { clock.now }) require(manual.automaticYouTubeQuality, "manual session override does not replace startup configuration") manual.shutdown() - // Control only the external extractor; media decoding and all model transitions stay real. - guard let controlPath = ProcessInfo.processInfo.environment["TEREBI_QUALITY_CONTROL"] else { - require(false, "isolated extraction controls are available") - return - } - let control = URL(fileURLWithPath: controlPath, isDirectory: true) - func mark(_ name: String) { - require(FileManager.default.createFile(atPath: control.appendingPathComponent(name).path, - contents: Data()), "publish extraction control " + name) - } - func marked(_ name: String) -> Bool { - FileManager.default.fileExists(atPath: control.appendingPathComponent(name).path) - } model.setYouTubeQuality(720) until(model, "manual selection opens real healthy 720p media") { model.youtubeHeight == 720 && model.canChangeYouTubeQuality && model.state.status == PLAYER_PLAYING && @@ -149,6 +190,8 @@ struct AutomaticQualityPlaybackChecks { "failed manual quality switch retains original selected media and playback progress") } model.closeFile() - print("PASS native adaptive playback: default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/session-only settings/cancelled extraction pause/failed manual quality playing+paused preservation") + require(!model.isCompact && !model.compactWindow.isTransitioning, + "closing a streamed video restores its original window mode") + print("PASS native adaptive playback: resolving/loading/buffering compact shortcuts+controls/default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/session-only settings/cancelled extraction pause/failed manual quality playing+paused preservation") } } diff --git a/Checks/automatic-quality.py b/Checks/automatic-quality.py index dab5d7c..641fb35 100755 --- a/Checks/automatic-quality.py +++ b/Checks/automatic-quality.py @@ -45,6 +45,10 @@ def main(): control.mkdir() wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n" + "control=" + shlex.quote(str(control)) + "\n" + + 'if [[ -f "$control/gate-open" ]]; then\n' + + ' : > "$control/entered-open"\n' + + ' while [[ -f "$control/gate-open" && ! -f "$control/release-open" ]]; do sleep 0.01; done\n' + + 'fi\n' + 'if [[ "$*" == *"[height=1080]"* ]]; then\n' + ' if [[ -f "$control/fail-1080" ]]; then\n' + ' echo "Fixture extraction deliberately failed" >&2; exit 1\n' diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index 32d7da6..38b4fa2 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -48,6 +48,24 @@ struct SystemPlaybackChecks { let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) let center = MPNowPlayingInfoCenter.default() let commands = MPRemoteCommandCenter.shared() + let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + model.attachWindow(window) + model.open(directory.appendingPathComponent("anamorphic.mp4")) + require(model.isBusy && model.perform(.enterCompact) && model.isCompact, + "compact entry is available before native media opening acknowledges") + until("decoded anamorphic metadata replaces provisional compact geometry") { + model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning && + abs(window.frame.width / window.frame.height - 8.0 / 3.0) < 0.01 + } + model.closeFile() + require(!model.isCompact && !model.compactWindow.isTransitioning, + "closing loading/loaded media restores immediately") + until("anamorphic source closes before the ordinary playback checks") { model.state.status == PLAYER_EMPTY } + require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry") model.open(directory.appendingPathComponent("av.mp4")) until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING } diff --git a/README.md b/README.md index 8f0792e..0c5af9b 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ Opening video fits the window to its display aspect ratio, including non-square **Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use the PiP-shaped button in the normal playback controls, **⌃⌘P**, or **Playback Actions → Enter Compact Mode**. The compact-mode button replaces the fullscreen button; fullscreen remains available through its menu, keyboard shortcuts, and double-clicking normal-mode video. Compact mode starts at the current screen's top-right corner with a 16-point inset and a preferred width of 320 points, preserving display aspect where usable screen bounds and the 160 × 90-point minimum allow; extreme ratios retain letterboxing. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Small hover controls provide play/pause, restore, and Playback Actions; keyboard playback controls remain available. +Compact-mode shortcuts and menu actions remain available during YouTube URL resolution, media opening, and network buffering. While metadata is unavailable, the window uses a provisional video ratio; decoded metadata then updates its geometry without leaving compact mode. + Entry and restoration animate the same window's position and size over **250 ms** with ease-in/ease-out timing while normal and compact controls cross-fade; playback and the Metal view stay in place. macOS **Reduce Motion** disables both animations. Rapid toggles keep the latest destination and finish the current native frame animation before changing direction; native fullscreen transitions are also serialized. Media close, errors, shutdown, and explicit fullscreen handoff restore immediately rather than leave a frame animation pending. Drag the compact video to move the window; on release, each axis snaps independently when within 24 points of an inset usable-screen edge. **Snap to Screen Edges** is enabled by default, configurable with `[ui].compact_snap_to_edges`, and its toggle is session-only. Disabling snapping permits free placement. Exiting compact mode restores the previous normal geometry and window behavior, including keep-on-top; entry from fullscreen waits for the native exit animation, and restoring that session returns to fullscreen. Double-clicking compact video also restores the previous mode. Video replacement updates the compact aspect; audio-only replacement, playback errors, and closing the file leave compact mode. Closing the window still quits the app. @@ -258,6 +260,8 @@ The network check also disconnects combined, secondary-audio and primary-video r Adaptive transition checks also gate a real extraction, cancel it by pausing, and verify that late completion cannot reopen quality or resume paused playback. Failed manual quality changes preserve the original media, position, and prior playing/paused state. +The gated YouTube check also exercises compact shortcut/control entry and restoration during initial resolution, staying compact across empty playback updates and loading completion, and switching modes while network playback is buffering. + `make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, isolated preference domains, independently initialized writers preserving each other's videos, and migration of existing bookmarks. It is included in `make check` and does not touch real playback history. `make check-reporting` decodes real JSONL files with an injected clock: initial/period boundaries, pending versus acknowledged seeks, threshold/pause/end/close facts, quality/replacement identity, nullable duration and eligibility, exclusive private creation, existing/symlink/concurrent rejection, and isolated OS write failure. `make check-network` also exercises the real backend's acknowledged initial restoration, audio-only/live eligibility, failed/cancelled opens, final drain, and continued playback/seek/replacement after a failed writer. Both are included in `make check`. @@ -272,6 +276,8 @@ Adaptive transition checks also gate a real extraction, cancel it by pausing, an Playback transition checks wait for native acknowledgement of rapid opposite intents, restore local bookmarks before autoplay, persist/resume the acknowledged position across close/reopen, and discard duration-exceeded bookmarks without preventing playback. +Native opening checks enter compact before playback acknowledges the request, then verify that anamorphic video metadata replaces the provisional ratio and that closing restores normal mode. + `make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies the registered app PID/bundle identity, then runs the real CLI against the production startup/SwiftUI scene/delegate: missing-config defaults without creation, nested settings/key overrides, actual plain/modified shortcuts, rapid opposite intents and held-key suppression, same-window compact entry/restoration, removed old bindings, real text insertion without playback interception, native menu equivalents and Play/Pause selections, malformed-file exit 78, cold/warm `--no-config`, unchanged file bytes and existing session settings, cold/default reuse, independent empty/local/YouTube launches, exactly one player window per PID, targeted media, retained original playback, help without launch, and warm default replacement. YouTube uses loopback media and actual offline yt-dlp selection; that portion requires yt-dlp and a supported JS runtime but no external network. It is included in `make check`; windows are transparent and ignore mouse input, audio muted, and configuration/bundle/history isolated. Native menus receive their normal lazy-population/validation callbacks before direct selection. Finder/Space behavior, visible controls/menu presentation and Vorssaint discovery still need a check in the app. Startup checks also exercise actual CLI-to-GUI reporting after the launcher exits: two independent private streams, canonical identities, acknowledged resume/seek/pause, quality reopenings, replacement/end/close/quit, failed resolution, local-media exclusions, and existing/dangling-destination preflight errors. Report readers tolerate duplicates and partial trailing lines; watched facts are derived from eligible native positions, not launcher success. diff --git a/Sources/App/CompactWindow.swift b/Sources/App/CompactWindow.swift index 741dd98..094bf40 100644 --- a/Sources/App/CompactWindow.swift +++ b/Sources/App/CompactWindow.swift @@ -102,12 +102,13 @@ final class CompactWindowController: NSObject { restore() return } - guard let window, aspect.isFinite, aspect > 0 else { return } + guard let window else { return } if !sessionActive { sessionActive = true originatedFullScreen = window.styleMask.contains(.fullScreen) || fullScreenTransition != nil } - videoAspect = aspect + // Keep the last usable ratio until loading supplies the video's metadata. + if aspect.isFinite && aspect > 0 { videoAspect = aspect } wantsCompact = true wantsFullScreen = false reconcile() diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 3a695d3..45c81f8 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -47,10 +47,7 @@ final class PlayerModel: ObservableObject { automaticYouTubeQuality ? .disableAutomaticQuality : .enableAutomaticQuality } var isCompact: Bool { compactWindow.isCompact } - var canToggleCompact: Bool { - isCompact || compactWindow.isTransitioning || - (canTogglePlayback && state.has_video && state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0) - } + var canToggleCompact: Bool { canPerform(compactAction) } var compactContentMinimumHeight: CGFloat { guard let window else { return 90 } // Hosting-view minimums include the native titlebar safe area, even with full-size content. @@ -509,9 +506,9 @@ final class PlayerModel: ObservableObject { return window != nil && fullScreenIntent == nil && !compactWindow.isTransitioning && (action == .enterFullscreen ? !isFullScreen : isFullScreen) case .enterCompact: - return window != nil && !(compactIntent ?? isCompact) && - canTogglePlayback && state.has_video && - state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0 + return window != nil && !(compactIntent ?? isCompact) && canOpen && + (isBusy || (canTogglePlayback && state.has_video && + state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0)) case .exitCompact: return window != nil && (compactIntent ?? isCompact) case .enableAlwaysOnTop, .disableAlwaysOnTop: return window != nil && !isFullScreen && fullScreenIntent == nil && @@ -705,8 +702,7 @@ final class PlayerModel: ObservableObject { defer { systemPlayback?.update() } state = next if let playing = observation.playback { setPlayback(playing: playing) } - if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || - (!isBusy && !next.has_video) { + if !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) { compactWindow.restore(resumeFullScreen: false, animated: false) } if !isSeeking && !transition.seekPending {