diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index 38b4fa2..ea5df66 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -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 diff --git a/README.md b/README.md index a8167fd..a78cedf 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index f2b7f6d..9b76429 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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 { @@ -1224,16 +1246,21 @@ private struct PlayerContent: View { private var titlebar: some View { HStack(spacing: 4) { - if let icon = model.fileIcon { - Image(nsImage: icon).resizable().frame(width: 16, height: 16) - } else { - Image(systemName: "play.rectangle.fill").font(.system(size: 14)) + HStack(spacing: 4) { + if let icon = model.fileIcon { + Image(nsImage: icon).resizable().frame(width: 16, height: 16) + } else { + Image(systemName: "play.rectangle.fill").font(.system(size: 14)) + } + Text(model.fileName ?? "terebi") + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) + .help(model.fileName ?? "terebi") } - Text(model.fileName ?? "terebi") - .font(.system(size: 13, weight: .semibold)) - .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 {