From 2a75d55c983f5e25f24c91723fabff35834efd79 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Mon, 12 Oct 2026 14:14:57 +0200 Subject: [PATCH] ui: adopt Sunset palette and warm glass styling --- README.md | 8 +-- Sources/App/TerebiApp.swift | 124 ++++++++++++++++++++++++++++-------- 2 files changed, 102 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 2f082db..67375c1 100644 --- a/README.md +++ b/README.md @@ -184,11 +184,11 @@ Terebi reports facts, not watched status. A consumer such as Tubular decides com ## Playback UI -The layout follows [IINA's video-first window](https://iina.io/img/screenshot.png) and the supplied screenshots: a 32-point transparent titlebar with native traffic lights and file icon, plus a dark frosted 440 × 68-point floating controller. AppKit materials blur the video beneath the controller; muted icons, an orange volume track, a narrow seek thumb, and zero-padded time labels follow the reference. Shared vertical padding and an optical offset for symbol/slider insets balance the controller's visible top and bottom margins in both play and pause states. The controller starts centered with a four-percent bottom inset and keeps its relative position when the window changes size. +The layout follows [IINA's video-first window](https://iina.io/img/screenshot.png) and the supplied screenshots: a 32-point transparent titlebar with native traffic lights and file icon, plus a frosted 440 × 68-point floating controller. Conjure-inspired **Sunset** styling uses translucent dark-brown glass, warm cream primary text, sand secondary text, and a fixed orange accent for the volume fill, active pin, and drop target. Native slider thumbs and buffered ranges share the palette; warning symbols retain their semantic yellow. Shared vertical padding and an optical offset for symbol/slider insets balance the controller's visible top and bottom margins in both play and pause states. The controller starts centered with a four-percent bottom inset and keeps its relative position when the window changes size. -The empty player uses the same HUD-glass material, 12-point rounded corners, and muted icon/text palette in a centered welcome card. File and YouTube opening actions share a consistently sized row; the card fits the 400 × 240-point minimum window, and the full window still accepts media-file and YouTube-link drops. +The empty player uses the same tinted HUD-glass material, faint warm outlines, 12-point rounded corners, and Sunset palette in a centered welcome card. File and YouTube opening actions share a consistently sized row with a subtle orange fill; the card fits the 400 × 240-point minimum window, and the full window still accepts media-file and YouTube-link drops. -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. +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. 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/left 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. @@ -211,7 +211,7 @@ Initial window setup and returning from the file/YouTube dialogs leave keyboard Frame stepping uses decoded presentation timestamps, not an average-FPS time offset, including variable-frame-rate video and reordered B-frames. Steps start from the displayed frame, stop at the first/last frame, and keep the timeline paused. Consecutive same-direction repeats share one counted queue entry without skipping frames or reordering seeks/direction changes. The last presented image remains visible during decoder preroll and queued steps; the progress thumb follows each authoritative stepped position. Opening/closing media still clears the old image. Audio-only files disable stepping; normal playback resumes with audio synchronized to the selected frame. - 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. +- 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 enabled volume thumb is opaque cream; secondary sand marks the seek thumb and buffered range, with dimmed disabled thumbs. - **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. diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 0365e05..78e2763 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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)) } }