feat: remember manually resized compact window width

This commit is contained in:
2026-10-12 16:52:47 +02:00
parent 750c20507c
commit 1f12660936
4 changed files with 68 additions and 14 deletions
+50 -4
View File
@@ -69,14 +69,14 @@ struct CompactWindowChecks {
"overlapping magnetic zones choose the closest edge, not first edge") "overlapping magnetic zones choose the closest edge, not first edge")
let usableScreen = NSRect(x: -1_200, y: 50, width: 1_200, height: 800) let usableScreen = NSRect(x: -1_200, y: 50, width: 1_200, height: 800)
let landscape = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: usableScreen) let landscape = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: usableScreen, width: 320)
try require(sameFrame(landscape, NSRect(x: -336, y: 654, width: 320, height: 180)), try require(sameFrame(landscape, NSRect(x: -336, y: 654, width: 320, height: 180)),
"16:9 enters at top right with 320pt width and 16pt inset") "16:9 enters at top right with 320pt width and 16pt inset")
let portrait = CompactWindowController.initialFrame(aspect: 9.0 / 16.0, visibleFrame: usableScreen) let portrait = CompactWindowController.initialFrame(aspect: 9.0 / 16.0, visibleFrame: usableScreen, width: 320)
try require(sameFrame(portrait, NSRect(x: -336, y: 834 - 5_120.0 / 9.0, width: 320, height: 5_120.0 / 9.0)), try require(sameFrame(portrait, NSRect(x: -336, y: 834 - 5_120.0 / 9.0, width: 320, height: 5_120.0 / 9.0)),
"portrait preserves exact aspect when it fits") "portrait preserves exact aspect when it fits")
for aspect in [16.0 / 9.0, 9.0 / 16.0, 100.0, 0.01] { for aspect in [16.0 / 9.0, 9.0 / 16.0, 100.0, 0.01] {
let frame = CompactWindowController.initialFrame(aspect: aspect, visibleFrame: usableScreen) let frame = CompactWindowController.initialFrame(aspect: aspect, visibleFrame: usableScreen, width: 320)
try require(frame.origin.x.isFinite && frame.origin.y.isFinite try require(frame.origin.x.isFinite && frame.origin.y.isFinite
&& frame.width.isFinite && frame.height.isFinite, && frame.width.isFinite && frame.height.isFinite,
"initial geometry is finite for aspect \(aspect)") "initial geometry is finite for aspect \(aspect)")
@@ -89,7 +89,7 @@ struct CompactWindowChecks {
"aspect \(aspect) stays anchored top right") "aspect \(aspect) stays anchored top right")
} }
let smallScreen = NSRect(x: 0, y: 0, width: 300, height: 400) let smallScreen = NSRect(x: 0, y: 0, width: 300, height: 400)
let bounded = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: smallScreen) let bounded = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: smallScreen, width: 320)
try require(sameFrame(bounded, NSRect(x: 16, y: 233.25, width: 268, height: 150.75)), try require(sameFrame(bounded, NSRect(x: 16, y: 233.25, width: 268, height: 150.75)),
"target width shrinks to usable bounds without distorting ordinary aspect") "target width shrinks to usable bounds without distorting ordinary aspect")
} }
@@ -404,6 +404,51 @@ struct CompactWindowChecks {
try replacement.restored() try replacement.restored()
} }
static func rememberedSize() throws {
let suite = "terebi.compact-size-check.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let fixture = try WindowFixture()
let controller = CompactWindowController(defaults: defaults, reduceMotion: { true })
defer {
controller.restore(resumeFullScreen: false, animated: false)
fixture.window.close()
}
controller.attach(fixture.window)
controller.toggle(aspect: 16.0 / 9.0)
var resized = fixture.window.frame
resized.size = NSSize(width: 240, height: 135)
fixture.window.setFrame(resized, display: false)
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window)
controller.restore(resumeFullScreen: false)
try fixture.restored()
controller.toggle(aspect: 16.0 / 9.0)
try require(close(fixture.window.frame.width, 240) && close(fixture.window.frame.height, 135),
"reentering compact restores the manually chosen size")
// Programmatic fitting and ordinary-window resizing must not replace the preference.
resized.size = NSSize(width: 320, height: 180)
fixture.window.setFrame(resized, display: false)
controller.updateAspect(4.0 / 3.0)
controller.restore(resumeFullScreen: false)
try fixture.restored()
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window)
let replacement = try WindowFixture()
let reloaded = CompactWindowController(defaults: UserDefaults(suiteName: suite)!, reduceMotion: { true })
defer {
reloaded.restore(resumeFullScreen: false, animated: false)
replacement.window.close()
}
reloaded.attach(replacement.window)
reloaded.toggle(aspect: 4.0 / 3.0)
try require(close(replacement.window.frame.width, 240) && close(replacement.window.frame.height, 180),
"a recreated controller restores preferred width with the new video's aspect")
try replacement.compact(aspect: 4.0 / 3.0)
reloaded.restore(resumeFullScreen: false)
try replacement.restored()
}
static func reducedMotion() throws { static func reducedMotion() throws {
let fixture = try WindowFixture() let fixture = try WindowFixture()
let controller = CompactWindowController(reduceMotion: { true }) let controller = CompactWindowController(reduceMotion: { true })
@@ -431,6 +476,7 @@ struct CompactWindowChecks {
try geometry() try geometry()
try windowBehavior() try windowBehavior()
try reducedMotion() try reducedMotion()
try rememberedSize()
print("Compact window checks passed") print("Compact window checks passed")
} }
} }
+3 -3
View File
@@ -123,7 +123,7 @@ For YouTube, bind `quality_<height>p` (for example, `quality_2160p = "ctrl+4"`)
Behavior/UI switches must be TOML booleans; `[ui].compact_title_progress` is a string: `"time"` (default), `"percentage"`, or `"none"`. Unknown keys, unsupported values, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. Top-level `automatic_quality` is no longer accepted: move it under `[playback]`. The old flat `show_buffer_info` and `snap_to_edges` settings are also rejected: move them to `[ui]` as `buffer_info` and `compact_snap_to_edges`. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes. Behavior/UI switches must be TOML booleans; `[ui].compact_title_progress` is a string: `"time"` (default), `"percentage"`, or `"none"`. Unknown keys, unsupported values, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. Top-level `automatic_quality` is no longer accepted: move it under `[playback]`. The old flat `show_buffer_info` and `snap_to_edges` settings are also rejected: move them to `[ui]` as `buffer_info` and `compact_snap_to_edges`. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes.
Put **`--no-config` before the media arguments** to skip reading the file entirely and use built-in settings. Combine it with `--new-instance` in either order to guarantee a fresh session. Reusing a running player neither reloads its file nor resets its current settings, even with `--no-config`. Playback history remains separate in UserDefaults; legacy UserDefaults settings are no longer read or written. Put **`--no-config` before the media arguments** to skip reading the file entirely and use built-in settings. Combine it with `--new-instance` in either order to guarantee a fresh session. Reusing a running player neither reloads its file nor resets its current settings, even with `--no-config`. Playback history and remembered compact width remain separate in UserDefaults and are not reset by `--no-config`; legacy UserDefaults configuration settings are no longer read or written.
## YouTube ## YouTube
@@ -199,7 +199,7 @@ Compact titles default to `<title> · <position> / <duration> <unit>`, using ack
Opening video fits the window to its display aspect ratio, including non-square pixels, and constrains subsequent window resizing. Fullscreen retains the normal letterboxed renderer; audio-only playback and close release the aspect constraint. Extreme ratios that cannot fit both the screen and usable controls keep a freely resizable letterboxed window. Opening video fits the window to its display aspect ratio, including non-square pixels, and constrains subsequent window resizing. Fullscreen retains the normal letterboxed renderer; audio-only playback and close release the aspect constraint. Extreme ratios that cannot fit both the screen and usable controls keep a freely resizable letterboxed window.
**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 the last manually chosen compact width (320 points before any resize). The width persists across mode changes and player restarts; height follows the current video's display aspect where usable screen bounds and the 160 × 90-point minimum allow, with extreme ratios retaining letterboxing. Normal-mode resize, transition animations, and automatic aspect/screen fitting do not overwrite the saved preference. Screen fitting may temporarily reduce the displayed size without reducing the saved width. 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. 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.
@@ -255,7 +255,7 @@ Generates short fixtures and exercises the real playback interface and native ou
`make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, and coarse container timestamps. It verifies that repeated forward/backward stepping stays silent and that resumed audio matches the exact selected-frame PCM suffix. It also checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook. `make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, and coarse container timestamps. It verifies that repeated forward/backward stepping stays silent and that resumed audio matches the exact selected-frame PCM suffix. It also checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook.
`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app. `make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, remembered manual compact width across reentry/controller recreation with a different aspect, isolation from programmatic/normal-window resizing, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
The native window check also covers queued entry/exit reversals, exact snapshot recovery, urgent nonanimated restoration, close/reattachment ignoring stale completions, and injected Reduce Motion. Animation smoothness and control cross-fades still need visual verification in the app. The native window check also covers queued entry/exit reversals, exact snapshot recovery, urgent nonanimated restoration, close/reattachment ignoring stale completions, and injected Reduce Motion. Animation smoothness and control cross-fades still need visual verification in the app.
+14 -6
View File
@@ -57,13 +57,16 @@ final class CompactWindowController: NSObject {
private var videoAspect = 16.0 / 9.0 private var videoAspect = 16.0 / 9.0
private var fittedAspect: Double? private var fittedAspect: Double?
private let reduceMotion: () -> Bool private let reduceMotion: () -> Bool
private let defaults: UserDefaults
private static let widthKey = "compactWindowWidth"
private var frameAnimating = false private var frameAnimating = false
private var frameAnimationGeneration: UInt64 = 0 private var frameAnimationGeneration: UInt64 = 0
init(snappingEnabled: Bool = true, init(snappingEnabled: Bool = true, defaults: UserDefaults = .standard,
reduceMotion: @escaping () -> Bool = { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }) { reduceMotion: @escaping () -> Bool = { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }) {
self.snappingEnabled = snappingEnabled self.snappingEnabled = snappingEnabled
self.reduceMotion = reduceMotion self.reduceMotion = reduceMotion
self.defaults = defaults
super.init() super.init()
} }
@@ -91,7 +94,7 @@ final class CompactWindowController: NSObject {
window.delegate = proxy window.delegate = proxy
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.didEnterFullScreenNotification, for name in [NSWindow.willEnterFullScreenNotification, NSWindow.didEnterFullScreenNotification,
NSWindow.willExitFullScreenNotification, NSWindow.didExitFullScreenNotification, NSWindow.willExitFullScreenNotification, NSWindow.didExitFullScreenNotification,
NSWindow.willCloseNotification] { NSWindow.willCloseNotification, NSWindow.didEndLiveResizeNotification] {
NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)), NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)),
name: name, object: window) name: name, object: window)
} }
@@ -137,6 +140,10 @@ final class CompactWindowController: NSObject {
@objc private func windowNotification(_ notification: Notification) { @objc private func windowNotification(_ notification: Notification) {
switch notification.name { switch notification.name {
case NSWindow.didEndLiveResizeNotification:
guard isCompact, !isTransitioning, let window,
window.frame.width.isFinite, window.frame.width > 0 else { return }
defaults.set(Double(window.frame.width), forKey: Self.widthKey)
case NSWindow.willEnterFullScreenNotification: case NSWindow.willEnterFullScreenNotification:
fullScreenTransition = .entering fullScreenTransition = .entering
setState(compact: isCompact, transitioning: true) setState(compact: isCompact, transitioning: true)
@@ -366,9 +373,10 @@ final class CompactWindowController: NSObject {
let visibleFrame = visibleFrame(for: window) let visibleFrame = visibleFrame(for: window)
let usable = Self.usableFrame(visibleFrame) let usable = Self.usableFrame(visibleFrame)
let aspect = videoAspect let aspect = videoAspect
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame) let savedWidth = initial ? defaults.double(forKey: Self.widthKey) : Double(window.frame.width)
let width = savedWidth.isFinite && savedWidth > 0 ? CGFloat(savedWidth) : 320
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame, width: width)
if !initial { if !initial {
frame.size = Self.compactSize(aspect: videoAspect, width: window.frame.width, available: usable.size)
frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width) frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width)
frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height) frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height)
} }
@@ -383,9 +391,9 @@ final class CompactWindowController: NSObject {
} }
} }
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect) -> NSRect { nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect, width: CGFloat) -> NSRect {
let usable = usableFrame(visibleFrame) let usable = usableFrame(visibleFrame)
let size = compactSize(aspect: aspect, width: 320, available: usable.size) let size = compactSize(aspect: aspect, width: width, available: usable.size)
return NSRect(x: usable.maxX - size.width, y: usable.maxY - size.height, width: size.width, height: size.height) return NSRect(x: usable.maxX - size.width, y: usable.maxY - size.height, width: size.width, height: size.height)
} }
+1 -1
View File
@@ -108,7 +108,7 @@ final class PlayerModel: ObservableObject {
compactTitleProgress = configuration.compactTitleProgress compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo showsBufferInfo = configuration.showBufferInfo
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges) compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
playbackProgress = PlaybackProgress(defaults: defaults) playbackProgress = PlaybackProgress(defaults: defaults)
let player = player_create() let player = player_create()
handle = player handle = player