ui: frost playback titles and hide chrome on pointer exit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user