Add macOS player top bar with title, avatar, and close

Show a top row in the macOS player controls (like tvOS) with the channel
avatar, video title, author name, and a close button whenever controls are
visible.

- Offset the top row below the traffic lights in the floating window by
  measuring the window buttons against the top bar (no effect in fullscreen,
  the inline sheet, or the side panel where there is no overlap).
- Tapping the avatar/title/author toggles the floating details panel, mirroring
  the iOS title/author control.
- Hide the controls immediately when the details panel opens instead of waiting
  for the auto-hide timer.
This commit is contained in:
Arkadiusz Fal
2026-05-29 08:17:14 +02:00
parent 486024834d
commit 32f526e05b
2 changed files with 215 additions and 4 deletions

View File

@@ -1591,7 +1591,11 @@ extension ExpandedPlayerSheet {
},
onShowQueue: { [self] in
showingQueueSheet = true
}
},
onTitleTap: {
onTogglePanel()
},
isDetailsPanelVisible: isPanelVisible
)
.frame(width: availableWidth, height: availableHeight)
.offset(x: controlsOffset)

View File

@@ -32,6 +32,11 @@ struct MacOSPlayerControlsView: View {
var onMuteToggled: (() -> Void)? = nil
var onShowSettings: (() -> Void)? = nil
var onShowQueue: (() -> Void)? = nil
/// Tapping the avatar / title / author in the top bar toggles the video details panel.
var onTitleTap: (() -> Void)? = nil
/// Whether the floating video details panel is currently visible. When it opens,
/// the controls hide immediately instead of waiting for the auto-hide timer.
var isDetailsPanelVisible: Bool = false
// MARK: - State
@@ -41,6 +46,12 @@ struct MacOSPlayerControlsView: View {
@State private var showControls: Bool?
@State private var keyboardMonitor: Any?
/// Extra top inset for the top bar so its content drops below the window's
/// traffic-light buttons in the separate/floating window presentation, while
/// keeping the avatar/title aligned to the leading edge.
/// Stays 0 when there is no overlap (inline sheet, side panel, fullscreen).
@State private var trafficLightInset: CGFloat = 0
// MARK: - Computed Properties
/// Controls visibility - show when hovering, interacting, or paused
@@ -59,6 +70,80 @@ struct MacOSPlayerControlsView: View {
playerState.isFailed
}
/// Yattee Server URL used by `ChannelAvatarView` for avatar fallback.
private var yatteeServerURL: URL? {
appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url
}
// MARK: - Top Bar
/// Top row showing channel avatar, video title, author name, and a close button.
/// Mirrors the tvOS `topBar`, scaled down for macOS.
private var topBar: some View {
HStack(alignment: .center, spacing: 12) {
if let video = playerState.currentVideo {
Button {
onTitleTap?()
} label: {
HStack(alignment: .center, spacing: 12) {
ChannelAvatarView(
author: video.author,
size: 36,
yatteeServerURL: yatteeServerURL,
source: video.id.source
)
VStack(alignment: .leading, spacing: 2) {
Text(video.title)
.font(.headline)
.lineLimit(1)
.foregroundStyle(.white)
Text(video.author.name)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(onTitleTap == nil)
.help(Text("player.controls.info"))
}
Spacer(minLength: 12)
if onClose != nil {
Button {
onClose?()
} label: {
Image(systemName: "xmark")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(.ultraThinMaterial, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("player.controls.close"))
}
}
.padding(.horizontal, 20)
.padding(.top, 16 + trafficLightInset)
.padding(.bottom, 24)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
LinearGradient(
colors: [.black.opacity(0.55), .clear],
startPoint: .top, endPoint: .bottom
)
.allowsHitTesting(false)
)
.background(
TrafficLightInsetReader(inset: $trafficLightInset)
.allowsHitTesting(false)
)
}
// MARK: - Body
var body: some View {
@@ -72,8 +157,10 @@ struct MacOSPlayerControlsView: View {
}
.allowsHitTesting(playerState.playbackState != .ended && !playerState.isFailed)
// Control bar at bottom center
VStack {
// Top bar (title/author/avatar/close) + control bar at bottom center
VStack(spacing: 0) {
topBar
Spacer()
MacOSControlBar(
@@ -109,8 +196,8 @@ struct MacOSPlayerControlsView: View {
}
)
.frame(width: 650)
.padding(.bottom, 20)
}
.padding(.bottom, 20)
.opacity(shouldShowControls ? 1 : 0)
.allowsHitTesting(shouldShowControls)
}
@@ -127,6 +214,16 @@ struct MacOSPlayerControlsView: View {
startHideTimer()
}
}
.onChange(of: isDetailsPanelVisible) { _, isVisible in
if isVisible {
// Hide controls immediately when the details panel opens.
showControls = false
cancelHideTimer()
} else {
// Restore default hover/paused-driven visibility when it closes.
showControls = nil
}
}
.onChange(of: playerState.playbackState) { oldState, newState in
// When playback starts, start hide timer
if newState == .playing && shouldShowControls {
@@ -242,6 +339,116 @@ struct MacOSPlayerControlsView: View {
}
}
// MARK: - Traffic Light Inset Reader
/// Reports how far the top bar's leading content must be pushed to clear the
/// hosting window's traffic-light buttons (close/minimize/zoom).
///
/// Returns 0 whenever the buttons don't actually overlap this view e.g. in
/// fullscreen (buttons hidden), or when the controls render inside the inline
/// sheet / side panel where the top bar sits below the window's titlebar.
private struct TrafficLightInsetReader: NSViewRepresentable {
@Binding var inset: CGFloat
func makeNSView(context: Context) -> ReaderView {
let view = ReaderView()
view.onUpdate = { newInset in
if inset != newInset { inset = newInset }
}
return view
}
func updateNSView(_ nsView: ReaderView, context: Context) {
nsView.onUpdate = { newInset in
if inset != newInset { inset = newInset }
}
DispatchQueue.main.async { nsView.recompute() }
}
final class ReaderView: NSView {
var onUpdate: ((CGFloat) -> Void)?
private var observers: [NSObjectProtocol] = []
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
removeObservers()
postsFrameChangedNotifications = true
guard let window else {
onUpdate?(0)
return
}
let center = NotificationCenter.default
let names: [NSNotification.Name] = [
NSWindow.didEnterFullScreenNotification,
NSWindow.didExitFullScreenNotification,
NSWindow.didResizeNotification,
NSWindow.didBecomeKeyNotification
]
for name in names {
observers.append(center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
self?.recompute()
})
}
observers.append(center.addObserver(forName: NSView.frameDidChangeNotification, object: self, queue: .main) { [weak self] _ in
self?.recompute()
})
recompute()
}
func recompute() {
guard let window,
!window.styleMask.contains(.fullScreen) else {
onUpdate?(0)
return
}
let buttons = [
window.standardWindowButton(.closeButton),
window.standardWindowButton(.miniaturizeButton),
window.standardWindowButton(.zoomButton)
].compactMap { $0 }.filter { !$0.isHidden && $0.superview != nil }
guard !buttons.isEmpty else {
onUpdate?(0)
return
}
// Union of the traffic lights and this view, both in window base coords.
let buttonsRect = buttons
.map { $0.convert($0.bounds, to: nil) }
.reduce(NSRect.null) { $0.union($1) }
let selfRect = convert(bounds, to: nil)
// Only inset when the buttons actually sit over this view.
guard selfRect.intersects(buttonsRect) else {
onUpdate?(0)
return
}
// Window base coords have a bottom-left origin (Y grows upward), so the
// buttons' bottom edge is `buttonsRect.minY` and this view's top edge is
// `selfRect.maxY`. Drop the content from its top edge down to just below
// the buttons, keeping it aligned to the leading edge.
let overlap = selfRect.maxY - buttonsRect.minY
onUpdate?(max(0, overlap + 8))
}
private func removeObservers() {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
observers.removeAll()
}
deinit {
removeObservers()
}
}
}
// MARK: - Preview
#Preview {