feat: add same-window compact playback with magnetic snapping
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
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
|
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore
|
||||||
OBJECTS := .build/player.o .build/Output.o
|
OBJECTS := .build/player.o .build/Output.o
|
||||||
|
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/CompactWindow.swift
|
||||||
APP := .build/terebi.app
|
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
|
all: $(APP)/Contents/MacOS/terebi $(APP)/Contents/Info.plist
|
||||||
|
|
||||||
.build:
|
.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
|
.build/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | .build
|
||||||
$(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@
|
$(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
|
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
|
$(APP)/Contents/Info.plist: Resources/Info.plist
|
||||||
mkdir -p $(APP)/Contents
|
mkdir -p $(APP)/Contents
|
||||||
@@ -35,7 +36,7 @@ run: all
|
|||||||
.build/playback-check: Checks/playback.mm $(OBJECTS) Makefile
|
.build/playback-check: Checks/playback.mm $(OBJECTS) Makefile
|
||||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
$(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
|
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 -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
|
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/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
|
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/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
|
||||||
|
|||||||
@@ -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.
|
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 |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `K` / `Space` | Toggle playback |
|
| `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.
|
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.
|
- 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.
|
- 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
|
## 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/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/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/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.
|
- `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency.
|
||||||
|
|
||||||
## Runnable check
|
## 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-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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
-31
@@ -16,6 +16,17 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published private(set) var controlsVisible = true
|
@Published private(set) var controlsVisible = true
|
||||||
@Published private(set) var isFullScreen = false
|
@Published private(set) var isFullScreen = false
|
||||||
@Published private(set) var isOnTop = 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?
|
let videoView: NSView?
|
||||||
private var handle: OpaquePointer?
|
private var handle: OpaquePointer?
|
||||||
@@ -41,6 +52,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
videoView = nil
|
videoView = nil
|
||||||
commandError = "Could not create the media player."
|
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
|
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||||
MainActor.assumeIsolated { self?.poll() }
|
MainActor.assumeIsolated { self?.poll() }
|
||||||
}
|
}
|
||||||
@@ -131,6 +143,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
commandError = "Could not queue closing the file. Please try again."
|
commandError = "Could not queue closing the file. Please try again."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
compactWindow.restore(resumeFullScreen: false)
|
||||||
fileName = nil
|
fileName = nil
|
||||||
fileIcon = nil
|
fileIcon = nil
|
||||||
needsVideoFit = false
|
needsVideoFit = false
|
||||||
@@ -178,8 +191,11 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func attachWindow(_ window: NSWindow) {
|
func attachWindow(_ window: NSWindow) {
|
||||||
|
guard self.window !== window else { return }
|
||||||
self.window = window
|
self.window = window
|
||||||
window.title = fileName ?? "terebi"
|
window.title = fileName ?? "terebi"
|
||||||
|
window.contentMinSize = NSSize(width: 400, height: 240)
|
||||||
|
compactWindow.attach(window)
|
||||||
updateWindowState()
|
updateWindowState()
|
||||||
fitVideoWindow()
|
fitVideoWindow()
|
||||||
}
|
}
|
||||||
@@ -188,18 +204,30 @@ final class PlayerModel: ObservableObject {
|
|||||||
guard let window else { return }
|
guard let window else { return }
|
||||||
isFullScreen = window.styleMask.contains(.fullScreen)
|
isFullScreen = window.styleMask.contains(.fullScreen)
|
||||||
windowActive = window.isKeyWindow
|
windowActive = window.isKeyWindow
|
||||||
window.level = isOnTop && !isFullScreen ? .floating : .normal
|
if !compactWindow.isTransitioning {
|
||||||
if !isFullScreen { fitVideoWindow() }
|
window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal
|
||||||
|
}
|
||||||
|
if !isFullScreen && !compactWindow.isTransitioning { fitVideoWindow() }
|
||||||
revealControls()
|
revealControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleFullScreen() {
|
func toggleFullScreen() {
|
||||||
|
guard !compactWindow.isTransitioning else { return }
|
||||||
|
if isCompact { compactWindow.restore(resumeFullScreen: false) }
|
||||||
window?.toggleFullScreen(nil)
|
window?.toggleFullScreen(nil)
|
||||||
revealControls()
|
revealControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toggleCompact() {
|
||||||
|
guard canToggleCompact else { return }
|
||||||
|
compactWindow.toggle(aspect: state.video_aspect_ratio)
|
||||||
|
controlsHovered = false
|
||||||
|
titleHovered = false
|
||||||
|
revealControls()
|
||||||
|
}
|
||||||
|
|
||||||
func toggleOnTop() {
|
func toggleOnTop() {
|
||||||
guard !isFullScreen else { return }
|
guard !isFullScreen, !isCompact, !compactWindow.isTransitioning else { return }
|
||||||
isOnTop.toggle()
|
isOnTop.toggle()
|
||||||
window?.level = isOnTop ? .floating : .normal
|
window?.level = isOnTop ? .floating : .normal
|
||||||
revealControls()
|
revealControls()
|
||||||
@@ -229,7 +257,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) {
|
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) {
|
||||||
controlsVisible = false
|
controlsVisible = false
|
||||||
}
|
}
|
||||||
NSCursor.setHiddenUntilMouseMoves(hideCursor)
|
NSCursor.setHiddenUntilMouseMoves(hideCursor && windowActive && !isCompact)
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginSeeking() {
|
func beginSeeking() {
|
||||||
@@ -252,14 +280,19 @@ final class PlayerModel: ObservableObject {
|
|||||||
private var shouldHideControls: Bool {
|
private var shouldHideControls: Bool {
|
||||||
canTogglePlayback && state.has_video && fileName != nil &&
|
canTogglePlayback && state.has_video && fileName != nil &&
|
||||||
!isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging &&
|
!isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging &&
|
||||||
windowActive && errorText == nil &&
|
(windowActive || isCompact) && errorText == nil &&
|
||||||
!NSWorkspace.shared.isVoiceOverEnabled
|
!NSWorkspace.shared.isVoiceOverEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fitVideoWindow() {
|
private func fitVideoWindow() {
|
||||||
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen else { return }
|
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen,
|
||||||
needsVideoFit = false
|
!compactWindow.isTransitioning else { return }
|
||||||
let aspect = state.video_aspect_ratio
|
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 {
|
guard state.has_video, aspect.isFinite, aspect > 0 else {
|
||||||
window.aspectRatio = .zero
|
window.aspectRatio = .zero
|
||||||
return
|
return
|
||||||
@@ -292,6 +325,10 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
let statusChanged = state.status != next.status
|
let statusChanged = state.status != next.status
|
||||||
state = next
|
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 !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false }
|
||||||
if !isSeeking && !seekPending {
|
if !isSeeking && !seekPending {
|
||||||
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
|
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
|
||||||
@@ -302,6 +339,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shutdown() {
|
func shutdown() {
|
||||||
|
compactWindow.restore(resumeFullScreen: false)
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
timer = nil
|
||||||
hideDeadline = nil
|
hideDeadline = nil
|
||||||
@@ -417,6 +455,50 @@ private struct VideoSurface: NSViewRepresentable {
|
|||||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
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
|
@MainActor
|
||||||
private struct PlayerContent: View {
|
private struct PlayerContent: View {
|
||||||
@ObservedObject var model: PlayerModel
|
@ObservedObject var model: PlayerModel
|
||||||
@@ -460,20 +542,61 @@ private struct PlayerContent: View {
|
|||||||
iconButton(model.isOnTop ? "pin.fill" : "pin",
|
iconButton(model.isOnTop ? "pin.fill" : "pin",
|
||||||
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||||||
.foregroundStyle(.white.opacity(model.isOnTop ? 1 : 0.6))
|
.foregroundStyle(.white.opacity(model.isOnTop ? 1 : 0.6))
|
||||||
.disabled(model.isFullScreen)
|
.disabled(model.isFullScreen || model.compactWindow.isTransitioning)
|
||||||
}
|
}
|
||||||
.padding(.leading, model.isFullScreen ? 16 : 86)
|
.padding(.leading, model.isFullScreen ? 16 : 86)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
.frame(height: 32)
|
.frame(height: 32)
|
||||||
.foregroundStyle(.white.opacity(0.6))
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
.background(PlayerGlass(titlebar: true).allowsHitTesting(false))
|
.background(PlayerGlass(titlebar: true).allowsHitTesting(false))
|
||||||
.background(WindowChrome(model: model))
|
|
||||||
.onHover(perform: model.setTitleHovered)
|
.onHover(perform: model.setTitleHovered)
|
||||||
.opacity(model.controlsVisible ? 1 : 0)
|
.opacity(model.controlsVisible ? 1 : 0)
|
||||||
.allowsHitTesting(model.controlsVisible)
|
.allowsHitTesting(model.controlsVisible)
|
||||||
.accessibilityHidden(!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 {
|
private var playbackControls: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -509,20 +632,8 @@ private struct PlayerContent: View {
|
|||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
iconButton(model.isFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
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)
|
model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
|
||||||
Menu {
|
.disabled(model.compactWindow.isTransitioning)
|
||||||
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
|
actionsMenu
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
.frame(width: 106, alignment: .trailing)
|
.frame(width: 106, alignment: .trailing)
|
||||||
}
|
}
|
||||||
@@ -580,6 +691,9 @@ private struct PlayerContent: View {
|
|||||||
.exclusively(before: TapGesture().onEnded { model.hideControls() })
|
.exclusively(before: TapGesture().onEnded { model.hideControls() })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if model.isCompact {
|
||||||
|
CompactDragSurface(model: model).accessibilityHidden(true)
|
||||||
|
}
|
||||||
if let error = model.errorText {
|
if let error = model.errorText {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow)
|
Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow)
|
||||||
@@ -617,9 +731,24 @@ private struct PlayerContent: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
titlebar
|
||||||
|
}
|
||||||
|
|
||||||
if model.canTogglePlayback {
|
if model.canTogglePlayback && !model.isCompact {
|
||||||
playbackControls
|
playbackControls
|
||||||
.frame(width: 440, height: 68)
|
.frame(width: 440, height: 68)
|
||||||
.background {
|
.background {
|
||||||
@@ -663,14 +792,17 @@ private struct PlayerContent: View {
|
|||||||
}
|
}
|
||||||
.onContinuousHover { phase in
|
.onContinuousHover { phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .active: break
|
case .active:
|
||||||
|
if model.isCompact { model.revealControls() }
|
||||||
case .ended: model.hideControls(hideCursor: false)
|
case .ended: model.hideControls(hideCursor: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: model.isCompact ? 160 : 400,
|
||||||
|
minHeight: model.isCompact ? model.compactContentMinimumHeight : 240)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.frame(minWidth: 400, minHeight: 240)
|
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
|
.background(WindowChrome(model: model))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,6 +819,7 @@ private struct WindowChrome: NSViewRepresentable {
|
|||||||
private weak var configuredWindow: NSWindow?
|
private weak var configuredWindow: NSWindow?
|
||||||
private var monitor: Any?
|
private var monitor: Any?
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
private var dragOrigin: NSPoint?
|
||||||
|
|
||||||
init(model: PlayerModel) {
|
init(model: PlayerModel) {
|
||||||
self.model = model
|
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 {
|
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 == .keyDown && event.keyCode == 48 { self.model.revealControls() }
|
||||||
if event.type == .mouseMoved || event.type == .leftMouseDown {
|
if event.type == .mouseMoved || event.type == .leftMouseDown {
|
||||||
self.model.revealControls()
|
self.model.revealControls()
|
||||||
@@ -768,7 +915,7 @@ private struct WindowChrome: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateButtons() {
|
func updateButtons() {
|
||||||
let hidden = !model.controlsVisible || model.isFullScreen
|
let hidden = !model.controlsVisible || model.isFullScreen || model.isCompact
|
||||||
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
|
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
|
||||||
if let view = configuredWindow?.standardWindowButton(button), view.isHidden != hidden {
|
if let view = configuredWindow?.standardWindowButton(button), view.isHidden != hidden {
|
||||||
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() {
|
func detach() {
|
||||||
if let monitor { NSEvent.removeMonitor(monitor) }
|
if let monitor { NSEvent.removeMonitor(monitor) }
|
||||||
monitor = nil
|
monitor = nil
|
||||||
|
dragOrigin = nil
|
||||||
observers.forEach(NotificationCenter.default.removeObserver)
|
observers.forEach(NotificationCenter.default.removeObserver)
|
||||||
observers.removeAll()
|
observers.removeAll()
|
||||||
configuredWindow = nil
|
configuredWindow = nil
|
||||||
@@ -849,8 +1002,15 @@ struct TerebiApp: App {
|
|||||||
CommandGroup(after: .windowArrangement) {
|
CommandGroup(after: .windowArrangement) {
|
||||||
Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
|
Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
|
||||||
.keyboardShortcut("f", modifiers: [.control, .command])
|
.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)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user