ui: frost playback titles and hide chrome on pointer exit

This commit is contained in:
2026-10-12 13:30:29 +02:00
parent 0ed35b4a58
commit 93acc1436a
3 changed files with 79 additions and 21 deletions
+24
View File
@@ -68,11 +68,35 @@ struct SystemPlaybackChecks {
require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry")
model.open(directory.appendingPathComponent("av.mp4"))
if !NSWorkspace.shared.isVoiceOverEnabled {
model.setPointerInsideWindow(false)
require(!model.controlsVisible, "pointer exit hides playback chrome while media is loading")
model.setPointerInsideWindow(true)
}
until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING }
require(center.playbackState == .playing, "playing publishes macOS playback ownership")
require(model.setPlayback(playing: false), "explicit pause accepted")
until("pause acknowledged") { model.state.status == PLAYER_PAUSED }
let pausedPosition = model.state.position
if !NSWorkspace.shared.isVoiceOverEnabled {
model.setControlsHovered(true)
model.setTitleHovered(true)
model.setPointerInsideWindow(false)
require(!model.controlsVisible,
"pointer exit hides paused playback chrome despite stale hover and inactive window")
model.setControlsHovered(false)
model.setTitleHovered(false)
model.updateWindowState()
require(!model.controlsVisible,
"late hover/focus updates cannot reshow chrome while the pointer is outside")
model.revealControls(forKeyboardNavigation: true)
model.revealControls()
require(model.controlsVisible, "Tab navigation can reveal controls without moving the pointer")
model.setPointerInsideWindow(false)
require(!model.controlsVisible, "another pointer exit dismisses keyboard-revealed controls")
model.setPointerInsideWindow(true)
require(model.controlsVisible, "pointer reentry reveals paused playback chrome")
}
require(model.setPlayback(playing: false), "repeated pause accepted")
until("idempotent pause does not restart playback") {
model.state.status == PLAYER_PAUSED && center.playbackState == .paused
+2 -2
View File
@@ -190,7 +190,7 @@ The empty player uses the same HUD-glass material, 12-point rounded corners, and
With no media open, the full window uses native behind-window HUD glass to frost the desktop and windows beneath it. The idle Metal surface stays attached but hidden; opening media restores a solid black background and letterboxing, and closing it restores the frosted background. macOS controls the material’s appearance, including Reduce Transparency.
Normal and compact titlebars have no separate glass strip or separator: content extends beneath AppKit’s transparent titlebar using `fullSizeContentView`, `titlebarAppearsTransparent`, and `titlebarSeparatorStyle = .none`. The title and pin sit directly over the existing background or video, with native traffic lights and window dragging preserved.
Normal and compact titlebars have no full-width glass strip or separator: content extends beneath AppKit’s transparent titlebar using `fullSizeContentView`, `titlebarAppearsTransparent`, and `titlebarSeparatorStyle = .none`. Normal-mode titles and their file icons have a small rounded HUD-glass backdrop for legibility; the pin stays over the existing background. Compact titles sit in a controls-style frosted box with 8-point top/right insets, matching the controller’s bottom inset. Long titles truncate in the middle and fit the 160-point minimum width. Native traffic lights and window dragging are preserved.
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.
@@ -212,7 +212,7 @@ Frame stepping uses decoded presentation timestamps, not an average-FPS time off
- Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking normal-mode video also toggles fullscreen.
- The timeline slider seeks on release and holds the requested position until queued seeking finishes. Both slider tracks span the native thumb-center travel range; the orange fill ends at the current volume thumb center. The volume thumb is opaque light gray.
- **Single-click 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.
- **Single-click normal-mode video:** hide controls. Mouse movement reveals them; video controls fade after 2.5 seconds, including while paused. While the pointer is inside, hovering the normal titlebar or either controller and an inactive normal-mode window keep them visible; audio-only controls remain visible. Leaving the window starts the title/control fade immediately in either mode, including paused, loading, and audio-only playback; late hover/focus updates do not reshow them. Reentry reveals them. Active dragging/seeking, errors, empty-window opening actions, and VoiceOver remain protected; a drag released or seek acknowledged outside hides the chrome. Tab navigation can explicitly reveal controls without moving the pointer. Compact hover and window exit never hide the desktop cursor.
- Click the right-hand time label to switch between total and remaining time. The titlebar pin keeps the window on top.
Only implemented actions have controls; there are no placeholder playlist, subtitle, 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).
+44 -10
View File
@@ -59,6 +59,8 @@ final class PlayerModel: ObservableObject {
private var timer: Timer?
private weak var window: NSWindow?
private var hideDeadline: ContinuousClock.Instant?
private var pointerInsideWindow = true
private var keyboardNavigation = false
private var controlsHovered = false
private var titleHovered = false
private var controlsDragging = false
@@ -596,6 +598,18 @@ final class PlayerModel: ObservableObject {
return true
}
func setPointerInsideWindow(_ inside: Bool) {
pointerInsideWindow = inside
keyboardNavigation = false
if inside {
revealControls()
} else {
controlsHovered = false
titleHovered = false
hideControls(hideCursor: false)
}
}
func setControlsHovered(_ hovered: Bool) {
guard controlsHovered != hovered else { return }
controlsHovered = hovered
@@ -617,10 +631,11 @@ final class PlayerModel: ObservableObject {
func hideControls(hideCursor: Bool = true) {
guard shouldHideControls else { return }
hideDeadline = nil
keyboardNavigation = false
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) {
controlsVisible = false
}
NSCursor.setHiddenUntilMouseMoves(hideCursor && windowActive && !isCompact)
NSCursor.setHiddenUntilMouseMoves(hideCursor && pointerInsideWindow && windowActive && !isCompact)
}
func beginSeeking() {
@@ -630,9 +645,14 @@ final class PlayerModel: ObservableObject {
revealControls()
}
func revealControls() {
func revealControls(forKeyboardNavigation: Bool = false) {
hideDeadline = nil
NSCursor.setHiddenUntilMouseMoves(false)
if forKeyboardNavigation { keyboardNavigation = true }
if !keyboardNavigation && !pointerInsideWindow && shouldHideControls {
hideControls(hideCursor: false)
return
}
if !controlsVisible {
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeOut(duration: 0.25)) {
controlsVisible = true
@@ -643,9 +663,11 @@ final class PlayerModel: ObservableObject {
}
private var shouldHideControls: Bool {
canTogglePlayback && state.has_video && fileName != nil &&
!isSeeking && !transition.seekPending && !controlsHovered && !titleHovered && !controlsDragging &&
(windowActive || isCompact) && errorText == nil &&
((canTogglePlayback && fileName != nil && (state.has_video || !pointerInsideWindow)) ||
(!pointerInsideWindow && isBusy)) &&
!isSeeking && !transition.seekPending && !controlsDragging &&
(!pointerInsideWindow || (!controlsHovered && !titleHovered)) &&
(windowActive || isCompact || !pointerInsideWindow) && errorText == nil &&
!NSWorkspace.shared.isVoiceOverEnabled
}
@@ -709,7 +731,7 @@ final class PlayerModel: ObservableObject {
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
}
fitVideoWindow()
if statusChanged { revealControls() }
if statusChanged || (!pointerInsideWindow && !keyboardNavigation && controlsVisible) { revealControls() }
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
updateAutomaticQuality()
if let restored = observation.restored {
@@ -1223,6 +1245,7 @@ private struct PlayerContent: View {
}
private var titlebar: some View {
HStack(spacing: 4) {
HStack(spacing: 4) {
if let icon = model.fileIcon {
Image(nsImage: icon).resizable().frame(width: 16, height: 16)
@@ -1234,6 +1257,10 @@ private struct PlayerContent: View {
.lineLimit(1)
.truncationMode(.middle)
.help(model.fileName ?? "terebi")
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false))
Spacer(minLength: 12)
iconButton(model.isOnTop ? "pin.fill" : "pin",
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
@@ -1495,8 +1522,12 @@ private struct PlayerContent: View {
Text(model.fileName ?? "terebi")
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.truncationMode(.middle)
.help(model.fileName ?? "terebi")
.padding(8)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)).allowsHitTesting(false))
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(8)
.frame(maxWidth: .infinity)
.foregroundStyle(.white.opacity(0.75))
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(false)
@@ -1511,6 +1542,7 @@ private struct PlayerContent: View {
.accessibilityHidden(!model.isCompact)
titlebar
.frame(width: geometry.size.width)
.animation(compactChromeAnimation) { content in
content.opacity(model.isCompact ? 0 : 1)
}
@@ -1566,8 +1598,8 @@ private struct PlayerContent: View {
.onContinuousHover { phase in
switch phase {
case .active:
if model.isCompact { model.revealControls() }
case .ended: model.hideControls(hideCursor: false)
model.setPointerInsideWindow(true)
case .ended: model.setPointerInsideWindow(false)
}
}
}
@@ -1646,7 +1678,9 @@ private struct WindowChrome: NSViewRepresentable {
}
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(forKeyboardNavigation: true)
}
if event.type == .mouseMoved || event.type == .leftMouseDown {
self.model.revealControls()
} else if event.type == .keyDown {