ui: adopt Sunset palette and warm glass styling

This commit is contained in:
2026-10-12 14:14:57 +02:00
parent 7aa6e5f805
commit 2a75d55c98
2 changed files with 102 additions and 30 deletions
+98 -26
View File
@@ -1018,9 +1018,62 @@ final class PlayerModel: ObservableObject {
}
}
@MainActor
private enum SunsetPalette {
static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.35)
static let text = NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1)
static let secondaryText = NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1)
static let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1)
static let ember = NSColor(srgbRed: 0.76, green: 0.27, blue: 0.18, alpha: 1)
static let outline = secondaryText.withAlphaComponent(0.22)
static let actionFill = accent.withAlphaComponent(0.08)
static let track = secondaryText.withAlphaComponent(0.18)
static let bufferedTrack = secondaryText.withAlphaComponent(0.4)
static let disabledThumb = secondaryText.withAlphaComponent(0.25)
}
@MainActor
private final class PlayerBackdrop: NSView {
var ambientGlows = false
private static let amber = NSGradient(colors: [
SunsetPalette.accent.withAlphaComponent(0.18),
SunsetPalette.accent.withAlphaComponent(0.065),
SunsetPalette.accent.withAlphaComponent(0),
])!
private static let ember = NSGradient(colors: [
SunsetPalette.ember.withAlphaComponent(0.26),
SunsetPalette.ember.withAlphaComponent(0.10),
SunsetPalette.ember.withAlphaComponent(0),
])!
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func draw(_ dirtyRect: NSRect) {
SunsetPalette.background.setFill()
dirtyRect.fill()
guard ambientGlows else { return }
let upperGlow = NSPoint(x: bounds.width * 0.58, y: bounds.height * 0.94)
Self.ember.draw(fromCenter: upperGlow, radius: 0, toCenter: upperGlow,
radius: bounds.width * 0.40, options: [])
let lowerGlow = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10)
Self.amber.draw(fromCenter: lowerGlow, radius: 0, toCenter: lowerGlow,
radius: bounds.width * 0.55, options: [])
let leftGlow = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current?.cgContext.setAlpha(0.45)
Self.amber.draw(fromCenter: leftGlow, radius: 0, toCenter: leftGlow,
radius: bounds.width * 0.32, options: [])
NSGraphicsContext.restoreGraphicsState()
}
}
@MainActor
private struct PlayerGlass: NSViewRepresentable {
var blendingMode: NSVisualEffectView.BlendingMode = .withinWindow
var cornerRadius: CGFloat = 12
var ambientGlows = false
func makeCoordinator() -> PlayerBackdrop { PlayerBackdrop(frame: .zero) }
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
@@ -1028,11 +1081,26 @@ private struct PlayerGlass: NSViewRepresentable {
view.state = .active
view.appearance = NSAppearance(named: .vibrantDark)
view.material = .hudWindow
view.wantsLayer = true
view.layer?.cornerRadius = cornerRadius
view.layer?.masksToBounds = true
view.layer?.borderWidth = cornerRadius > 0 ? 0.5 : 0
view.layer?.borderColor = SunsetPalette.outline.cgColor
let backdrop = context.coordinator
backdrop.frame = view.bounds
backdrop.autoresizingMask = [.width, .height]
backdrop.ambientGlows = ambientGlows
backdrop.setAccessibilityElement(false)
view.addSubview(backdrop)
return view
}
func updateNSView(_ view: NSVisualEffectView, context: Context) {
view.blendingMode = blendingMode
if context.coordinator.ambientGlows != ambientGlows {
context.coordinator.ambientGlows = ambientGlows
context.coordinator.needsDisplay = true
}
}
}
@@ -1102,12 +1170,12 @@ private struct PlaybackSlider: NSViewRepresentable {
override func drawBar(inside rect: NSRect, flipped: Bool) {
let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5,
width: max(0, rect.width - knobThickness), height: 3)
NSColor.white.withAlphaComponent(0.12).setFill()
SunsetPalette.track.setFill()
NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill()
if volume {
let fraction = (doubleValue - minValue) / (maxValue - minValue)
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
NSColor.systemOrange.withAlphaComponent(0.85).setFill()
SunsetPalette.accent.setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
} else if let bufferedRange, maxValue > minValue {
let lower = min(maxValue, max(minValue, bufferedRange.lowerBound))
@@ -1115,7 +1183,7 @@ private struct PlaybackSlider: NSViewRepresentable {
let scale = bar.width / (maxValue - minValue)
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY,
width: (upper - lower) * scale, height: bar.height)
NSColor.white.withAlphaComponent(0.4).setFill()
SunsetPalette.bufferedTrack.setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
}
}
@@ -1123,8 +1191,8 @@ private struct PlaybackSlider: NSViewRepresentable {
let width: CGFloat = volume ? 16 : 3
let height: CGFloat = volume ? 12 : 15
let knob = NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2, width: width, height: height)
let color = volume ? NSColor(white: isEnabled ? 0.85 : 0.45, alpha: 1)
: NSColor.white.withAlphaComponent(isEnabled ? 0.65 : 0.25)
let color = isEnabled ? (volume ? SunsetPalette.text : SunsetPalette.secondaryText)
: SunsetPalette.disabledThumb
color.setFill()
NSBezierPath(roundedRect: knob, xRadius: volume ? 6 : 1, yRadius: volume ? 6 : 1).fill()
}
@@ -1219,7 +1287,7 @@ private struct PlayerContent: View {
return (model.isCompact ? "" : "Buffer: ") + String(format: "−%.0fs +%.0fs", behind, ahead)
} ?? (model.isCompact ? "Buffer —" : "Buffer: —"))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.white.opacity(0.75))
.foregroundStyle(Color(SunsetPalette.secondaryText))
.lineLimit(1)
.frame(height: 12)
.help(bufferedStatus ?? "Reusable buffer unavailable.")
@@ -1260,17 +1328,17 @@ private struct PlayerContent: View {
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false))
.background(PlayerGlass(cornerRadius: 8).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)
.foregroundStyle(.white.opacity(model.isOnTop ? 1 : 0.6))
.foregroundStyle(Color(model.isOnTop ? SunsetPalette.accent : SunsetPalette.secondaryText))
.disabled(model.isFullScreen || model.compactWindow.isTransitioning)
}
.padding(.leading, model.isFullScreen ? 16 : 86)
.padding(.trailing, 12)
.frame(height: 32)
.foregroundStyle(.white.opacity(0.6))
.foregroundStyle(Color(SunsetPalette.text))
.onHover(perform: model.setTitleHovered)
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
@@ -1319,8 +1387,8 @@ private struct PlayerContent: View {
}
.padding(.horizontal, 12)
.padding(.vertical, showsBufferReadout ? 6 : 8)
.foregroundStyle(.white.opacity(0.85))
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
.foregroundStyle(Color(SunsetPalette.text))
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)))
.onHover(perform: model.setControlsHovered)
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
@@ -1395,13 +1463,14 @@ private struct PlayerContent: View {
time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
}
.font(.system(size: 11).monospacedDigit())
.foregroundStyle(Color(SunsetPalette.secondaryText))
.padding(.horizontal, 16)
if showsBufferReadout { bufferReadout }
}
.padding(.vertical, 11)
// Optical centering accounts for symbol insets and the native seek thumb.
.offset(y: -1.5)
.foregroundStyle(.white.opacity(0.6))
.foregroundStyle(Color(SunsetPalette.text))
.onHover(perform: model.setControlsHovered)
}
@@ -1418,7 +1487,8 @@ private struct PlayerContent: View {
GeometryReader { geometry in
ZStack(alignment: .top) {
if model.fileName == nil {
PlayerGlass(blendingMode: .behindWindow)
PlayerGlass(blendingMode: .behindWindow, cornerRadius: 0,
ambientGlows: !model.isBusy && model.errorText == nil)
.allowsHitTesting(false)
.accessibilityHidden(true)
} else {
@@ -1445,13 +1515,13 @@ private struct PlayerContent: View {
}
.padding(24)
.frame(maxWidth: 420)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.isBusy {
VStack(spacing: 12) {
ProgressView().controlSize(.large)
Text(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…")
.font(.callout).foregroundStyle(.secondary)
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.fileName == nil {
@@ -1460,14 +1530,14 @@ private struct PlayerContent: View {
Image(systemName: "play.rectangle.fill")
.font(.system(size: 28))
.frame(width: 40, height: 40)
.foregroundStyle(.white.opacity(0.6))
.foregroundStyle(Color(SunsetPalette.accent))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("terebi").font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
.foregroundStyle(Color(SunsetPalette.text))
Text("Open media to get started")
.font(.callout)
.foregroundStyle(.white.opacity(0.6))
.foregroundStyle(Color(SunsetPalette.secondaryText))
}
Spacer(minLength: 0)
}
@@ -1486,14 +1556,14 @@ private struct PlayerContent: View {
.accessibilityLabel("Open YouTube URL…")
}
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.85))
.foregroundStyle(Color(SunsetPalette.text))
.imageScale(.large)
.buttonStyle(.plain)
.disabled(!model.canOpen)
.background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 8))
.background(Color(SunsetPalette.actionFill), in: RoundedRectangle(cornerRadius: 8))
Text("Drop a media file or YouTube link here")
.font(.system(size: 12))
.foregroundStyle(.white.opacity(0.6))
.foregroundStyle(Color(SunsetPalette.secondaryText))
.multilineTextAlignment(.center)
}
.padding(20)
@@ -1506,14 +1576,14 @@ private struct PlayerContent: View {
Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight))
Text(model.fileName ?? "").font(.headline).lineLimit(1)
}
.foregroundStyle(.secondary)
.foregroundStyle(Color(SunsetPalette.secondaryText))
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if model.state.buffering && !model.isBusy && model.errorText == nil {
ProgressView("Buffering…")
.padding(12)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)))
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
.allowsHitTesting(false)
}
@@ -1525,10 +1595,10 @@ private struct PlayerContent: View {
.truncationMode(.middle)
.help(model.fileName ?? "terebi")
.padding(8)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)).allowsHitTesting(false))
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)).allowsHitTesting(false))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.foregroundStyle(.white.opacity(0.75))
.foregroundStyle(Color(SunsetPalette.text))
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(false)
.accessibilityHidden(!model.controlsVisible)
@@ -1581,7 +1651,7 @@ private struct PlayerContent: View {
}
if dropTargeted {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.white.opacity(0.75), lineWidth: 2)
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)
.padding(8)
.allowsHitTesting(false)
}
@@ -1607,6 +1677,8 @@ private struct PlayerContent: View {
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
.foregroundStyle(Color(SunsetPalette.text))
.tint(Color(SunsetPalette.accent))
.background(WindowChrome(model: model))
}
}