fix: allow compact mode while media is loading
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user