fix: allow compact mode while media is loading

This commit is contained in:
2026-10-12 12:00:56 +02:00
parent f495c8b719
commit 1e75f554f0
6 changed files with 93 additions and 25 deletions
+57 -14
View File
@@ -36,12 +36,66 @@ struct AutomaticQualityPlaybackChecks {
let model = PlayerModel(defaults: defaults, qualityClock: { clock.now }) let model = PlayerModel(defaults: defaults, qualityClock: { clock.now })
defer { model.shutdown() } defer { model.shutdown() }
model.setVolume(0) 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") 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") 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") { until(model, "default selects real 1080p and runs out of the gated network prefix") {
model.youtubeHeight == 1080 && model.canTogglePlayback && model.youtubeHeight == 1080 && model.canTogglePlayback &&
model.state.status == PLAYER_PLAYING && model.state.position > 0.5 && model.state.buffering 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 clock.now = 2000
polled(model) polled(model)
require(model.youtubeHeight == 1080 && !model.resolvingYouTube, require(model.youtubeHeight == 1080 && !model.resolvingYouTube,
@@ -75,19 +129,6 @@ struct AutomaticQualityPlaybackChecks {
qualityClock: { clock.now }) qualityClock: { clock.now })
require(manual.automaticYouTubeQuality, "manual session override does not replace startup configuration") require(manual.automaticYouTubeQuality, "manual session override does not replace startup configuration")
manual.shutdown() 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) model.setYouTubeQuality(720)
until(model, "manual selection opens real healthy 720p media") { until(model, "manual selection opens real healthy 720p media") {
model.youtubeHeight == 720 && model.canChangeYouTubeQuality && model.state.status == PLAYER_PLAYING && 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") "failed manual quality switch retains original selected media and playback progress")
} }
model.closeFile() 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")
} }
} }
+4
View File
@@ -45,6 +45,10 @@ def main():
control.mkdir() control.mkdir()
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n" wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n"
+ "control=" + shlex.quote(str(control)) + "\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 [[ "$*" == *"[height=1080]"* ]]; then\n'
+ ' if [[ -f "$control/fail-1080" ]]; then\n' + ' if [[ -f "$control/fail-1080" ]]; then\n'
+ ' echo "Fixture extraction deliberately failed" >&2; exit 1\n' + ' echo "Fixture extraction deliberately failed" >&2; exit 1\n'
+18
View File
@@ -48,6 +48,24 @@ struct SystemPlaybackChecks {
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
let center = MPNowPlayingInfoCenter.default() let center = MPNowPlayingInfoCenter.default()
let commands = MPRemoteCommandCenter.shared() 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")) model.open(directory.appendingPathComponent("av.mp4"))
until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING } until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING }
+6
View File
@@ -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** 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. 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. 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. 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-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`. `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. 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. `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. 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.
+3 -2
View File
@@ -102,12 +102,13 @@ final class CompactWindowController: NSObject {
restore() restore()
return return
} }
guard let window, aspect.isFinite, aspect > 0 else { return } guard let window else { return }
if !sessionActive { if !sessionActive {
sessionActive = true sessionActive = true
originatedFullScreen = window.styleMask.contains(.fullScreen) || fullScreenTransition != nil 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 wantsCompact = true
wantsFullScreen = false wantsFullScreen = false
reconcile() reconcile()
+5 -9
View File
@@ -47,10 +47,7 @@ final class PlayerModel: ObservableObject {
automaticYouTubeQuality ? .disableAutomaticQuality : .enableAutomaticQuality automaticYouTubeQuality ? .disableAutomaticQuality : .enableAutomaticQuality
} }
var isCompact: Bool { compactWindow.isCompact } var isCompact: Bool { compactWindow.isCompact }
var canToggleCompact: Bool { var canToggleCompact: Bool { canPerform(compactAction) }
isCompact || compactWindow.isTransitioning ||
(canTogglePlayback && state.has_video && state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0)
}
var compactContentMinimumHeight: CGFloat { var compactContentMinimumHeight: CGFloat {
guard let window else { return 90 } guard let window else { return 90 }
// Hosting-view minimums include the native titlebar safe area, even with full-size content. // 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 && return window != nil && fullScreenIntent == nil && !compactWindow.isTransitioning &&
(action == .enterFullscreen ? !isFullScreen : isFullScreen) (action == .enterFullscreen ? !isFullScreen : isFullScreen)
case .enterCompact: case .enterCompact:
return window != nil && !(compactIntent ?? isCompact) && return window != nil && !(compactIntent ?? isCompact) && canOpen &&
canTogglePlayback && state.has_video && (isBusy || (canTogglePlayback && state.has_video &&
state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0 state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0))
case .exitCompact: return window != nil && (compactIntent ?? isCompact) case .exitCompact: return window != nil && (compactIntent ?? isCompact)
case .enableAlwaysOnTop, .disableAlwaysOnTop: case .enableAlwaysOnTop, .disableAlwaysOnTop:
return window != nil && !isFullScreen && fullScreenIntent == nil && return window != nil && !isFullScreen && fullScreenIntent == nil &&
@@ -705,8 +702,7 @@ final class PlayerModel: ObservableObject {
defer { systemPlayback?.update() } defer { systemPlayback?.update() }
state = next state = next
if let playing = observation.playback { setPlayback(playing: playing) } if let playing = observation.playback { setPlayback(playing: playing) }
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || if !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) {
(!isBusy && !next.has_video) {
compactWindow.restore(resumeFullScreen: false, animated: false) compactWindow.restore(resumeFullScreen: false, animated: false)
} }
if !isSeeking && !transition.seekPending { if !isSeeking && !transition.seekPending {