ui: match titlebar chrome and prefer upward caption menus

This commit is contained in:
2026-10-12 23:23:33 +02:00
parent 7d24ce609e
commit 2398370c23
2 changed files with 80 additions and 28 deletions
+2 -2
View File
@@ -251,9 +251,9 @@ The inline URL field, including its placeholder, uses the same 12-point system f
With no media open, the full window uses native behind-window HUD glass with a 35%-opaque brown tint. Static, cached amber/rust radial gradients add ambient color only to the empty screen; they disappear during loading or errors, and are never drawn over video or playback chrome. Glass title/control/error surfaces use the same brown tint with half-point sand outlines. Decorative drawing does not intercept input. The idle Metal surface stays attached but hidden; opening media restores a solid black background and letterboxing without tinting the video, and closing it restores the ambient frosted background. macOS controls the material’s appearance, including Reduce Transparency.
The captions selector sits immediately right of the compact-mode button in both playback-control layouts and is disabled when no external subtitle tracks are available. Subtitle text uses the floating controller's rounded HUD-glass background rather than an opaque black box. Subtitle-load errors remain visible independently of auto-hidden controls.
The captions selector sits immediately right of the compact-mode button in both playback-control layouts and is disabled when no external subtitle tracks are available. Its explicitly sized native symbol has a 24-point footprint comparable to the compact-mode icon. The native pull-down menu prefers opening above the button; AppKit can reposition it to fit the screen. Subtitle text uses the floating controller's rounded HUD-glass background rather than an opaque black box. Subtitle-load errors remain visible independently of auto-hidden controls.
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`. Titles retain their rounded HUD-glass backdrop. The pin and settings buttons share a separate container with the same tint, outline and rounded treatment; settings sits immediately right of the pin, and both retain 24-point click targets. Compact titles and the shared button container occupy the top row with 8-point insets; title and progress remain adjacent. Native traffic lights and window dragging are 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`. Titles retain their rounded HUD-glass backdrop. The pin and settings buttons share a separate container matching the title's height, horizontal padding, tint, outline and corner radius: 24 points high in normal mode and 28 in compact mode. Settings sits immediately right of the pin, and both retain 24-point click targets. Each title/button pair shares the same vertical inset; compact titles and buttons occupy the top row with 8-point outer insets, while title and progress remain adjacent. Native traffic lights and window dragging are preserved.
Compact titles default to `<title> · <position> / <duration> <unit>`, using acknowledged playback position rather than slider previews. Total duration selects the format: below one minute, `12 / 45 s`; from one minute to below one hour, `05:12 / 42:08 m`; from one hour onward, `01:23 / 02:10 h`. Values truncate to the displayed precision. Set `[ui].compact_title_progress = "percentage"` to append only a whole percentage number (`<title> · 59`, without a percent sign), or `"none"` for title-only display. Percentages truncate and stay within 0–100. Loading, failed, live, unknown-duration and closed media have no progress suffix. Normal-mode titles remain unchanged.
+78 -26
View File
@@ -1305,6 +1305,73 @@ private struct VideoSurface: NSViewRepresentable {
func updateNSView(_ nsView: NSView, context: Context) {}
}
@MainActor
private struct CaptionSelector: NSViewRepresentable {
let model: PlayerModel
func makeNSView(context: Context) -> Selector { Selector(model: model) }
func updateNSView(_ view: Selector, context: Context) { view.updateTracks() }
final class Selector: NSPopUpButton {
private let model: PlayerModel
private var displayedTracks: [ResolvedSubtitle] = []
private var displayedSelection: Int?
private let captionItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
init(model: PlayerModel) {
self.model = model
super.init(frame: NSRect(x: 0, y: 0, width: 24, height: 24), pullsDown: true)
usesItemFromMenu = false
preferredEdge = .maxY
isBordered = false
imagePosition = .imageOnly
imageScaling = .scaleProportionallyUpOrDown
(cell as? NSPopUpButtonCell)?.arrowPosition = .noArrow
let symbol = NSImage(systemSymbolName: "captions.bubble", accessibilityDescription: "Subtitles")?
.withSymbolConfiguration(.init(pointSize: 22, weight: .regular))
symbol?.size = NSSize(width: 24, height: 24)
captionItem.image = symbol
contentTintColor = SunsetPalette.text
toolTip = "Subtitles"
setAccessibilityLabel("Subtitles")
updateTracks()
}
required init?(coder: NSCoder) { nil }
func updateTracks() {
guard menu == nil || displayedSelection != model.selectedSubtitle ||
displayedTracks.count != model.subtitleTracks.count ||
zip(displayedTracks, model.subtitleTracks).contains(where: {
$0.label != $1.label || $0.language != $1.language
}) else { return }
displayedTracks = model.subtitleTracks
displayedSelection = model.selectedSubtitle
let tracks = NSMenu()
tracks.autoenablesItems = false
func add(_ label: String, index: Int?) {
let item = NSMenuItem(title: label, action: #selector(selectTrack(_:)), keyEquivalent: "")
item.target = self
item.tag = index ?? -1
item.state = model.selectedSubtitle == index ? .on : .off
tracks.addItem(item)
}
add("Off", index: nil)
for (index, track) in model.subtitleTracks.enumerated() {
add(track.label + " (" + track.language + ")", index: index)
}
menu = tracks
(cell as? NSPopUpButtonCell)?.menuItem = captionItem
isEnabled = !model.subtitleTracks.isEmpty
}
@objc private func selectTrack(_ item: NSMenuItem) {
model.selectSubtitle(item.tag < 0 ? nil : item.tag)
model.revealControls()
}
}
}
@MainActor
private struct CompactDragSurface: NSViewRepresentable {
let model: PlayerModel
@@ -1452,10 +1519,10 @@ private struct PlayerContent: View {
.help(model.fileName ?? "terebi")
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(height: 24)
.background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false))
Spacer(minLength: 12)
titleControls
titleControls(compact: false)
}
.padding(.leading, model.isFullScreen ? 16 : 86)
.padding(.trailing, 12)
@@ -1467,7 +1534,7 @@ private struct PlayerContent: View {
.accessibilityHidden(!model.controlsVisible)
}
private var titleControls: some View {
private func titleControls(compact: Bool) -> some View {
HStack(spacing: 4) {
iconButton(model.isOnTop ? "pin.fill" : "pin",
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
@@ -1477,30 +1544,14 @@ private struct PlayerContent: View {
actionsMenu
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false))
.frame(height: compact ? 28 : 24)
.background(PlayerGlass(cornerRadius: compact ? 10 : 8)
.clipShape(RoundedRectangle(cornerRadius: compact ? 10 : 8)).allowsHitTesting(false))
}
private var captionsMenu: some View {
Menu {
Button("Off") { model.selectSubtitle(nil) }
ForEach(model.subtitleTracks.indices, id: \.self) { index in
Button {
model.selectSubtitle(index)
} label: {
Label(model.subtitleTracks[index].label + " (" + model.subtitleTracks[index].language + ")",
systemImage: model.selectedSubtitle == index ? "checkmark" : "text.bubble")
}
}
} label: {
Image(systemName: "captions.bubble").font(.system(size: 17)).frame(width: 24, height: 24)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.disabled(model.subtitleTracks.isEmpty)
.accessibilityLabel("Subtitles")
.help("Subtitles")
CaptionSelector(model: model)
.frame(width: 24, height: 24)
}
private var actionsMenu: some View {
@@ -1817,11 +1868,12 @@ private struct PlayerContent: View {
.font(.system(size: 11, weight: .medium))
.accessibilityElement(children: .combine)
.help((model.fileName ?? "terebi") + (progress.map { " · \($0)" } ?? ""))
.padding(8)
.padding(.horizontal, 8)
.frame(height: 28)
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)).allowsHitTesting(false))
.allowsHitTesting(false)
Spacer(minLength: 0)
titleControls
titleControls(compact: true)
}
.padding(8)
.foregroundStyle(Color(SunsetPalette.text))