feat: add same-window compact playback with magnetic snapping

This commit is contained in:
2026-10-10 21:07:14 +02:00
parent 681a7be6f7
commit fa66020951
5 changed files with 826 additions and 39 deletions
+383
View File
@@ -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))
}
}
+192 -32
View File
@@ -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)
}
}
}