From fa66020951c9304f638fc5de8b85aa3d2ce6303a Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 10 Oct 2026 21:07:14 +0200 Subject: [PATCH] feat: add same-window compact playback with magnetic snapping --- Checks/compact-window.swift | 230 +++++++++++++++++++ Makefile | 15 +- README.md | 13 +- Sources/App/CompactWindow.swift | 383 ++++++++++++++++++++++++++++++++ Sources/App/TerebiApp.swift | 224 ++++++++++++++++--- 5 files changed, 826 insertions(+), 39 deletions(-) create mode 100644 Checks/compact-window.swift create mode 100644 Sources/App/CompactWindow.swift diff --git a/Checks/compact-window.swift b/Checks/compact-window.swift new file mode 100644 index 0000000..5f5140c --- /dev/null +++ b/Checks/compact-window.swift @@ -0,0 +1,230 @@ +import AppKit +import Foundation + +@main +@MainActor +struct CompactWindowChecks { + struct Failure: Error, CustomStringConvertible { + let description: String + } + + static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { + if !condition() { throw Failure(description: "FAIL: \(message)") } + } + + static func close(_ actual: CGFloat, _ expected: CGFloat) -> Bool { + abs(actual - expected) < 0.001 + } + + static func sameFrame(_ actual: NSRect, _ expected: NSRect) -> Bool { + close(actual.minX, expected.minX) && close(actual.minY, expected.minY) + && close(actual.width, expected.width) && close(actual.height, expected.height) + } + + static func geometry() throws { + let screen = NSRect(x: 100, y: 200, width: 1_000, height: 700) + func snap(_ x: CGFloat, _ y: CGFloat, to expectedX: CGFloat, _ expectedY: CGFloat) throws { + let frame = NSRect(x: x, y: y, width: 320, height: 180) + let expected = NSRect(x: expectedX, y: expectedY, width: 320, height: 180) + try require(sameFrame(CompactWindowController.snappedFrame(frame, visibleFrame: screen), expected), + "snap (\(x), \(y)) to (\(expectedX), \(expectedY)), preserving size") + } + // Inset edges: x = 116 / 764, y = 216 / 704. Each axis acts independently. + try snap(130, 460, to: 116, 460) + try snap(750, 460, to: 764, 460) + try snap(440, 230, to: 440, 216) + try snap(440, 690, to: 440, 704) + try snap(130, 230, to: 116, 216) + try snap(750, 230, to: 764, 216) + try snap(130, 690, to: 116, 704) + try snap(750, 690, to: 764, 704) + try snap(140, 460, to: 116, 460) + try snap(740, 460, to: 764, 460) + try snap(440, 240, to: 440, 216) + try snap(440, 680, to: 440, 704) + try snap(92, 460, to: 116, 460) + try snap(788, 460, to: 764, 460) + try snap(141, 460, to: 141, 460) + try snap(739, 460, to: 739, 460) + try snap(440, 241, to: 440, 241) + try snap(440, 679, to: 440, 679) + try snap(440, 192, to: 440, 216) + try snap(440, 728, to: 440, 704) + try snap(91, 460, to: 91, 460) + try snap(789, 460, to: 789, 460) + try snap(440, 191, to: 440, 191) + try snap(440, 729, to: 440, 729) + try snap(440, 460, to: 440, 460) + + let negativeScreen = NSRect(x: -1_440, y: -900, width: 1_440, height: 900) + let negativeFrame = NSRect(x: -1_410, y: -870, width: 320, height: 180) + try require(sameFrame(CompactWindowController.snappedFrame(negativeFrame, visibleFrame: negativeScreen), + NSRect(x: -1_424, y: -884, width: 320, height: 180)), + "negative-origin screen uses its own inset edges") + let narrowScreen = NSRect(x: 0, y: 0, width: 100, height: 200) + try require(sameFrame(CompactWindowController.snappedFrame( + NSRect(x: 35, y: 80, width: 40, height: 20), visibleFrame: narrowScreen), + NSRect(x: 44, y: 80, width: 40, height: 20)), + "overlapping magnetic zones choose the closest edge, not first edge") + + let usableScreen = NSRect(x: -1_200, y: 50, width: 1_200, height: 800) + let landscape = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: usableScreen) + try require(sameFrame(landscape, NSRect(x: -336, y: 66, width: 320, height: 180)), + "16:9 enters at bottom right with 320pt width and 16pt inset") + let portrait = CompactWindowController.initialFrame(aspect: 9.0 / 16.0, visibleFrame: usableScreen) + try require(sameFrame(portrait, NSRect(x: -336, y: 66, width: 320, height: 5_120.0 / 9.0)), + "portrait preserves exact aspect when it fits") + for aspect in [16.0 / 9.0, 9.0 / 16.0, 100.0, 0.01] { + let frame = CompactWindowController.initialFrame(aspect: aspect, visibleFrame: usableScreen) + try require(frame.origin.x.isFinite && frame.origin.y.isFinite + && frame.width.isFinite && frame.height.isFinite, + "initial geometry is finite for aspect \(aspect)") + try require(frame.width >= 160 && frame.height >= 90, + "extreme aspect \(aspect) never defeats minimum size") + try require(frame.minX >= usableScreen.minX + 16 && frame.minY >= usableScreen.minY + 16 + && frame.maxX <= usableScreen.maxX - 16 && frame.maxY <= usableScreen.maxY - 16, + "aspect \(aspect) stays inside inset usable screen") + try require(close(frame.maxX, usableScreen.maxX - 16) && close(frame.minY, usableScreen.minY + 16), + "aspect \(aspect) stays anchored bottom right") + } + let smallScreen = NSRect(x: 0, y: 0, width: 300, height: 400) + let bounded = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: smallScreen) + try require(sameFrame(bounded, NSRect(x: 16, y: 16, width: 268, height: 150.75)), + "target width shrinks to usable bounds without distorting ordinary aspect") + } + + static func windowBehavior(defaults: UserDefaults) throws { + _ = NSApplication.shared + guard let screen = NSScreen.main else { + throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen") + } + // Creating a window does not display it: never orderFront, makeKey, or enter native fullscreen. + 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() } + guard let content = window.contentView else { throw Failure(description: "FAIL: missing content view") } + let sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14)) + content.addSubview(sentinel) + let sentinelFrame = sentinel.frame + window.minSize = NSSize(width: 300, height: 180) + window.contentMinSize = NSSize(width: 220, height: 120) + window.aspectRatio = NSSize(width: 4, height: 3) + window.collectionBehavior = [.managed, .fullScreenPrimary] + window.level = .normal + window.hidesOnDeactivate = true + window.isMovableByWindowBackground = false + let originalFrame = window.frame + let originalMinSize = window.minSize + let originalContentMinSize = window.contentMinSize + let originalAspect = window.aspectRatio + let originalStyle = window.styleMask + let originalTitleVisibility = window.titleVisibility + let originalTransparentTitlebar = window.titlebarAppearsTransparent + let originalCollection = window.collectionBehavior + let originalLevel = window.level + let originalHides = window.hidesOnDeactivate + let originalDrag = window.isMovableByWindowBackground + let identity = ObjectIdentifier(window) + let controller = CompactWindowController(defaults: defaults) + defer { controller.restore(resumeFullScreen: false) } + controller.attach(window) + try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary") + try require(controller.snappingEnabled, "fresh isolated preferences default snapping on") + + func preservedContent() throws { + try require(ObjectIdentifier(window) == identity && sentinel.window === window, + "compact mode keeps the actual rendering window") + try require(window.contentView === content && sentinel.superview === content + && content.subviews.count == 1 && sameFrame(sentinel.frame, sentinelFrame), + "compact mode preserves content and attached sentinel without reparenting") + try require(!window.isVisible, "checks never display a window") + } + func restored() throws { + try require(!controller.isCompact && !controller.isTransitioning, "restore finishes in ordinary mode") + try require(sameFrame(window.frame, originalFrame), "restore recovers original normal geometry") + try require(window.minSize == originalMinSize && window.contentMinSize == originalContentMinSize + && window.aspectRatio == originalAspect, "restore recovers aspect and both minima") + try require(window.styleMask == originalStyle && window.collectionBehavior == originalCollection + && window.level == originalLevel, "restore recovers style, Spaces policy, and level") + try require(window.titleVisibility == originalTitleVisibility + && window.titlebarAppearsTransparent == originalTransparentTitlebar, + "restore recovers original native chrome") + try require(window.hidesOnDeactivate == originalHides + && window.isMovableByWindowBackground == originalDrag, + "restore recovers deactivate and background drag behavior") + try preservedContent() + } + + controller.toggle(aspect: 16.0 / 9.0) + try require(controller.isCompact && !controller.isTransitioning, "windowed toggle enters compact synchronously") + try require(window.level == .floating && !window.hidesOnDeactivate, "compact floats and remains visible on deactivate") + try require(window.collectionBehavior.contains([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]), + "compact can join Spaces, other-app fullscreen, and Stage Manager") + try require(window.collectionBehavior.intersection([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty, + "compact removes incompatible primary and auxiliary policies") + try require(window.contentMinSize == NSSize(width: 160, height: 90), "compact uses 160x90 content minimum") + try require(window.isMovableByWindowBackground, "compact allows background dragging") + try require(window.canBecomeKey, "compact window still accepts keyboard playback controls") + try preservedContent() + + let visible = (window.screen ?? screen).visibleFrame + try require(close(window.frame.maxX, visible.maxX - 16) + && close(window.frame.minY, visible.minY + 16) + && close(window.frame.width / window.frame.height, 16.0 / 9.0), + "actual compact window enters at inset bottom right with playback aspect") + let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27, + width: window.frame.width, height: window.frame.height) + window.setFrame(nearEdge, display: false) + try require(sameFrame(window.frame, nearEdge), "native move installs unsnapped drag position") + controller.snapAfterDrag() + try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16) + && window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window") + controller.snappingEnabled = false + let second = CompactWindowController(defaults: defaults) + try require(!second.snappingEnabled, "disabled preference persists across controller instances") + window.setFrame(nearEdge, display: false) + controller.snapAfterDrag() + try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched") + second.snappingEnabled = true + let third = CompactWindowController(defaults: defaults) + try require(third.snappingEnabled, "re-enabled preference persists across controller instances") + controller.snappingEnabled = true + let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135) + window.setFrame(resized, display: false) + controller.updateAspect(16.0 / 9.0) + try require(sameFrame(window.frame, resized), + "repeated aspect updates preserve a user's compact resize and placement") + controller.updateAspect(4.0 / 3.0) + try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode") + try require(close(window.frame.width / window.frame.height, 4.0 / 3.0), + "actual compact window follows changed playback aspect") + try preservedContent() + controller.toggle(aspect: 4.0 / 3.0) + try restored() + controller.toggle(aspect: 16.0 / 9.0) + controller.restore(resumeFullScreen: false) + try restored() + controller.restore(resumeFullScreen: false) + try restored() + // Replay AppKit's failure lifecycle without creating a visible fullscreen Space. + NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: window) + controller.toggle(aspect: 16.0 / 9.0) + try require(controller.isTransitioning && !controller.isCompact, + "compact entry waits for an in-flight native fullscreen transition") + window.delegate?.windowDidFailToEnterFullScreen?(window) + try restored() + } + + static func main() throws { + let suite = "terebi.compact-window-checks.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + throw Failure(description: "FAIL: cannot create isolated preference suite") + } + defer { defaults.removePersistentDomain(forName: suite) } + try geometry() + try windowBehavior(defaults: defaults) + print("Compact window checks passed") + } +} diff --git a/Makefile b/Makefile index 018383e..b0337b4 100644 --- a/Makefile +++ b/Makefile @@ -7,9 +7,10 @@ ARCH := $(shell uname -m) CXXFLAGS := -std=c++20 -mmacosx-version-min=15.0 -O2 -g -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS) FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore OBJECTS := .build/player.o .build/Output.o +SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift APP := .build/terebi.app -.PHONY: all run check check-audio +.PHONY: all run check check-audio check-window all: $(APP)/Contents/MacOS/terebi $(APP)/Contents/Info.plist .build: @@ -21,9 +22,9 @@ all: $(APP)/Contents/MacOS/terebi $(APP)/Contents/Info.plist .build/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | .build $(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@ -$(APP)/Contents/MacOS/terebi: Sources/App/TerebiApp.swift $(OBJECTS) Makefile +$(APP)/Contents/MacOS/terebi: $(SWIFT_SOURCES) $(OBJECTS) Makefile mkdir -p $(APP)/Contents/MacOS - $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g -import-objc-header Sources/Core/player_macos.h $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g -import-objc-header Sources/Core/player_macos.h $(SWIFT_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ $(APP)/Contents/Info.plist: Resources/Info.plist mkdir -p $(APP)/Contents @@ -35,7 +36,7 @@ run: all .build/playback-check: Checks/playback.mm $(OBJECTS) Makefile $(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@ -check: .build/playback-check check-audio +check: .build/playback-check check-audio check-window mkdir -p .build/fixtures ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 4 -c:v mpeg4 -bf 2 -c:a aac .build/fixtures/av.mp4 ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -c:v copy .build/fixtures/video.mp4 @@ -65,3 +66,9 @@ check-audio: .build/audio-continuity-check ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone441.mka -ar 48000 -ac 2 -f f32le .build/fixtures/tone441.mka.f32 ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/tone-aac.m4a -ar 48000 -ac 2 -f f32le .build/fixtures/tone-aac.m4a.f32 .build/audio-continuity-check .build/fixtures + +.build/compact-window-check: Checks/compact-window.swift Sources/App/CompactWindow.swift Makefile | .build + $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O -g Checks/compact-window.swift Sources/App/CompactWindow.swift -framework AppKit -o $@ + +check-window: .build/compact-window-check + .build/compact-window-check diff --git a/README.md b/README.md index 2b5e42b..4ae1bc9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ The layout follows [IINA's video-first window](https://iina.io/img/screenshot.pn 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 **⌃⌘P** or **Playback Actions → Enter Compact Mode**. It starts at the current screen's bottom-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. + +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 and remembers its toggle across launches. 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. + | Key | Action | |---|---| | `K` / `Space` | Toggle playback | @@ -37,12 +41,12 @@ Single-key shortcuts apply only to the playback window and do not intercept text Frame stepping uses decoded presentation timestamps, not an average-FPS time offset, including variable-frame-rate video and reordered B-frames. Steps start from the displayed frame, stop at the first/last frame, and keep the timeline paused. Consecutive same-direction repeats share one counted queue entry without skipping frames or reordering seeks/direction changes. The last presented image remains visible during decoder preroll and queued steps; the progress thumb follows each authoritative stepped position. Opening/closing media still clears the old image. Audio-only files disable stepping; normal playback resumes with audio synchronized to the selected frame. -- Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking video also toggles fullscreen. +- Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking normal-mode video also toggles fullscreen. - The timeline slider seeks on release and holds the requested position until queued seeking finishes. Both slider tracks span the native thumb-center travel range; the orange fill ends at the current volume thumb center. The volume thumb is opaque light gray. -- **Single-click video:** hide controls. Mouse movement reveals them; they fade after 2.5 seconds, including while paused. Hovering the titlebar/controller, dragging, seeking, an inactive window, errors, or VoiceOver keep them visible. +- **Single-click normal-mode video:** hide controls. Mouse movement reveals them; they fade after 2.5 seconds, including while paused. Hovering the normal titlebar or either controller, dragging, seeking, an inactive normal-mode window, errors, or VoiceOver keep them visible. Compact controls can fade while another app is active; hovering its video reveals them without hiding the desktop cursor. - Click the right-hand time label to switch between total and remaining time. The titlebar pin keeps the window on top. -Only implemented actions have controls; there are no placeholder playlist, subtitle, picture-in-picture, or settings buttons. Layout and interaction references: IINA v1.5.0's [floating controller](https://github.com/iina/iina/blob/v1.5.0/iina/OSCFloatingView.swift), [preferences](https://github.com/iina/iina/blob/v1.5.0/iina/Preference.swift), and [titlebar](https://github.com/iina/iina/blob/v1.5.0/iina/Titlebar.swift). +Only implemented actions have controls; there are no placeholder playlist, subtitle, or settings buttons. Layout and interaction references: IINA v1.5.0's [floating controller](https://github.com/iina/iina/blob/v1.5.0/iina/OSCFloatingView.swift), [preferences](https://github.com/iina/iina/blob/v1.5.0/iina/Preference.swift), and [titlebar](https://github.com/iina/iina/blob/v1.5.0/iina/Titlebar.swift). ## Where to change things @@ -50,6 +54,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti - `Sources/Core/player.cpp`: single demux/decode worker, bounded buffers, timestamps, audio-master synchronization, paused previews, exact frame stepping, decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes all old timeline state before resuming. - `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely. - `Sources/App/TerebiApp.swift`: MainActor state polling, file panel/Finder/drop handling, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. The shared `PlayerModel.open` path enqueues play after an accepted open, on the new file's command timeline; the C interface still opens paused for callers that need explicit playback control. +- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, magnetic edge snapping, and its persisted preference. - `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency. ## Runnable check @@ -62,6 +67,8 @@ 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-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, isolated preference persistence, 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. + Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks. Volume persists across open, seek, and close. Live gain changes ramp over five milliseconds without callback allocation or locking; a stopped output adopts the requested gain before playback starts, so muted startup cannot leak audio. diff --git a/Sources/App/CompactWindow.swift b/Sources/App/CompactWindow.swift new file mode 100644 index 0000000..1e7d18c --- /dev/null +++ b/Sources/App/CompactWindow.swift @@ -0,0 +1,383 @@ +import AppKit + +@MainActor +final class CompactWindowController: NSObject { + var onChange: (() -> Void)? + private(set) var isCompact = false + private(set) var isTransitioning = false + + var snappingEnabled: Bool { + didSet { + guard snappingEnabled != oldValue else { return } + defaults.set(snappingEnabled, forKey: "compactSnappingEnabled") + onChange?() + } + } + + private struct Snapshot { + let frame: NSRect + let minSize: NSSize + let contentMinSize: NSSize + let aspectRatio: NSSize + let styleMask: NSWindow.StyleMask + let titleVisibility: NSWindow.TitleVisibility + let titlebarAppearsTransparent: Bool + let collectionBehavior: NSWindow.CollectionBehavior + let level: NSWindow.Level + let hidesOnDeactivate: Bool + let isMovableByWindowBackground: Bool + + @MainActor + init(_ window: NSWindow) { + frame = window.frame + minSize = window.minSize + contentMinSize = window.contentMinSize + aspectRatio = window.aspectRatio + styleMask = window.styleMask + titleVisibility = window.titleVisibility + titlebarAppearsTransparent = window.titlebarAppearsTransparent + collectionBehavior = window.collectionBehavior + level = window.level + hidesOnDeactivate = window.hidesOnDeactivate + isMovableByWindowBackground = window.isMovableByWindowBackground + } + } + + private enum FullScreenTransition { case entering, exiting } + + private let defaults: UserDefaults + private weak var window: NSWindow? + private var snapshot: Snapshot? + private var fullScreenTransition: FullScreenTransition? + private var delegateProxy: FullScreenDelegate? + private var sessionActive = false + private var wantsCompact = false + private var wantsFullScreen = false + private var originatedFullScreen = false + private var videoAspect = 16.0 / 9.0 + private var fittedAspect: Double? + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + defaults.register(defaults: ["compactSnappingEnabled": true]) + snappingEnabled = defaults.bool(forKey: "compactSnappingEnabled") + super.init() + } + + deinit { NotificationCenter.default.removeObserver(self) } + + func attach(_ window: NSWindow) { + guard window !== self.window else { return } + if let oldWindow = self.window, fullScreenTransition == nil, + !oldWindow.styleMask.contains(.fullScreen) { + restoreSnapshot(to: oldWindow) + } + restoreDelegate() + NotificationCenter.default.removeObserver(self) + self.window = window + snapshot = nil + fullScreenTransition = nil + sessionActive = false + wantsCompact = false + wantsFullScreen = false + originatedFullScreen = false + setState(compact: false, transitioning: false) + let proxy = FullScreenDelegate(owner: self, original: window.delegate) + delegateProxy = proxy + window.delegate = proxy + for name in [NSWindow.willEnterFullScreenNotification, NSWindow.didEnterFullScreenNotification, + NSWindow.willExitFullScreenNotification, NSWindow.didExitFullScreenNotification, + NSWindow.willCloseNotification] { + NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)), + name: name, object: window) + } + } + + func toggle(aspect: Double) { + if sessionActive && wantsCompact { + restore() + return + } + guard let window, aspect.isFinite, aspect > 0 else { return } + if !sessionActive { + sessionActive = true + originatedFullScreen = window.styleMask.contains(.fullScreen) || fullScreenTransition != nil + } + videoAspect = aspect + wantsCompact = true + wantsFullScreen = false + reconcile() + } + + func restore(resumeFullScreen: Bool = true) { + guard sessionActive else { return } + wantsCompact = false + wantsFullScreen = resumeFullScreen && originatedFullScreen + reconcile() + } + + func updateAspect(_ aspect: Double) { + guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return } + videoAspect = aspect + guard isCompact, !isTransitioning, let window else { return } + fitCompactWindow(window, initial: false) + } + + func snapAfterDrag() { + guard isCompact, !isTransitioning, snappingEnabled, let window else { return } + let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window)) + if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) } + } + + @objc private func windowNotification(_ notification: Notification) { + switch notification.name { + case NSWindow.willEnterFullScreenNotification: + fullScreenTransition = .entering + setState(compact: isCompact, transitioning: true) + case NSWindow.willExitFullScreenNotification: + fullScreenTransition = .exiting + setState(compact: isCompact, transitioning: true) + case NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification: + fullScreenTransition = nil + reconcile() + case NSWindow.willCloseNotification: + restoreDelegate() + NotificationCenter.default.removeObserver(self) + window = nil + snapshot = nil + fullScreenTransition = nil + sessionActive = false + wantsCompact = false + wantsFullScreen = false + originatedFullScreen = false + setState(compact: false, transitioning: false) + default: + break + } + } + + private func restoreDelegate() { + if let window, let delegateProxy, window.delegate === delegateProxy { + window.delegate = delegateProxy.original + } + delegateProxy = nil + } + + private func fullScreenFailed() { + fullScreenTransition = nil + wantsFullScreen = false + if isCompact { + wantsCompact = true + setState(compact: true, transitioning: false) + } else { + if let window, !window.styleMask.contains(.fullScreen) { restoreSnapshot(to: window) } + finishSession() + } + } + + @MainActor + private final class FullScreenDelegate: NSObject, NSWindowDelegate { + // NSObject forwarding returns untyped Any; accesses remain on AppKit's main thread. + nonisolated(unsafe) weak var original: (any NSWindowDelegate)? + private weak var owner: CompactWindowController? + + init(owner: CompactWindowController, original: (any NSWindowDelegate)?) { + self.owner = owner + self.original = original + } + + nonisolated override func responds(to selector: Selector!) -> Bool { + if super.responds(to: selector) { return true } + return MainActor.assumeIsolated { original?.responds(to: selector) ?? false } + } + + nonisolated override func forwardingTarget(for selector: Selector!) -> Any? { + let responds = MainActor.assumeIsolated { original?.responds(to: selector) ?? false } + return responds ? original : nil + } + + func windowDidFailToEnterFullScreen(_ window: NSWindow) { + owner?.fullScreenFailed() + original?.windowDidFailToEnterFullScreen?(window) + } + + func windowDidFailToExitFullScreen(_ window: NSWindow) { + owner?.fullScreenFailed() + original?.windowDidFailToExitFullScreen?(window) + } + } + + // A toggle changes the destination, never reverses an in-flight native animation. + private func reconcile() { + guard let window, fullScreenTransition == nil else { return } + guard sessionActive else { + setState(compact: isCompact, transitioning: false) + return + } + if wantsCompact { + if window.styleMask.contains(.fullScreen) { + toggleNativeFullScreen(window, transition: .exiting) + } else if !isCompact { + enterCompact(window) + } else { + if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) } + setState(compact: true, transitioning: false) + } + return + } + if window.styleMask.contains(.fullScreen) { + if wantsFullScreen && snapshot == nil { + finishSession() + } else { + toggleNativeFullScreen(window, transition: .exiting) + } + return + } + if snapshot != nil { + setState(compact: isCompact, transitioning: true) + restoreSnapshot(to: window) + setState(compact: false, transitioning: wantsFullScreen) + } + if wantsFullScreen { + toggleNativeFullScreen(window, transition: .entering) + } else { + finishSession() + } + } + + private func toggleNativeFullScreen(_ window: NSWindow, transition: FullScreenTransition) { + fullScreenTransition = transition + setState(compact: isCompact, transitioning: true) + window.toggleFullScreen(nil) + } + + private func enterCompact(_ window: NSWindow) { + setState(compact: false, transitioning: true) + // After didExitFullScreen, this is the normal frame, not the fullscreen frame. + snapshot = Snapshot(window) + window.aspectRatio = .zero + window.minSize = .zero + window.contentMinSize = .zero + // A borderless NSWindow cannot become key; retain native keyboard and resize behavior. + window.styleMask.formUnion([.titled, .fullSizeContentView, .resizable]) + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.minSize = NSSize(width: 160, height: 90) + window.contentMinSize = NSSize(width: 160, height: 90) + var behavior = window.collectionBehavior + behavior.subtract([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone, .moveToActiveSpace]) + behavior.formUnion([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]) + window.collectionBehavior = behavior + window.level = .floating + window.hidesOnDeactivate = false + window.isMovableByWindowBackground = true + fitCompactWindow(window, initial: true) + setState(compact: true, transitioning: false) + } + + private func restoreSnapshot(to window: NSWindow) { + guard let snapshot else { return } + window.aspectRatio = .zero + window.minSize = .zero + window.contentMinSize = .zero + window.styleMask = snapshot.styleMask + window.titleVisibility = snapshot.titleVisibility + window.titlebarAppearsTransparent = snapshot.titlebarAppearsTransparent + window.collectionBehavior = snapshot.collectionBehavior + window.level = snapshot.level + window.hidesOnDeactivate = snapshot.hidesOnDeactivate + window.isMovableByWindowBackground = snapshot.isMovableByWindowBackground + window.minSize = snapshot.minSize + window.contentMinSize = snapshot.contentMinSize + window.setFrame(snapshot.frame, display: true) + window.aspectRatio = snapshot.aspectRatio + self.snapshot = nil + } + + private func finishSession() { + sessionActive = false + originatedFullScreen = false + wantsCompact = false + wantsFullScreen = false + setState(compact: false, transitioning: false) + } + + private func setState(compact: Bool, transitioning: Bool) { + guard isCompact != compact || isTransitioning != transitioning else { return } + isCompact = compact + isTransitioning = transitioning + onChange?() + } + + private func visibleFrame(for window: NSWindow) -> NSRect { + (window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame + } + + private func fitCompactWindow(_ window: NSWindow, initial: Bool) { + let visibleFrame = visibleFrame(for: window) + let usable = Self.usableFrame(visibleFrame) + var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame) + 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.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height) + } + window.aspectRatio = .zero + window.setFrame(frame, display: true) + if Self.canFitAspect(videoAspect, available: usable.size) { + window.aspectRatio = NSSize(width: videoAspect, height: 1) + } + fittedAspect = videoAspect + } + + nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect) -> NSRect { + let usable = usableFrame(visibleFrame) + let size = compactSize(aspect: aspect, width: 320, available: usable.size) + return NSRect(x: usable.maxX - size.width, y: usable.minY, width: size.width, height: size.height) + } + + nonisolated static func snappedFrame(_ frame: NSRect, visibleFrame: NSRect) -> NSRect { + guard frame.origin.x.isFinite, frame.origin.y.isFinite, + frame.width.isFinite, frame.height.isFinite, frame.width >= 0, frame.height >= 0, + validVisibleFrame(visibleFrame) else { return frame } + let usable = usableFrame(visibleFrame) + var result = frame + result.origin.x = snappedOrigin(frame.minX, lower: usable.minX, upper: usable.maxX - frame.width) + result.origin.y = snappedOrigin(frame.minY, lower: usable.minY, upper: usable.maxY - frame.height) + return result + } + + nonisolated private static func snappedOrigin(_ origin: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat { + let nearest = abs(origin - lower) <= abs(origin - upper) ? lower : upper + return abs(origin - nearest) <= 24 ? nearest : origin + } + + nonisolated private static func validVisibleFrame(_ frame: NSRect) -> Bool { + frame.origin.x.isFinite && frame.origin.y.isFinite && frame.width.isFinite && frame.height.isFinite && + frame.width > 0 && frame.height > 0 && frame.maxX.isFinite && frame.maxY.isFinite + } + + nonisolated private static func usableFrame(_ visibleFrame: NSRect) -> NSRect { + guard validVisibleFrame(visibleFrame) else { return .zero } + return visibleFrame.insetBy(dx: min(16, visibleFrame.width / 2), + dy: min(16, visibleFrame.height / 2)) + } + + nonisolated private static func canFitAspect(_ aspect: Double, available: NSSize) -> Bool { + guard aspect.isFinite, aspect > 0, available.width > 0, available.height > 0 else { return false } + return aspect >= min(160, available.width) / available.height && + aspect <= available.width / min(90, available.height) + } + + nonisolated private static func compactSize(aspect: Double, width: CGFloat, available: NSSize) -> NSSize { + guard available.width > 0, available.height > 0 else { return .zero } + let aspect = aspect.isFinite && aspect > 0 ? aspect : 16.0 / 9.0 + var width = min(max(width, min(160, available.width)), available.width) + if canFitAspect(aspect, available: available) { + width = min(max(width, min(90, available.height) * aspect), available.height * aspect) + return NSSize(width: width, height: width / aspect) + } + // Extreme video ratios letterbox instead of violating the compact minimum or screen bounds. + return NSSize(width: width, height: min(max(width / (16.0 / 9.0), min(90, available.height)), available.height)) + } +} diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 5a0c151..a7a150c 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -16,6 +16,17 @@ final class PlayerModel: ObservableObject { @Published private(set) var controlsVisible = true @Published private(set) var isFullScreen = false @Published private(set) var isOnTop = false + let compactWindow = CompactWindowController() + 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 compactContentMinimumHeight: CGFloat { + guard let window else { return 90 } + // Hosting-view minimums include the native titlebar safe area, even with full-size content. + return max(0, 90 - (window.frame.height - window.contentLayoutRect.height)) + } let videoView: NSView? private var handle: OpaquePointer? @@ -41,6 +52,7 @@ final class PlayerModel: ObservableObject { videoView = nil commandError = "Could not create the media player." } + compactWindow.onChange = { [weak self] in self?.objectWillChange.send() } timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.poll() } } @@ -131,6 +143,7 @@ final class PlayerModel: ObservableObject { commandError = "Could not queue closing the file. Please try again." return } + compactWindow.restore(resumeFullScreen: false) fileName = nil fileIcon = nil needsVideoFit = false @@ -178,8 +191,11 @@ final class PlayerModel: ObservableObject { } func attachWindow(_ window: NSWindow) { + guard self.window !== window else { return } self.window = window window.title = fileName ?? "terebi" + window.contentMinSize = NSSize(width: 400, height: 240) + compactWindow.attach(window) updateWindowState() fitVideoWindow() } @@ -188,18 +204,30 @@ final class PlayerModel: ObservableObject { guard let window else { return } isFullScreen = window.styleMask.contains(.fullScreen) windowActive = window.isKeyWindow - window.level = isOnTop && !isFullScreen ? .floating : .normal - if !isFullScreen { fitVideoWindow() } + if !compactWindow.isTransitioning { + window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal + } + if !isFullScreen && !compactWindow.isTransitioning { fitVideoWindow() } revealControls() } func toggleFullScreen() { + guard !compactWindow.isTransitioning else { return } + if isCompact { compactWindow.restore(resumeFullScreen: false) } window?.toggleFullScreen(nil) revealControls() } + func toggleCompact() { + guard canToggleCompact else { return } + compactWindow.toggle(aspect: state.video_aspect_ratio) + controlsHovered = false + titleHovered = false + revealControls() + } + func toggleOnTop() { - guard !isFullScreen else { return } + guard !isFullScreen, !isCompact, !compactWindow.isTransitioning else { return } isOnTop.toggle() window?.level = isOnTop ? .floating : .normal revealControls() @@ -229,7 +257,7 @@ final class PlayerModel: ObservableObject { withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) { controlsVisible = false } - NSCursor.setHiddenUntilMouseMoves(hideCursor) + NSCursor.setHiddenUntilMouseMoves(hideCursor && windowActive && !isCompact) } func beginSeeking() { @@ -252,14 +280,19 @@ final class PlayerModel: ObservableObject { private var shouldHideControls: Bool { canTogglePlayback && state.has_video && fileName != nil && !isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging && - windowActive && errorText == nil && + (windowActive || isCompact) && errorText == nil && !NSWorkspace.shared.isVoiceOverEnabled } private func fitVideoWindow() { - guard needsVideoFit, canTogglePlayback, let window, !isFullScreen else { return } - needsVideoFit = false + guard needsVideoFit, canTogglePlayback, let window, !isFullScreen, + !compactWindow.isTransitioning else { return } let aspect = state.video_aspect_ratio + if isCompact { + compactWindow.updateAspect(state.video_aspect_ratio) + return + } + needsVideoFit = false guard state.has_video, aspect.isFinite, aspect > 0 else { window.aspectRatio = .zero return @@ -292,6 +325,10 @@ final class PlayerModel: ObservableObject { } let statusChanged = state.status != next.status state = next + if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || + (!isBusy && !next.has_video) { + compactWindow.restore(resumeFullScreen: false) + } if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false } if !isSeeking && !seekPending { seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration) @@ -302,6 +339,7 @@ final class PlayerModel: ObservableObject { } func shutdown() { + compactWindow.restore(resumeFullScreen: false) timer?.invalidate() timer = nil hideDeadline = nil @@ -417,6 +455,50 @@ private struct VideoSurface: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) {} } +@MainActor +private struct CompactDragSurface: NSViewRepresentable { + let model: PlayerModel + + func makeNSView(context: Context) -> DragView { DragView(model: model) } + func updateNSView(_ view: DragView, context: Context) {} + + final class DragView: NSView { + private let model: PlayerModel + private var hoverTracking: NSTrackingArea? + + init(model: PlayerModel) { + self.model = model + super.init(frame: .zero) + } + required init?(coder: NSCoder) { nil } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let hoverTracking { removeTrackingArea(hoverTracking) } + let area = NSTrackingArea(rect: .zero, + options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited, .mouseMoved], + owner: self, userInfo: nil) + hoverTracking = area + addTrackingArea(area) + } + + override func mouseEntered(with event: NSEvent) { model.revealControls() } + override func mouseMoved(with event: NSEvent) { model.revealControls() } + override func mouseExited(with event: NSEvent) { model.hideControls(hideCursor: false) } + + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 { + model.toggleCompact() + return + } + model.setControlsDragging(true) + window?.performDrag(with: event) + model.compactWindow.snapAfterDrag() + model.setControlsDragging(false) + } + } +} + @MainActor private struct PlayerContent: View { @ObservedObject var model: PlayerModel @@ -460,20 +542,61 @@ private struct PlayerContent: View { iconButton(model.isOnTop ? "pin.fill" : "pin", model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) .foregroundStyle(.white.opacity(model.isOnTop ? 1 : 0.6)) - .disabled(model.isFullScreen) + .disabled(model.isFullScreen || model.compactWindow.isTransitioning) } .padding(.leading, model.isFullScreen ? 16 : 86) .padding(.trailing, 12) .frame(height: 32) .foregroundStyle(.white.opacity(0.6)) .background(PlayerGlass(titlebar: true).allowsHitTesting(false)) - .background(WindowChrome(model: model)) .onHover(perform: model.setTitleHovered) .opacity(model.controlsVisible ? 1 : 0) .allowsHitTesting(model.controlsVisible) .accessibilityHidden(!model.controlsVisible) } + private var actionsMenu: some View { + Menu { + Button("Open File…", action: model.openPanel).disabled(!model.canOpen) + Button("Close File", action: model.closeFile).disabled(!model.canClose) + Divider() + Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact) + .disabled(!model.canToggleCompact) + Toggle("Snap to Screen Edges", isOn: Binding( + get: { model.compactWindow.snappingEnabled }, + set: { model.compactWindow.snappingEnabled = $0 })) + Divider() + Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) + .disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning) + } label: { + Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityLabel("Playback Actions") + .help("Playback Actions") + } + + private var compactControls: some View { + HStack(spacing: 16) { + iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill", + model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback) + .disabled(!model.canTogglePlayback) + iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact) + .disabled(model.compactWindow.isTransitioning) + actionsMenu + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .foregroundStyle(.white.opacity(0.85)) + .background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10))) + .onHover(perform: model.setControlsHovered) + .opacity(model.controlsVisible ? 1 : 0) + .allowsHitTesting(model.controlsVisible) + .accessibilityHidden(!model.controlsVisible) + } + private var playbackControls: some View { VStack(spacing: 8) { HStack(spacing: 0) { @@ -509,20 +632,8 @@ private struct PlayerContent: View { HStack(spacing: 12) { iconButton(model.isFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right", model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen) - Menu { - Button("Open File…", action: model.openPanel).disabled(!model.canOpen) - Button("Close File", action: model.closeFile).disabled(!model.canClose) - Divider() - Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) - .disabled(model.isFullScreen) - } label: { - Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .accessibilityLabel("Playback Actions") - .help("Playback Actions") + .disabled(model.compactWindow.isTransitioning) + actionsMenu } .frame(width: 106, alignment: .trailing) } @@ -580,6 +691,9 @@ private struct PlayerContent: View { .exclusively(before: TapGesture().onEnded { model.hideControls() }) ) } + if model.isCompact { + CompactDragSurface(model: model).accessibilityHidden(true) + } if let error = model.errorText { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow) @@ -617,9 +731,24 @@ private struct PlayerContent: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - titlebar + if model.isCompact { + Text(model.fileName ?? "terebi") + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + .padding(8) + .frame(maxWidth: .infinity) + .foregroundStyle(.white.opacity(0.75)) + .background(PlayerGlass(titlebar: true)) + .opacity(model.controlsVisible ? 1 : 0) + .allowsHitTesting(false) + .accessibilityHidden(!model.controlsVisible) + compactControls + .position(x: geometry.size.width / 2, y: geometry.size.height - 28) + } else { + titlebar + } - if model.canTogglePlayback { + if model.canTogglePlayback && !model.isCompact { playbackControls .frame(width: 440, height: 68) .background { @@ -663,14 +792,17 @@ private struct PlayerContent: View { } .onContinuousHover { phase in switch phase { - case .active: break + case .active: + if model.isCompact { model.revealControls() } case .ended: model.hideControls(hideCursor: false) } } } + .frame(minWidth: model.isCompact ? 160 : 400, + minHeight: model.isCompact ? model.compactContentMinimumHeight : 240) .ignoresSafeArea() - .frame(minWidth: 400, minHeight: 240) .preferredColorScheme(.dark) + .background(WindowChrome(model: model)) } } @@ -687,6 +819,7 @@ private struct WindowChrome: NSViewRepresentable { private weak var configuredWindow: NSWindow? private var monitor: Any? private var observers: [NSObjectProtocol] = [] + private var dragOrigin: NSPoint? init(model: PlayerModel) { self.model = model @@ -747,9 +880,23 @@ private struct WindowChrome: NSViewRepresentable { } }) } - monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .keyDown]) { [weak self] event in + monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .leftMouseUp, .keyDown]) { [weak self] event in let consumed = MainActor.assumeIsolated { - guard let self, event.window === self.configuredWindow else { return false } + guard let self, let window = self.configuredWindow else { return false } + if event.type == .leftMouseUp { + let origin = self.dragOrigin + self.dragOrigin = nil + if let origin { + Task { @MainActor [weak self, weak window] in + guard let self, let window, self.configuredWindow === window, + self.model.isCompact, window.frame.origin != origin else { return } + self.model.compactWindow.snapAfterDrag() + } + } + return false + } + guard event.window === window else { return false } + if event.type == .leftMouseDown { self.dragOrigin = window.frame.origin } if event.type == .keyDown && event.keyCode == 48 { self.model.revealControls() } if event.type == .mouseMoved || event.type == .leftMouseDown { self.model.revealControls() @@ -768,7 +915,7 @@ private struct WindowChrome: NSViewRepresentable { } func updateButtons() { - let hidden = !model.controlsVisible || model.isFullScreen + let hidden = !model.controlsVisible || model.isFullScreen || model.isCompact for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { if let view = configuredWindow?.standardWindowButton(button), view.isHidden != hidden { view.isHidden = hidden @@ -776,11 +923,17 @@ private struct WindowChrome: NSViewRepresentable { } } - override func mouseDown(with event: NSEvent) { window?.performDrag(with: event) } + override func mouseDown(with event: NSEvent) { + model.setControlsDragging(true) + window?.performDrag(with: event) + model.compactWindow.snapAfterDrag() + model.setControlsDragging(false) + } func detach() { if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil + dragOrigin = nil observers.forEach(NotificationCenter.default.removeObserver) observers.removeAll() configuredWindow = nil @@ -849,8 +1002,15 @@ struct TerebiApp: App { CommandGroup(after: .windowArrangement) { Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen) .keyboardShortcut("f", modifiers: [.control, .command]) + .disabled(model.compactWindow.isTransitioning) + Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact) + .keyboardShortcut("p", modifiers: [.control, .command]) + .disabled(!model.canToggleCompact) + Toggle("Snap to Screen Edges", isOn: Binding( + get: { model.compactWindow.snappingEnabled }, + set: { model.compactWindow.snappingEnabled = $0 })) Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) - .disabled(model.isFullScreen) + .disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning) } } }