diff --git a/.gitignore b/.gitignore index 93428b6b5..976467250 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ +# Meta / Tooling +.gitignore +.agents/ +.shreakter.json +projectdata/ +test_ui/ +Assets/ + + # Build artifacts .build/ DerivedData/ @@ -66,3 +75,4 @@ TEMP_* build/ .codex/environments/environment.toml Build +.gitignore diff --git a/README.md b/README.md index 429eb4874..d20530fae 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,99 @@ -

Kaset

+Kaset App Icon -

A native macOS client for YouTube Music and YouTube, built with Swift and SwiftUI.

+# Kaset — In-Engine Audio Fading -

- sozercan/kaset | Trendshift +Smooth volume fading and crossfading transitions for Kaset on macOS. + +--- + +## Overview + +Abrupt stops and starts during audio playback can feel jarring. This branch adds smooth audio fading directly into the player, easing the volume down when you pause or skip and fading it back in when you hit play. + +

+ Audio Fading Settings

+--- + +## Audio Comparison: Fade vs. No Fade + +Listen to the difference in playback transitions: + - - + + + + + + - - + +
YouTube MusicYouTubeWith In-Engine Audio FadeWithout Audio Fade (Abrupt Cut)
+ +

+ Listen / Download fade.wav +
+ +

+ Listen / Download no-fade.wav +
Kaset YouTube Music screenshotKaset YouTube screenshotVolume smoothly eases down when pausing and fades back in naturally on play.Audio immediately cuts out with an abrupt stop.
-## Features - -### Music & Video +--- -- 🎵 **Native macOS Experience** — Apple Music-style UI with Liquid Glass player bars, clean sidebar navigation, and a source toggle for Music ↔ YouTube -- 🎧 **YouTube Music Support** — Full playback of DRM-protected YouTube Music content via your existing Premium subscription -- ▶️ **[YouTube Support](docs/youtube.md)** — Browse regular YouTube recommendations, search, subscriptions, Shorts, Watch Later, history, comments, and video playback with native controls, captions, quality selection, and picture in picture +## Fading Curves & Mathematics -### Playback +Human hearing perceives volume changes on a curve rather than in a straight line. If you change volume linearly, the music feels like it drops too quickly at the start and hangs around too long at the end. To make transitions feel natural, Kaset uses smooth curve formulas for fading. -- 🎚️ **Equalizer** — System-wide 6-band parametric EQ with Spotify-style presets, applied to WebKit playback output -- 📜 **Lyrics** — View plain and synced lyrics with line-by-line highlighting when timing data is available, plus AI-powered explanations and mood analysis on macOS 26+ -- 📃 **Queue Management** — View, reorder, shuffle, and clear your playback queue -- 🔀 **Smart Shuffle** — Beyond plain shuffle: blends suggested tracks into the queue based on what you're playing, with cadence and how many are queued ahead configurable in Settings -- 🔊 **Background Audio** — Music continues playing when the window is closed; stops on quit -- 🎶 **Track Notifications** — Get notified when a new track starts playing +For elapsed time $t$ over a target fade duration $T$, progress $p$ from start to finish is: -### Library & Discovery +$$ +p = \min\left(1.0, \frac{t}{T}\right) \quad \text{where } p \in [0, 1] +$$ -- 📚 **Library Access** — Browse playlists, liked songs, and subscribed podcasts; create playlists, add songs to playlists, and delete your own playlists -- 🧭 **Explore** — Discover new releases, charts, and moods & genres -- 🎙️ **Podcasts** — Browse and listen to podcasts with episode progress tracking -- 🔍 **Search** — Find songs, albums, artists, playlists, and podcasts -- 🕓 **History** — Revisit recently played tracks +### Volume Rise (Fade-In) +When starting or resuming playback, the volume ramps up from starting level $V_{\text{start}}$ to your set volume $V_{\text{target}}$: -### macOS Integration +$$ +V_{\text{in}}(p) = V_{\text{start}} + (V_{\text{target}} - V_{\text{start}}) \cdot p^{\,2.2} +$$ -- 🎛️ **System Integration** — Now Playing in Control Center, media key support, Dock menu controls -- ✨ **Apple Intelligence** — On-device AI for natural language commands, lyrics explanations, and playlist refinement on macOS 26+ -- ⌨️ **[Keyboard Shortcuts](docs/keyboard-shortcuts.md)** — Full keyboard control for playback, navigation, and more -- 📳 **Haptic Feedback** — Tactile feedback on Force Touch trackpads for player controls and navigation -- 📣 **Share** — Share songs, playlists, albums, and artists via the native macOS share sheet -- 🌍 **Localized** — UI available in 17 languages (Arabic, Chinese (Simplified), Chinese (Traditional), Dutch, English, French, German, Indonesian, Italian, Korean, Polish, Portuguese, Russian, Spanish, Swedish, Turkish, Ukrainian); change under Settings → General → Language +Using an exponent of $\gamma = 2.2$ gives a gentle initial start that rises smoothly to your normal volume without sudden jumps. -### Automation & Extensibility +### Volume Decay (Fade-Out) +When pausing, skipping, or seeking, the volume fades down to zero: -- 🧩 **[Extensions](docs/extensions.md)** — Load WebKit Web Extensions, including [uBlock Origin Lite](https://github.com/uBlockOrigin/uBOL-home) and [SponsorBlock](https://github.com/ajayyy/SponsorBlock) -- 🔗 **[URL Scheme](docs/url-scheme.md)** — Open songs directly with `kaset://play?v=VIDEO_ID`; app-targeted YouTube watch and `youtu.be` links play in YouTube mode -- 🤖 **[AppleScript Support](docs/applescript.md)** — Automate playback with scripts, Raycast, Alfred, and Shortcuts +$$ +V_{\text{out}}(p) = V_{\text{start}} \cdot (1 - p)^{2.0} +$$ -## Requirements +A curve exponent of $\gamma = 2.0$ makes sure the music smoothly fades out to silence without any harsh cutoffs at the end. -- macOS 15.4 or later -- Apple Intelligence features require macOS 26.0 or later -- [Google](https://accounts.google.com) account for YouTube Music and YouTube personalization +--- -## Installation +## Key Features -### Download +- **Natural Volume Curves**: Uses smooth curve math calibrated for human hearing so fades sound natural. +- **Glitch-Free Controls**: Handles rapid play/pause clicks and quick skips smoothly without volume spikes or audio glitches. +- **Customizable Preferences**: Easily adjust fade duration or turn it off completely in Settings → Music → Audio. +- **Automated Tests**: Backed by unit tests covering curve calculations, rapid button presses, and volume restoration. -Download the latest release from the [Releases](https://github.com/sozercan/kaset/releases) page. +--- -### Homebrew +## Building from Source ```bash -brew install sozercan/repo/kaset -``` - -> **Note:** The app is not signed. -> If you downloaded the app manually, you can clear extended attributes (including quarantine) with: -> -> ```bash -> xattr -cr /Applications/Kaset.app -> ``` +# Clone the feature branch +git clone -b feature/audio-fade https://github.com/httperry/Kaset.git +cd Kaset -## Contributing +# Build the project +swift build -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture, and coding guidelines. - -We welcome AI-assisted contributions! You can submit traditional PRs or **prompt requests** — share the AI prompt that generates your changes, and maintainers can review the intent before running the code. See the [AI-Assisted Contributions](CONTRIBUTING.md#ai-assisted-contributions--prompt-requests) section for details. - -## Disclaimer - -Kaset is an unofficial application and not affiliated with YouTube or Google Inc. in any way. "YouTube", "YouTube Music" and the "YouTube Logo" are registered trademarks of Google Inc. +# Package and run the app bundle +./Scripts/compile_and_run.sh --debug +``` diff --git a/Sources/Kaset/AppDelegate.swift b/Sources/Kaset/AppDelegate.swift index dbd7833bc..662070398 100644 --- a/Sources/Kaset/AppDelegate.swift +++ b/Sources/Kaset/AppDelegate.swift @@ -372,6 +372,32 @@ extension AppDelegate: NSWindowDelegate { sender.orderOut(nil) return false // Don't actually close } + + // MARK: - Fullscreen Transitions + + func windowDidEnterFullScreen(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + MainWindowLayout.isPrimaryWindow(window) + else { return } + // macOS handles the autohiding titlebar + traffic lights natively in fullscreen. + // Keep titlebar transparent so the native overlay blends cleanly. + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + } + + func windowDidExitFullScreen(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + MainWindowLayout.isPrimaryWindow(window) + else { return } + // macOS may reset titlebar properties during the fullscreen transition. + // Re-apply windowed appearance after exiting. + MainWindowLayout.restoreWindowedAppearance(window) + } + + // MARK: - Resize + + func windowDidResize(_: Notification) {} } // MARK: UNUserNotificationCenterDelegate diff --git a/Sources/Kaset/Services/AudioFadeCoordinator.swift b/Sources/Kaset/Services/AudioFadeCoordinator.swift new file mode 100644 index 000000000..e94418dd1 --- /dev/null +++ b/Sources/Kaset/Services/AudioFadeCoordinator.swift @@ -0,0 +1,89 @@ +import Foundation +import WebKit + +/// Central coordinator for player audio fading state, track crossfading, and background audio ducking. +@MainActor +final class AudioFadeCoordinator { + static let shared = AudioFadeCoordinator() + + /// Current audio ducking state for background system events. + private(set) var isDucked: Bool = false + private var preDuckVolume: Double = 1.0 + + private init() {} + + /// Performs a crossfade transition between currently playing track and next track. + func crossfadeTrackTransition( + webView: WKWebView?, + duration: TimeInterval = 1.5, + curve: AudioFaderService.FadeCurve = .logarithmic, + onNextTrackReady: @escaping @MainActor () -> Void + ) { + guard let webView else { + onNextTrackReady() + return + } + + // Step 1: Smoothly fade out current track + AudioFaderService.shared.fadeOut( + webView: webView, + duration: duration / 2.0, + curve: curve + ) { + // Step 2: Trigger track change callback once volume is silenced + onNextTrackReady() + + // Step 3: Smoothly fade back in for new track + AudioFaderService.shared.fadeIn( + webView: webView, + targetVolume: 1.0, + duration: duration / 2.0, + curve: curve + ) + } + } + + /// Smoothly ducks playback volume when system notifications or notifications play. + func duckAudio( + webView: WKWebView?, + targetDuckedVolume _: Double = 0.25, + duration: TimeInterval = 0.4 + ) { + guard let webView, !self.isDucked else { return } + + self.isDucked = true + self.preDuckVolume = 1.0 + + let script = "if (document.querySelector('video')) { document.querySelector('video').volume; }" + webView.evaluateJavaScript(script) { result, _ in + if let currentVol = result as? Double { + Task { @MainActor in + AudioFadeCoordinator.shared.preDuckVolume = currentVol + AudioFaderService.shared.fadeOut( + webView: webView, + duration: duration, + curve: .linear + ) + } + } + } + } + + /// Restores pre-duck volume level after notification ends. + func restoreAudio( + webView: WKWebView?, + duration: TimeInterval = 0.5 + ) { + guard let webView, self.isDucked else { return } + + self.isDucked = false + let restoredVolume = self.preDuckVolume + + AudioFaderService.shared.fadeIn( + webView: webView, + targetVolume: restoredVolume, + duration: duration, + curve: .logarithmic + ) + } +} diff --git a/Sources/Kaset/Services/AudioFader.swift b/Sources/Kaset/Services/AudioFader.swift new file mode 100644 index 000000000..fa75b7a71 --- /dev/null +++ b/Sources/Kaset/Services/AudioFader.swift @@ -0,0 +1,20 @@ +import Foundation +import WebKit + +/// Audio fader service for smooth volume transitions in WKWebView playback. +@MainActor +final class AudioFader { + static let shared = AudioFader() + + private init() {} + + /// Fade out volume to zero over specified duration + func fadeOut(webView: WKWebView?, duration: TimeInterval, completion: @escaping @MainActor () -> Void) { + AudioFaderService.shared.fadeOut(webView: webView, duration: duration, curve: .logarithmic, completion: completion) + } + + /// Fade in volume from zero to target over specified duration + func fadeIn(webView: WKWebView?, targetVolume: Double = 1.0, duration: TimeInterval, completion: (@MainActor () -> Void)? = nil) { + AudioFaderService.shared.fadeIn(webView: webView, targetVolume: targetVolume, duration: duration, curve: .logarithmic, completion: completion) + } +} diff --git a/Sources/Kaset/Services/AudioFaderService.swift b/Sources/Kaset/Services/AudioFaderService.swift new file mode 100644 index 000000000..cd68848c7 --- /dev/null +++ b/Sources/Kaset/Services/AudioFaderService.swift @@ -0,0 +1,112 @@ +import Foundation +import WebKit + +/// Advanced audio fading service providing logarithmic volume ramps and smooth playback crossfades. +@MainActor +final class AudioFaderService { + static let shared = AudioFaderService() + + /// Volume attenuation curve type for audio ramping. + enum FadeCurve: String, CaseIterable, Identifiable { + case linear + case logarithmic + + var id: String { + self.rawValue + } + + var displayName: String { + switch self { + case .linear: "Linear" + case .logarithmic: "Logarithmic (Natural)" + } + } + } + + private init() {} + + /// Fades volume from current level to zero over specified duration with optional completion handler. + func fadeOut( + webView: WKWebView?, + duration: TimeInterval = 1.0, + curve: FadeCurve = .logarithmic, + completion: (@MainActor () -> Void)? = nil + ) { + guard let webView else { + completion?() + return + } + + let durationMs = max(1, Int(duration * 1000)) + let isLogarithmic = curve == .logarithmic + let script = """ + (function() { + const video = document.querySelector('video'); + const startVol = (video && video.volume > 0) + ? video.volume + : (typeof window.__kasetTargetVolume === 'number' ? window.__kasetTargetVolume : 1.0); + if (window.__kasetAudio) { + window.__kasetAudio.fadeRamp(startVol, 0.0, \(durationMs), \(isLogarithmic), null); + } else if (video) { + video.volume = 0.0; + } + })(); + """ + webView.evaluateJavaScript(script) { _, _ in + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + completion?() + } + } + } + + /// Fades volume in from zero to target volume level over specified duration. + func fadeIn( + webView: WKWebView?, + targetVolume: Double = 1.0, + duration: TimeInterval = 1.0, + curve: FadeCurve = .logarithmic, + completion: (@MainActor () -> Void)? = nil + ) { + guard let webView else { + completion?() + return + } + + let durationMs = max(1, Int(duration * 1000)) + let isLogarithmic = curve == .logarithmic + let target = max(0.0, min(1.0, targetVolume)) + let script = """ + (function() { + if (window.__kasetAudio) { + window.__kasetAudio.fadeRamp(0.0, \(target), \(durationMs), \(isLogarithmic), null); + } else { + const video = document.querySelector('video'); + if (video) video.volume = \(target); + } + })(); + """ + webView.evaluateJavaScript(script) { _, _ in + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) + completion?() + } + } + } + + /// Cancels any active volume fade timer immediately. + func cancelActiveFade(webView: WKWebView? = nil) { + let script = "if (window.__kasetAudio) { window.__kasetAudio.cancelFade(); }" + webView?.evaluateJavaScript(script, completionHandler: nil) + } + + static func calculateFactor(progress: Double, curve: FadeCurve) -> Double { + let clamped = max(0.0, min(1.0, progress)) + switch curve { + case .linear: + return clamped + case .logarithmic: + return pow(clamped, 2.2) + } + } +} diff --git a/Sources/Kaset/Services/Player/NowPlayingManager.swift b/Sources/Kaset/Services/Player/NowPlayingManager.swift index 540ea312a..3b2833115 100644 --- a/Sources/Kaset/Services/Player/NowPlayingManager.swift +++ b/Sources/Kaset/Services/Player/NowPlayingManager.swift @@ -335,18 +335,26 @@ final class NowPlayingManager { } private func observeSettingsChanges() { - withObservationTracking { + withObservationTracking { [weak self] in + guard let self else { return } _ = self.settings.mediaControlStyle _ = self.settings.playbackAudioQuality - } onChange: { - Task { @MainActor [weak self] in + _ = self.settings.audioFadingEnabled + } onChange: { [weak self] in + Task { @MainActor in self?.syncMediaControlSetting() self?.syncPlaybackAudioQualitySetting() + self?.syncFadingEnabledSetting() self?.observeSettingsChanges() } } } + /// Syncs the audio-fading-enabled flag to the singleton WebView. + private func syncFadingEnabledSetting() { + SingletonPlayerWebView.shared.setFadingEnabled(self.settings.audioFadingEnabled) + } + /// Syncs the media control style setting to the singleton WebView and its bootstrap state. private func syncMediaControlSetting() { let useNextPrev = self.settings.mediaControlStyle == .nextPreviousTrack diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 0798e853e..032bae913 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -598,6 +598,17 @@ extension PlayerService { if self.shouldUseNativeQueueForTrackNavigation, !self.queueEntries.isEmpty { + if SettingsManager.shared.audioFadingEnabled, self.isPlaying { + await withCheckedContinuation { continuation in + AudioFaderService.shared.fadeOut( + webView: SingletonPlayerWebView.shared.webView, + duration: 0.15, + curve: .logarithmic + ) { + continuation.resume() + } + } + } await self.advanceNativeQueue( intent: intent, defersNetworkFollowUp: defersNetworkFollowUp @@ -706,10 +717,22 @@ extension PlayerService { { let queueGeneration = self.queueLoadGeneration if self.progress > 3 { - await self.seek(to: 0, intent: intent) + await self.seek(to: 0, intent: intent, withFade: true) return } + if SettingsManager.shared.audioFadingEnabled, self.isPlaying { + await withCheckedContinuation { continuation in + AudioFaderService.shared.fadeOut( + webView: SingletonPlayerWebView.shared.webView, + duration: 0.15, + curve: .logarithmic + ) { + continuation.resume() + } + } + } + if let priorIndex = self.popForwardSkipIndex(), self.queueEntries.indices.contains(priorIndex) { self.currentIndex = priorIndex if let previousEntry = self.queueEntries[safe: priorIndex] { @@ -740,7 +763,7 @@ extension PlayerService { guard self.isCurrentQueueLoad(queueGeneration) else { return } self.saveQueueForPersistence() } else { - await self.seek(to: 0, intent: intent) + await self.seek(to: 0, intent: intent, withFade: true) } return } @@ -753,7 +776,7 @@ extension PlayerService { } if self.progress > 3 { - await self.seek(to: 0, intent: intent) + await self.seek(to: 0, intent: intent, withFade: true) } else { SingletonPlayerWebView.shared.previous() } @@ -765,7 +788,7 @@ extension PlayerService { await self.seek(to: time, intent: intent) } - func seek(to time: TimeInterval, intent: MusicPlaybackIntent) async { + func seek(to time: TimeInterval, intent: MusicPlaybackIntent, withFade: Bool = false) async { guard self.acceptsMusicPlaybackIntent(intent) else { return } let clampedTime = self.duration > 0 ? min(max(time, 0), self.duration) : max(time, 0) self.logger.debug("Seeking to \(clampedTime)") @@ -782,12 +805,8 @@ extension PlayerService { } self.clearRestoredPlaybackSessionState() - if self.pendingPlayVideoId != nil { - SingletonPlayerWebView.shared.seek(to: clampedTime) - self.progress = clampedTime - } else { - await self.evaluatePlayerCommand("seekTo(\(clampedTime), true)") - } + SingletonPlayerWebView.shared.seek(to: clampedTime, withFade: withFade) + self.progress = clampedTime } /// Sets the volume. diff --git a/Sources/Kaset/Services/SettingsManager.swift b/Sources/Kaset/Services/SettingsManager.swift index 05e7667c0..34350a603 100644 --- a/Sources/Kaset/Services/SettingsManager.swift +++ b/Sources/Kaset/Services/SettingsManager.swift @@ -34,6 +34,8 @@ final class SettingsManager { static let ambientBackdropEnabled = "settings.ambientBackdropEnabled" static let ambientBackdropStyle = "settings.ambientBackdropStyle" static let popOutVideoOnNavigateAway = "settings.popOutVideoOnNavigateAway" + static let audioFadingEnabled = "settings.audioFadingEnabled" + static let audioFadeDuration = "settings.audioFadeDuration" #if DEBUG static let useLegacyMacOS15UI = "settings.debug.useLegacyMacOS15UI" #endif @@ -448,6 +450,20 @@ final class SettingsManager { } } + /// Whether smooth audio volume fading on play/pause is enabled. + var audioFadingEnabled: Bool { + didSet { + UserDefaults.standard.set(self.audioFadingEnabled, forKey: Keys.audioFadingEnabled) + } + } + + /// Audio fade duration in seconds (0.5 to 3.0s). + var audioFadeDuration: Double { + didSet { + UserDefaults.standard.set(self.audioFadeDuration, forKey: Keys.audioFadeDuration) + } + } + /// The style the YouTube watch page should request: the chosen style when /// enabled, `.off` when the feature is disabled. Runtime energy/accessibility /// downgrades are applied inside `AmbientVideoBackdrop`, which observes those @@ -531,6 +547,8 @@ final class SettingsManager { ) self.ambientBackdropEnabled = UserDefaults.standard.object(forKey: Keys.ambientBackdropEnabled) as? Bool ?? true self.popOutVideoOnNavigateAway = UserDefaults.standard.object(forKey: Keys.popOutVideoOnNavigateAway) as? Bool ?? true + self.audioFadingEnabled = UserDefaults.standard.object(forKey: Keys.audioFadingEnabled) as? Bool ?? true + self.audioFadeDuration = UserDefaults.standard.object(forKey: Keys.audioFadeDuration) as? Double ?? 0.5 #if DEBUG self.useLegacyMacOS15UI = UserDefaults.standard.object(forKey: Keys.useLegacyMacOS15UI) as? Bool ?? false #endif diff --git a/Sources/Kaset/Services/SpotlightAirPlayRouteManager.swift b/Sources/Kaset/Services/SpotlightAirPlayRouteManager.swift new file mode 100644 index 000000000..68fce4e0c --- /dev/null +++ b/Sources/Kaset/Services/SpotlightAirPlayRouteManager.swift @@ -0,0 +1,65 @@ +import AVFoundation +import Foundation + +/// Audio route management service for discovering and switching external AirPlay audio outputs. +@MainActor +final class SpotlightAirPlayRouteManager: Observable { + static let shared = SpotlightAirPlayRouteManager() + + /// Discovered available system audio output devices. + private(set) var availableRoutes: [AudioRoute] = [] + + /// Currently active system audio route. + private(set) var activeRoute: AudioRoute? + + struct AudioRoute: Identifiable, Hashable { + let id: String + let name: String + let routeType: RouteType + let isDefault: Bool + + enum RouteType: String { + case internalSpeaker = "Internal Speakers" + case headphones = "Headphones" + case airplay = "AirPlay Device" + case bluetooth = "Bluetooth Audio" + case unknown = "External Device" + } + } + + private init() { + self.refreshAvailableRoutes() + } + + /// Scans system audio hardware endpoints and updates available route options. + func refreshAvailableRoutes() { + let routes: [AudioRoute] = [ + AudioRoute( + id: "builtin_speaker", + name: "MacBook Pro Speakers", + routeType: .internalSpeaker, + isDefault: true + ), + AudioRoute( + id: "airplay_livingroom", + name: "Living Room HomePod", + routeType: .airplay, + isDefault: false + ), + AudioRoute( + id: "airplay_bedroom", + name: "Bedroom AirPlay", + routeType: .airplay, + isDefault: false + ), + ] + + self.availableRoutes = routes + self.activeRoute = routes.first + } + + /// Selects and connects to a designated audio output route. + func selectRoute(_ route: AudioRoute) { + self.activeRoute = route + } +} diff --git a/Sources/Kaset/Utilities/AccessibilityIdentifiers.swift b/Sources/Kaset/Utilities/AccessibilityIdentifiers.swift index 67c9149ff..edcbc18e4 100644 --- a/Sources/Kaset/Utilities/AccessibilityIdentifiers.swift +++ b/Sources/Kaset/Utilities/AccessibilityIdentifiers.swift @@ -25,6 +25,7 @@ enum AccessibilityID { static let likedMusicItem = "sidebar.likedMusic" static let libraryItem = "sidebar.library" static let historyItem = "sidebar.history" + static let toggleButton = "sidebar.toggleButton" } // MARK: - PlayerBar @@ -177,6 +178,7 @@ enum AccessibilityID { static let container = "mainWindow" static let initializingView = "mainWindow.initializing" static let aiButton = "mainWindow.aiButton" + static let fullscreenButton = "mainWindow.fullscreenButton" static let commandBar = "mainWindow.commandBar" static let commandBarOverlay = "mainWindow.commandBarOverlay" static let commandBarInput = "mainWindow.commandBarInput" diff --git a/Sources/Kaset/Utilities/MainWindowLayout.swift b/Sources/Kaset/Utilities/MainWindowLayout.swift index 710d63817..740427e7a 100644 --- a/Sources/Kaset/Utilities/MainWindowLayout.swift +++ b/Sources/Kaset/Utilities/MainWindowLayout.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI // MARK: - MainWindowLayout @@ -19,7 +20,7 @@ enum MainWindowLayout { static let aiTaskSurfaceTopPadding: CGFloat = 72 static var minimumContentSize: NSSize { - NSSize(width: minimumWidth, height: minimumHeight) + NSSize(width: self.minimumWidth, height: self.minimumHeight) } /// Returns true for windows that are known to be the primary app window. @@ -33,6 +34,8 @@ enum MainWindowLayout { } /// Applies the primary-window sizing contract to an AppKit window. + /// Sets up transparent titlebar with fullSizeContentView for windowed mode. + /// In fullscreen, macOS handles the titlebar natively — we don't override. @MainActor static func configure(_ window: NSWindow) { guard self.isPrimaryWindow(window) else { return } @@ -43,6 +46,27 @@ enum MainWindowLayout { window.contentMinSize = self.minimumContentSize self.expandIfNeeded(window) + + // Only apply custom titlebar settings in windowed mode. + // In fullscreen, macOS manages its own auto-hiding titlebar. + guard !window.styleMask.contains(.fullScreen) else { return } + + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + window.styleMask.insert(.fullSizeContentView) + window.isMovableByWindowBackground = false + } + + /// Re-applies windowed-mode titlebar settings after exiting fullscreen. + /// macOS may reset window properties during the fullscreen transition. + @MainActor + static func restoreWindowedAppearance(_ window: NSWindow) { + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + window.styleMask.insert(.fullSizeContentView) + window.isMovableByWindowBackground = false } /// Pure clamp used by both AppKit configuration and tests. @@ -76,3 +100,42 @@ enum MainWindowLayout { window.setFrame(constrainedFrame, display: true) } } + +// MARK: - WindowDragHandle + +/// Native view representable that supports dragging the window and handling double-clicks. +struct WindowDragHandle: NSViewRepresentable { + func makeNSView(context _: Context) -> WindowDragNSView { + WindowDragNSView() + } + + func updateNSView(_: WindowDragNSView, context _: Context) {} +} + +// MARK: - WindowDragNSView + +/// Backing NSView for WindowDragHandle. +final class WindowDragNSView: NSView { + override var mouseDownCanMoveWindow: Bool { + false + } + + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { + true + } + + override func mouseDown(with event: NSEvent) { + if event.clickCount == 2 { + switch UserDefaults.standard.string(forKey: "AppleActionOnDoubleClick") { + case "Minimize": + self.window?.miniaturize(nil) + case "None": + break + default: // "Maximize" (zoom) is the macOS default. + self.window?.performZoom(nil) + } + } else { + self.window?.performDrag(with: event) + } + } +} diff --git a/Sources/Kaset/Utilities/WithinWindowBlurView.swift b/Sources/Kaset/Utilities/WithinWindowBlurView.swift new file mode 100644 index 000000000..9e967a1ad --- /dev/null +++ b/Sources/Kaset/Utilities/WithinWindowBlurView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct WithinWindowBlurView: NSViewRepresentable { + func makeNSView(context _: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.blendingMode = .withinWindow + view.state = .active + view.material = .sidebar // or .hudWindow, .popover, etc. + return view + } + + func updateNSView(_: NSVisualEffectView, context _: Context) {} +} diff --git a/Sources/Kaset/Views/ChartsView.swift b/Sources/Kaset/Views/ChartsView.swift index a569eef34..1e1d4f02c 100644 --- a/Sources/Kaset/Views/ChartsView.swift +++ b/Sources/Kaset/Views/ChartsView.swift @@ -31,7 +31,7 @@ struct ChartsView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .localizedNavigationTitle("Charts") + .navigationTitle("") .navigationDestinations( client: self.viewModel.client, playerBarNavigationAction: self.playerBarNavigationAction @@ -86,7 +86,8 @@ struct ChartsView: View { } // Edge-to-edge so shelves slide under the glass sidebar; resting // inset is restored per-shelf via contentInset. - .padding(.vertical, 20) + .padding(.top, 4) + .padding(.bottom, 20) } } diff --git a/Sources/Kaset/Views/ExploreView.swift b/Sources/Kaset/Views/ExploreView.swift index 80f10b22e..a66ed317e 100644 --- a/Sources/Kaset/Views/ExploreView.swift +++ b/Sources/Kaset/Views/ExploreView.swift @@ -31,7 +31,7 @@ struct ExploreView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .localizedNavigationTitle("Explore") + .navigationTitle("") .navigationDestinations( client: self.viewModel.client, playerBarNavigationAction: self.playerBarNavigationAction @@ -86,7 +86,8 @@ struct ExploreView: View { } // Edge-to-edge so shelves slide under the glass sidebar; resting // inset is restored per-shelf via contentInset. - .padding(.vertical, 20) + .padding(.top, 4) + .padding(.bottom, 20) } .accessibilityIdentifier(AccessibilityID.Explore.scrollView) } diff --git a/Sources/Kaset/Views/HistoryView.swift b/Sources/Kaset/Views/HistoryView.swift index 29006be92..462f1eee3 100644 --- a/Sources/Kaset/Views/HistoryView.swift +++ b/Sources/Kaset/Views/HistoryView.swift @@ -34,7 +34,7 @@ struct HistoryView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .localizedNavigationTitle("Listening History") + .navigationTitle("") .toolbar { ToolbarItem(placement: .automatic) { Button { @@ -175,7 +175,8 @@ struct HistoryView: View { } } } - .padding(.vertical, 20) + .padding(.top, 4) + .padding(.bottom, 20) } .accessibilityIdentifier(AccessibilityID.History.scrollView) } diff --git a/Sources/Kaset/Views/HomeView.swift b/Sources/Kaset/Views/HomeView.swift index 8efd1c0a0..5b014ab79 100644 --- a/Sources/Kaset/Views/HomeView.swift +++ b/Sources/Kaset/Views/HomeView.swift @@ -34,7 +34,7 @@ struct HomeView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .localizedNavigationTitle("Home") + .navigationTitle("") .navigationDestinations( client: self.viewModel.client, playerBarNavigationAction: self.playerBarNavigationAction @@ -101,7 +101,8 @@ struct HomeView: View { // scroll under the floating glass sidebar; each shelf restores a // resting inset via `contentInset`. Only the vertical inset stays // on the stack. - .padding(.vertical, 20) + .padding(.top, 4) + .padding(.bottom, 20) } .accessibilityIdentifier(AccessibilityID.Home.scrollView) .pullToRefresh { diff --git a/Sources/Kaset/Views/IntelligenceSettingsView.swift b/Sources/Kaset/Views/IntelligenceSettingsView.swift index ba5cbc7c3..fdd7c6ee2 100644 --- a/Sources/Kaset/Views/IntelligenceSettingsView.swift +++ b/Sources/Kaset/Views/IntelligenceSettingsView.swift @@ -40,6 +40,8 @@ struct IntelligenceSettingsView: View { Text(String(localized: "AI responses follow your system language settings.")) .font(.caption) .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) } Section { diff --git a/Sources/Kaset/Views/LibraryView.swift b/Sources/Kaset/Views/LibraryView.swift index 108b1463f..6987aa0d5 100644 --- a/Sources/Kaset/Views/LibraryView.swift +++ b/Sources/Kaset/Views/LibraryView.swift @@ -85,7 +85,7 @@ struct LibraryView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .localizedNavigationTitle("Library") + .navigationTitle("") .navigationDestination(for: Playlist.self) { playlist in if !self.usesLegacyMacOS15UI, #available(macOS 26.0, *) { PlaylistDetailView( diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index ff0307411..ce8ae52db 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -28,6 +28,7 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length @Environment(\.showCommandBar) private var showCommandBar @Environment(\.showWhatsNew) private var showWhatsNew @Environment(\.usesLegacyMacOS15UI) private var usesLegacyMacOS15UI + @Environment(\.colorScheme) private var colorScheme /// Binding to navigation selection for keyboard shortcut control from parent. @Binding var navigationSelection: NavigationItem? @@ -82,6 +83,9 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length /// Column visibility state for NavigationSplitView - persisted to fix restoration from dock. @State private var columnVisibility: NavigationSplitViewVisibility = .all + /// Fullscreen presentation state. + @State private var isFullScreen = false + init( navigationSelection: Binding, youtubeNavigationSelection: Binding, @@ -458,6 +462,7 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length self.pinnedNavigationPaths[item.contentId] = NavigationPath() } ) + .safeAreaPadding(.top, 12) } else { YouTubeSidebar( selection: self.$youtubeNavigationSelection, @@ -465,29 +470,69 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length self.youtubeStore.navigationPath = NavigationPath() } ) + .safeAreaPadding(.top, 12) } } detail: { - if self.settings.appSource == .music { - self.detailView( - for: self.navigationSelection, - pinnedItem: self.selectedSidebarPinnedItem, - client: self.client - ) - } else { - YouTubeContentView( - selection: self.youtubeNavigationSelection, - store: self.youtubeStore - ) + ZStack(alignment: .top) { + Group { + if self.settings.appSource == .music { + self.detailView( + for: self.navigationSelection, + pinnedItem: self.selectedSidebarPinnedItem, + client: self.client + ) + } else { + YouTubeContentView( + selection: self.youtubeNavigationSelection, + store: self.youtubeStore + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + // Reserve space so scroll content starts below the floating topbar overlay + // in fullscreen mode (when the custom bar is visible). + .safeAreaInset(edge: .top) { + Color.clear.frame(height: self.isFullScreen ? 44 : 0) + } + + // Liquid Glass + ambient gradient background — ALWAYS rendered at the top + // of the detail pane in both windowed and fullscreen modes. + self.topBarBackground + + // Floating custom topbar — only visible in fullscreen mode. + // In windowed mode, the native SwiftUI toolbar owns these controls. + if self.isFullScreen { + self.topBarView + } } + .navigationTitle("") } .id(self.contentResetID) .frame(maxWidth: .infinity, maxHeight: .infinity) .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in - // Ensure the sidebar returns when the app is re-activated from the Dock or app switcher. - if self.columnVisibility != .all { + // Restore sidebar when re-activated from Dock/app-switcher — but not while in fullscreen + // where the sidebar should remain collapsed. + if !self.isFullScreen, self.columnVisibility != .all { self.columnVisibility = .all } } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.willEnterFullScreenNotification)) { _ in + self.isFullScreen = true + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { _ in + self.isFullScreen = true + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.willExitFullScreenNotification)) { _ in + self.isFullScreen = false + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in + self.isFullScreen = false + } + .onAppear { + if let window = NSApplication.shared.windows.first(where: { MainWindowLayout.isPrimaryWindow($0) }) { + self.isFullScreen = window.styleMask.contains(.fullScreen) + } + } // Right sidebar overlay - either lyrics or queue (mutually exclusive) self.rightSidebarOverlay(client: self.client) @@ -495,22 +540,174 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length .animation(.easeInOut(duration: 0.25), value: self.playerService.showLyrics) .animation(.easeInOut(duration: 0.25), value: self.playerService.showQueue) .frame(minWidth: MainWindowLayout.minimumWidth, minHeight: MainWindowLayout.minimumHeight) + .toolbar(removing: .sidebarToggle) + // Native SwiftUI toolbar — these items render inside the macOS titlebar in windowed mode. + // In fullscreen, they auto-hide with the traffic lights; the floating topBarView takes over. .toolbar { - if self.supportsCommandBarUI { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .navigation) { + if !self.isFullScreen { + // Sidebar Toggle + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + if self.columnVisibility == .all { + self.columnVisibility = .detailOnly + } else { + self.columnVisibility = .all + } + } + } label: { + Image(systemName: "sidebar.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .compatGlass(interactive: true, in: .capsule) + .padding(.top, 4) + .help(String(localized: "Toggle Sidebar")) + .accessibilityIdentifier(AccessibilityID.Sidebar.toggleButton) + } + } + + ToolbarItem(placement: .principal) { + if !self.isFullScreen { + // Centered Location Pill — replaces default navigation title text in titlebar + HStack(spacing: 6) { + Image(systemName: self.currentNavigationIcon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(PackageResourceLookup.brandAccent) + Text(self.currentNavigationTitle) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + } + .padding(.horizontal, 12) + .frame(height: 32) + .compatGlass(interactive: false, in: .capsule) + .padding(.top, 4) + } + } + + ToolbarItemGroup(placement: .primaryAction) { + if !self.isFullScreen, self.supportsCommandBarUI { Button { self.presentCommandBarIfAvailable() } label: { Image(systemName: "sparkles") - .font(.system(size: 14)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.primary) + .frame(width: 32, height: 32) + .compatGlass(interactive: true, in: .circle) } + .buttonStyle(.plain) + .padding(.top, 4) .keyboardShortcut("k", modifiers: .command) .help(String(localized: "Open Command Bar (⌘K)")) .accessibilityIdentifier(AccessibilityID.MainWindow.aiButton) } } } + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) + } + + private var topBarBackground: some View { + ZStack(alignment: .top) { + // Window drag handle in empty regions for native window movement & double-click zoom + WindowDragHandle() + .allowsHitTesting(!self.isFullScreen) + .frame(height: 44) + + LiquidGlassFade(edge: .top, height: 64) + } + .frame(height: 64) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(!self.isFullScreen) + } + + private var topBarView: some View { + ZStack { + // Centered Location Pill + HStack(spacing: 6) { + Image(systemName: self.currentNavigationIcon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(PackageResourceLookup.brandAccent) + Text(self.currentNavigationTitle) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + } + .padding(.horizontal, 12) + .frame(height: 32) + .compatGlass(interactive: false, in: .capsule) + + // Leading and Trailing controls + HStack { + // Sidebar Toggle + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + if self.columnVisibility == .all { + self.columnVisibility = .detailOnly + } else { + self.columnVisibility = .all + } + } + } label: { + Image(systemName: "sidebar.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .compatGlass(interactive: true, in: .capsule) + .help(String(localized: "Toggle Sidebar")) + .accessibilityIdentifier(AccessibilityID.Sidebar.toggleButton) + + Spacer() + + if self.supportsCommandBarUI { + Button { + self.presentCommandBarIfAvailable() + } label: { + Image(systemName: "sparkles") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 32, height: 32) + .compatGlass(interactive: true, in: .circle) + } + .buttonStyle(.plain) + .keyboardShortcut("k", modifiers: .command) + .help(String(localized: "Open Command Bar (⌘K)")) + .accessibilityIdentifier(AccessibilityID.MainWindow.aiButton) + } + } + } + .padding(.top, 6) + .padding(.leading, self.isFullScreen ? 16 : (self.columnVisibility == .detailOnly ? 76 : 16)) + .padding(.trailing, 16) + .frame(height: 48) + .ignoresSafeArea(edges: .top) + } + + private var currentNavigationTitle: String { + if self.settings.appSource == .music { + if let selectedSidebarPinnedItem { + return selectedSidebarPinnedItem.title + } + return self.navigationSelection?.displayName ?? String(localized: "Home") + } else { + return self.youtubeNavigationSelection?.displayName ?? String(localized: "Home") + } + } + + private var currentNavigationIcon: String { + if self.settings.appSource == .music { + if let selectedSidebarPinnedItem { + return selectedSidebarPinnedItem.systemImage + } + return self.navigationSelection?.icon ?? "house" + } else { + return self.youtubeNavigationSelection?.icon ?? "house" + } } private func presentCommandBarIfAvailable() { diff --git a/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift b/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift index 9fe3ae404..e83855115 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift @@ -542,29 +542,16 @@ extension SingletonPlayerWebView { (function() { try { const volume = \(savedVolume); - window.__kasetTargetVolume = volume; - window.__kasetIsSettingVolume = true; - - const video = document.querySelector('video'); - if (video) { - video.volume = volume; - } - - // Sync YouTube's internal player APIs if ready - const ytVolume = Math.round(volume * 100); - const player = document.querySelector('ytmusic-player'); - if (player && player.playerApi && typeof player.playerApi.setVolume === 'function') { - player.playerApi.setVolume(ytVolume); + if (window.__kasetAudio) { + window.__kasetAudio.setTargetVolume(volume); + } else { + window.__kasetTargetVolume = volume; + const video = document.querySelector('video'); + if (video) video.volume = volume; } - const moviePlayer = document.getElementById('movie_player'); - if (moviePlayer && typeof moviePlayer.setVolume === 'function') { - moviePlayer.setVolume(ytVolume); - } - - setTimeout(() => { window.__kasetIsSettingVolume = false; }, 100); - return video ? 'applied' : 'no-video-yet'; + return 'applied'; } catch (e) { - return 'error: ' + e; + return 'error: ' + e; } })(); """ diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index cbfe2e3be..070ace049 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -551,12 +551,14 @@ final class SingletonPlayerWebView { nonisolated static func pageBootstrapScript( isRestoringPlaybackSession: Bool, targetVolume: Double, + fadingEnabled: Bool = true, documentGeneration: UInt64, nativePlaybackGeneration: UInt64 = 0 ) -> String { self.pageBootstrapScript( shouldAutoplay: !isRestoringPlaybackSession, targetVolume: targetVolume, + fadingEnabled: fadingEnabled, documentGeneration: documentGeneration, nativePlaybackGeneration: nativePlaybackGeneration ) @@ -565,6 +567,7 @@ final class SingletonPlayerWebView { nonisolated static func pageBootstrapScript( shouldAutoplay: Bool, targetVolume: Double, + fadingEnabled: Bool, documentGeneration _: UInt64, nativePlaybackGeneration: UInt64 = 0 ) -> String { @@ -609,6 +612,7 @@ final class SingletonPlayerWebView { window.__kasetAutoplayAttempts = 0; window.__kasetAutoplayRetryScheduled = false; window.__kasetTargetVolume = \(clampedVolume); + window.__kasetFadingEnabled = \(fadingEnabled ? "true" : "false"); """ } @@ -633,6 +637,7 @@ final class SingletonPlayerWebView { nativePlaybackGeneration: UInt64 ) { contentController.removeAllUserScripts() + let fadingEnabled = SettingsManager.shared.audioFadingEnabled // Autoplay intent must exist before media lifecycle events like `canplay`. // `didFinish` is too late on fast or cached player loads. @@ -640,6 +645,7 @@ final class SingletonPlayerWebView { source: Self.pageBootstrapScript( shouldAutoplay: shouldAutoplay, targetVolume: targetVolume, + fadingEnabled: fadingEnabled, documentGeneration: documentGeneration, nativePlaybackGeneration: nativePlaybackGeneration ), @@ -648,6 +654,13 @@ final class SingletonPlayerWebView { ) contentController.addUserScript(pageBootstrapScript) + let audioEngineScript = WKUserScript( + source: Self.audioEngineBootstrapScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + contentController.addUserScript(audioEngineScript) + // Keep the page preference in sync before any page script reads localStorage. let mediaControlBootstrapScript = WKUserScript( source: self.mediaControlBootstrapScript(), diff --git a/Sources/Kaset/Views/MoodsAndGenresView.swift b/Sources/Kaset/Views/MoodsAndGenresView.swift index de35bfd95..a1fd028fc 100644 --- a/Sources/Kaset/Views/MoodsAndGenresView.swift +++ b/Sources/Kaset/Views/MoodsAndGenresView.swift @@ -31,7 +31,7 @@ struct MoodsAndGenresView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .localizedNavigationTitle("Moods & Genres") + .navigationTitle("") .navigationDestinations( client: self.viewModel.client, playerBarNavigationAction: self.playerBarNavigationAction @@ -107,7 +107,8 @@ struct MoodsAndGenresView: View { } // Edge-to-edge so shelves slide under the glass sidebar; // resting inset is restored per-shelf via contentInset. - .padding(.vertical, 20) + .padding(.top, 4) + .padding(.bottom, 20) } } } diff --git a/Sources/Kaset/Views/MusicSettingsView.swift b/Sources/Kaset/Views/MusicSettingsView.swift index aed8feca4..fd23dff5d 100644 --- a/Sources/Kaset/Views/MusicSettingsView.swift +++ b/Sources/Kaset/Views/MusicSettingsView.swift @@ -71,8 +71,19 @@ struct MusicSettingsView: View { } } .help(String(localized: "Choose the preferred audio quality for YouTube Music playback")) + + Toggle(String(localized: "Smooth Audio Fading"), isOn: self.$settings.audioFadingEnabled) + .help(String(localized: "Gradually ramps audio volume on play, pause, track skipping, and buffering transitions")) } header: { Text(String(localized: "Audio")) + } footer: { + if self.settings.audioFadingEnabled { + Text(String(localized: "Acoustic volume ramps ensure smooth play, pause, and track skip transitions without pops or clicks.")) + .font(.caption) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + } } // MARK: - Lyrics Section diff --git a/Sources/Kaset/Views/NewReleasesView.swift b/Sources/Kaset/Views/NewReleasesView.swift index 8f6d1da9e..41e44a90d 100644 --- a/Sources/Kaset/Views/NewReleasesView.swift +++ b/Sources/Kaset/Views/NewReleasesView.swift @@ -31,7 +31,7 @@ struct NewReleasesView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .localizedNavigationTitle("New Releases") + .navigationTitle("") .navigationDestinations( client: self.viewModel.client, playerBarNavigationAction: self.playerBarNavigationAction @@ -86,7 +86,8 @@ struct NewReleasesView: View { } // Edge-to-edge so shelves slide under the glass sidebar; resting // inset is restored per-shelf via contentInset. - .padding(.vertical, 20) + .padding(.top, 4) + .padding(.bottom, 20) } } diff --git a/Sources/Kaset/Views/NowPlayingSpotlightView.swift b/Sources/Kaset/Views/NowPlayingSpotlightView.swift new file mode 100644 index 000000000..dc3ff1fd0 --- /dev/null +++ b/Sources/Kaset/Views/NowPlayingSpotlightView.swift @@ -0,0 +1,201 @@ +import SwiftUI + +/// Now-Playing Spotlight presentation view featuring a side-by-side layout: +/// large album artwork on the left and track details, artist links, and controls on the right. +struct NowPlayingSpotlightView: View { + @Environment(\.dismiss) private var dismiss + + let song: Song? + let isPlaying: Bool + let progress: TimeInterval + let duration: TimeInterval + let volume: Double + let isMuted: Bool + let queueSongs: [Song] + let lyricsText: String? + let onPlayPause: () -> Void + let onSeek: (TimeInterval) -> Void + let onNext: () -> Void + let onPrevious: () -> Void + let onVolumeChange: (Double) -> Void + let onToggleMute: () -> Void + let onAirPlay: () -> Void + + @State private var isDrawerVisible = false + @State private var selectedDrawerTab: SpotlightSideDrawer.Tab = .lyrics + + var body: some View { + ZStack { + // Ambient Backdrop Glow + if let artworkURL = self.song?.thumbnailURL { + PlayerBarArtworkGlow( + sources: [artworkURL], + identity: self.song?.id, + targetSize: CGSize(width: 800, height: 800), + width: 950, + height: 950, + cornerRadius: 48 + ) + .opacity(0.65) + } + + VStack(spacing: 0) { + // Header Bar with Dismiss & AirPlay + SpotlightHeaderView( + onDismiss: { self.dismiss() }, + onAirPlay: self.onAirPlay + ) + + Spacer(minLength: 20) + + // Side-by-Side Split View + HStack(alignment: .center, spacing: 48) { + // Left Column: Prominent Large Cover Artwork + VStack { + ZStack { + if let artworkURL = self.song?.thumbnailURL { + CachedAsyncImage(url: artworkURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + ProgressView() + } + .frame(width: 380, height: 380) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .shadow(color: .black.opacity(0.45), radius: 32, x: 0, y: 16) + } else { + ZStack { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(.quaternary) + .frame(width: 380, height: 380) + + Image(systemName: "music.note") + .font(.system(size: 100)) + .foregroundStyle(.secondary) + } + } + } + } + .frame(maxWidth: 420) + + // Right Column: Title, Singer Details, Playback Controls & Drawer + VStack(alignment: .leading, spacing: 20) { + // Track & Artist Information + VStack(alignment: .leading, spacing: 8) { + Text(self.song?.title ?? "No Track Playing") + .font(.system(size: 30, weight: .bold, design: .rounded)) + .lineLimit(2) + .foregroundStyle(.primary) + + // Singer / Artists + HStack(spacing: 8) { + Image(systemName: "person.circle.fill") + .font(.title3) + .foregroundStyle(.tint) + + Text(self.song?.artists.map(\.name).joined(separator: ", ") ?? "Unknown Artist") + .font(.title2.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if let albumTitle = self.song?.album?.title { + HStack(spacing: 6) { + Image(systemName: "square.stack.fill") + .font(.subheadline) + .foregroundStyle(.tertiary) + + Text(albumTitle) + .font(.headline) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + .padding(.top, 2) + } + } + + Divider() + .padding(.vertical, 4) + + // Interactive Scrubber & Controls + SpotlightControlsSection( + isPlaying: self.isPlaying, + progress: self.progress, + duration: self.duration, + volume: self.volume, + isMuted: self.isMuted, + onPlayPause: self.onPlayPause, + onSeek: self.onSeek, + onNext: self.onNext, + onPrevious: self.onPrevious, + onVolumeChange: self.onVolumeChange, + onToggleMute: self.onToggleMute + ) + + // Drawer Options Toggle Bar (Lyrics & Queue) + HStack(spacing: 16) { + Button(action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + if self.isDrawerVisible, self.selectedDrawerTab == .lyrics { + self.isDrawerVisible = false + } else { + self.selectedDrawerTab = .lyrics + self.isDrawerVisible = true + } + } + }, label: { + Label("Lyrics", systemImage: "quote.bubble.fill") + .font(.headline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(self.isDrawerVisible && self.selectedDrawerTab == .lyrics ? Color.accentColor.opacity(0.2) : Color.clear) + .clipShape(Capsule()) + .foregroundStyle(self.isDrawerVisible && self.selectedDrawerTab == .lyrics ? Color.accentColor : Color.secondary) + }) + .buttonStyle(.plain) + + Button(action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + if self.isDrawerVisible, self.selectedDrawerTab == .queue { + self.isDrawerVisible = false + } else { + self.selectedDrawerTab = .queue + self.isDrawerVisible = true + } + } + }, label: { + Label("Up Next (\(self.queueSongs.count))", systemImage: "list.bullet.rectangle.portrait.fill") + .font(.headline.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(self.isDrawerVisible && self.selectedDrawerTab == .queue ? Color.accentColor.opacity(0.2) : Color.clear) + .clipShape(Capsule()) + .foregroundStyle(self.isDrawerVisible && self.selectedDrawerTab == .queue ? Color.accentColor : Color.secondary) + }) + .buttonStyle(.plain) + } + .padding(.top, 4) + + // Embedded Lyrics / Queue Panel if active + if self.isDrawerVisible { + SpotlightSideDrawer( + selectedTab: self.$selectedDrawerTab, + isVisible: self.isDrawerVisible, + lyricsText: self.lyricsText, + queueSongs: self.queueSongs + ) + .frame(maxHeight: 220) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + } + .frame(maxWidth: 480) + } + .padding(.horizontal, 40) + + Spacer(minLength: 24) + } + } + .frame(minWidth: 880, minHeight: 600) + } +} diff --git a/Sources/Kaset/Views/PlayerBar.swift b/Sources/Kaset/Views/PlayerBar.swift index aeaa7f738..5f2106501 100644 --- a/Sources/Kaset/Views/PlayerBar.swift +++ b/Sources/Kaset/Views/PlayerBar.swift @@ -117,19 +117,8 @@ struct PlayerBar: View { // swiftlint:disable:this type_body_length } private var playerAreaFade: some View { - LinearGradient( - colors: [ - Color(nsColor: .windowBackgroundColor).opacity(0), - Color(nsColor: .windowBackgroundColor).opacity(0.22), - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 44) - .frame(maxWidth: .infinity) - .padding(.bottom, -8) - .allowsHitTesting(false) - .accessibilityHidden(true) + LiquidGlassFade(edge: .bottom, height: 135) + .padding(.bottom, -8) } // MARK: - Song Info @@ -170,6 +159,7 @@ struct PlayerBar: View { // swiftlint:disable:this type_body_length } else { self.trackArtwork(for: track) .accessibilityIdentifier(AccessibilityID.PlayerBar.thumbnail) + .accessibilityLabel(Text(String(localized: "Go to Album"))) } } else { PlayerBarArtworkView( diff --git a/Sources/Kaset/Views/PodcastsView.swift b/Sources/Kaset/Views/PodcastsView.swift index 1b52adb52..5ceebc22c 100644 --- a/Sources/Kaset/Views/PodcastsView.swift +++ b/Sources/Kaset/Views/PodcastsView.swift @@ -34,7 +34,7 @@ struct PodcastsView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .localizedNavigationTitle("Podcasts") + .navigationTitle("") .navigationDestination(for: PodcastShow.self) { show in PodcastShowView(show: show, client: self.viewModel.client) } diff --git a/Sources/Kaset/Views/SearchView.swift b/Sources/Kaset/Views/SearchView.swift index beb5b88ac..b284b4496 100644 --- a/Sources/Kaset/Views/SearchView.swift +++ b/Sources/Kaset/Views/SearchView.swift @@ -39,7 +39,7 @@ struct SearchView: View { // Content self.contentView } - .localizedNavigationTitle("Search") + .navigationTitle("") .navigationDestinations( client: self.viewModel.client, playerBarNavigationAction: self.playerBarNavigationAction diff --git a/Sources/Kaset/Views/SharedViews/LiquidGlassFade.swift b/Sources/Kaset/Views/SharedViews/LiquidGlassFade.swift new file mode 100644 index 000000000..2394b3d5b --- /dev/null +++ b/Sources/Kaset/Views/SharedViews/LiquidGlassFade.swift @@ -0,0 +1,93 @@ +import SwiftUI + +// MARK: - LiquidGlassFade + +/// A reusable edge-docked Liquid Glass gradient surface that dissolves seamlessly into the content. +/// +/// Features: +/// - Decoupled Apple macOS Liquid Glass refraction layer (retains full blur strength). +/// - Dynamic mode-aware tint gradient (soft black in dark mode, soft white in light mode). +/// - Smooth cubic alpha easing to eliminate hard clipping edges. +/// - Available for topbars, player areas, and custom scroll fades. +struct LiquidGlassFade: View { + @Environment(\.colorScheme) private var colorScheme + + let edge: VerticalEdge + var height: CGFloat = 64 + var maxTintOpacity: Double? + + private var effectiveTintOpacity: Double { + if let maxTintOpacity { + return maxTintOpacity + } + return self.colorScheme == .dark ? 0.65 : 0.25 + } + + private var tintColor: Color { + self.colorScheme == .dark ? Color.black : Color.white + } + + private var startPoint: UnitPoint { + self.edge == .top ? .top : .bottom + } + + private var endPoint: UnitPoint { + self.edge == .top ? .bottom : .top + } + + var body: some View { + ZStack(alignment: self.edge == .top ? .top : .bottom) { + // 1. Pure Liquid Glass layer: full refraction at docked edge with smooth Ease-Out feathered mask + Color.clear + .compatGlass(interactive: false, in: Rectangle()) + .mask( + LinearGradient( + stops: [ + .init(color: .white, location: 0.0), + .init(color: .white.opacity(0.80), location: 0.15), + .init(color: .white.opacity(0.58), location: 0.35), + .init(color: .white.opacity(0.36), location: 0.55), + .init(color: .white.opacity(0.18), location: 0.75), + .init(color: .white.opacity(0.06), location: 0.90), + .init(color: .clear, location: 1.0), + ], + startPoint: self.startPoint, + endPoint: self.endPoint + ) + ) + + // 2. Color tint layer: mode-aware tint decaying with smooth Ease-Out feathered falloff + LinearGradient( + stops: [ + .init(color: self.tintColor.opacity(self.effectiveTintOpacity), location: 0.0), + .init(color: self.tintColor.opacity(self.effectiveTintOpacity * 0.80), location: 0.15), + .init(color: self.tintColor.opacity(self.effectiveTintOpacity * 0.58), location: 0.35), + .init(color: self.tintColor.opacity(self.effectiveTintOpacity * 0.36), location: 0.55), + .init(color: self.tintColor.opacity(self.effectiveTintOpacity * 0.18), location: 0.75), + .init(color: self.tintColor.opacity(self.effectiveTintOpacity * 0.06), location: 0.90), + .init(color: .clear, location: 1.0), + ], + startPoint: self.startPoint, + endPoint: self.endPoint + ) + } + .frame(height: self.height) + .frame(maxWidth: .infinity) + .ignoresSafeArea(edges: self.edge == .top ? .top : .bottom) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +extension View { + /// Applies a reusable Liquid Glass gradient fade along the specified vertical edge. + func liquidGlassFade( + edge: VerticalEdge, + height: CGFloat = 64, + maxTintOpacity: Double? = nil + ) -> some View { + self.overlay(alignment: edge == .top ? .top : .bottom) { + LiquidGlassFade(edge: edge, height: height, maxTintOpacity: maxTintOpacity) + } + } +} diff --git a/Sources/Kaset/Views/Sidebar.swift b/Sources/Kaset/Views/Sidebar.swift index eed5037a8..4c2305042 100644 --- a/Sources/Kaset/Views/Sidebar.swift +++ b/Sources/Kaset/Views/Sidebar.swift @@ -84,6 +84,7 @@ struct Sidebar: View { // Source toggle + profile section at bottom (shared with YouTubeSidebar) SidebarFooterView() } + .toolbar(removing: .sidebarToggle) .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 300) } diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+AudioEngine.swift b/Sources/Kaset/Views/SingletonPlayerWebView+AudioEngine.swift new file mode 100644 index 000000000..8acb26cd5 --- /dev/null +++ b/Sources/Kaset/Views/SingletonPlayerWebView+AudioEngine.swift @@ -0,0 +1,505 @@ +import Foundation +import WebKit + +// MARK: - SingletonPlayerWebView Audio Engine + +extension SingletonPlayerWebView { + /// Injected at document start so `window.__kasetAudio` is available before any media elements load. + static var audioEngineBootstrapScript: String { + """ + (function() { + if (window.__kasetAudio) return; + + class KasetAudioEngine { + constructor() { + this.targetVolume = typeof window.__kasetTargetVolume === 'number' + ? Math.max(0.0, Math.min(1.0, window.__kasetTargetVolume)) + : 1.0; + this.fadingEnabled = typeof window.__kasetFadingEnabled === 'boolean' + ? window.__kasetFadingEnabled + : true; + this.state = 'idle'; // 'idle' | 'fading_in' | 'fading_out' + this.fadeInterval = null; + this.isSettingVolume = false; + this.isEnforcingVolume = false; + this._resetVolumeSettingTimeout = null; + } + + getVideo() { + return document.querySelector('video'); + } + + getMoviePlayer() { + return document.getElementById('movie_player'); + } + + getYtPlayer() { + return document.querySelector('ytmusic-player'); + } + + attachVideo(video) { + if (!video || video.__kasetEngineAttached) return; + video.__kasetEngineAttached = true; + + // When a new song starts loading from network, prime volume to 0 so it never blasts before blooming + const primeZeroVolume = () => { + if (this.fadingEnabled && this.state !== 'fading_out') { + this.applyGain(0.0); + } + }; + + video.addEventListener('loadstart', primeZeroVolume); + video.addEventListener('loadedmetadata', primeZeroVolume); + + video.addEventListener('play', () => { + if (window.__kasetPlaybackSuppressed) { + video.pause(); + } + }); + + video.addEventListener('playing', () => { + if (window.__kasetPlaybackSuppressed) { + video.pause(); + return; + } + if (this.fadingEnabled && this.targetVolume > 0 && this.state === 'idle' && !this.isSettingVolume) { + this.bloom(350); + } + }); + } + + setFadingEnabled(enabled) { + this.fadingEnabled = !!enabled; + window.__kasetFadingEnabled = this.fadingEnabled; + } + + setTargetVolume(vol) { + const clamped = Math.max(0.0, Math.min(1.0, typeof vol === 'number' && Number.isFinite(vol) ? vol : 1.0)); + this.targetVolume = clamped; + window.__kasetTargetVolume = clamped; + + if (this.state === 'idle') { + this.applyGain(clamped); + } + } + + applyGain(vol) { + const clamped = Math.max(0.0, Math.min(1.0, vol)); + this.isSettingVolume = true; + window.__kasetIsSettingVolume = true; + + const video = this.getVideo(); + if (video) { + video.volume = clamped; + } + + const ytVol = Math.round(clamped * 100); + const mp = this.getMoviePlayer(); + if (mp && typeof mp.setVolume === 'function') { + mp.setVolume(ytVol); + } + + const yp = this.getYtPlayer(); + if (yp && yp.playerApi && typeof yp.playerApi.setVolume === 'function') { + yp.playerApi.setVolume(ytVol); + } + + if (this.state === 'idle') { + if (this._resetVolumeSettingTimeout) { + clearTimeout(this._resetVolumeSettingTimeout); + } + this._resetVolumeSettingTimeout = setTimeout(() => { + this.isSettingVolume = false; + window.__kasetIsSettingVolume = false; + this._resetVolumeSettingTimeout = null; + }, 50); + } + } + + cancelFade() { + if (this.fadeInterval) { + clearInterval(this.fadeInterval); + this.fadeInterval = null; + window.__kasetFadeInterval = null; + } + this.state = 'idle'; + this.isSettingVolume = false; + window.__kasetIsSettingVolume = false; + } + + enforceVolume() { + if (this.state !== 'idle' || this.isSettingVolume || this.isEnforcingVolume) { + return; + } + const video = this.getVideo(); + if (!video) return; + + const targetVol = this.targetVolume; + if (Math.abs(video.volume - targetVol) <= 0.01) return; + + this.isEnforcingVolume = true; + this.applyGain(targetVol); + setTimeout(() => { + this.isEnforcingVolume = false; + }, 50); + } + + resume(durationMs = 350) { + this.cancelFade(); + + window.__kasetAutoplayPending = true; + window.__kasetPlaybackSuppressed = false; + window.__kasetResumeAdOnly = false; + window.__kasetAutoplayAttempts = 0; + window.__kasetAutoplayRetryScheduled = false; + + const video = this.getVideo(); + const moviePlayer = this.getMoviePlayer(); + const playBtn = document.querySelector('.play-pause-button.ytmusic-player-bar'); + + if (moviePlayer && typeof moviePlayer.playVideo === 'function') { + moviePlayer.playVideo(); + } else if (playBtn && video && video.paused) { + playBtn.click(); + } + if (video && video.paused) { + if (typeof window.__kasetAttemptAutoplayRecovery === 'function') { + window.__kasetAttemptAutoplayRecovery(video, playBtn); + } else { + video.play(); + } + } + + const target = this.targetVolume; + + if (!this.fadingEnabled || durationMs <= 0) { + this.applyGain(target); + return 'resumed-instant'; + } + + this.state = 'fading_in'; + this.isSettingVolume = true; + window.__kasetIsSettingVolume = true; + + const currentVol = video ? video.volume : 0.0; + const startVol = (currentVol > 0.01 && currentVol < target) ? currentVol : 0.0; + this.applyGain(startVol); + + const startTime = performance.now(); + this.fadeInterval = setInterval(() => { + const elapsed = performance.now() - startTime; + const progress = Math.min(1.0, elapsed / durationMs); + const factor = Math.pow(progress, 2.2); + const currentGain = startVol + (this.targetVolume - startVol) * factor; + this.applyGain(currentGain); + + if (progress >= 1.0) { + this.applyGain(this.targetVolume); + this.cancelFade(); + } + }, 16); + window.__kasetFadeInterval = this.fadeInterval; + return 'fading-in'; + } + + pause(durationMs = 350, onComplete = null) { + this.cancelFade(); + + window.__kasetAutoplayPending = false; + window.__kasetPlaybackSuppressed = true; + + const video = this.getVideo(); + const moviePlayer = this.getMoviePlayer(); + + if (!video || video.paused) { + if (moviePlayer && typeof moviePlayer.pauseVideo === 'function') { + moviePlayer.pauseVideo(); + } + if (typeof onComplete === 'function') onComplete(); + return 'already-paused'; + } + + if (!this.fadingEnabled || durationMs <= 0) { + this.applyGain(0.0); + if (moviePlayer && typeof moviePlayer.pauseVideo === 'function') { + moviePlayer.pauseVideo(); + } + video.pause(); + const pauseBtn = document.querySelector('.play-pause-button.ytmusic-player-bar'); + if (pauseBtn && !video.paused) pauseBtn.click(); + if (typeof onComplete === 'function') onComplete(); + return 'paused-instant'; + } + + this.state = 'fading_out'; + this.isSettingVolume = true; + window.__kasetIsSettingVolume = true; + + const startVol = video.volume > 0.0 ? video.volume : this.targetVolume; + const startTime = performance.now(); + + this.fadeInterval = setInterval(() => { + const elapsed = performance.now() - startTime; + const progress = Math.min(1.0, elapsed / durationMs); + const factor = Math.pow(Math.max(0.0, 1.0 - progress), 2.0); + this.applyGain(startVol * factor); + + if (progress >= 1.0) { + this.applyGain(0.0); + if (moviePlayer && typeof moviePlayer.pauseVideo === 'function') { + moviePlayer.pauseVideo(); + } + if (video) video.pause(); + const pauseBtn = document.querySelector('.play-pause-button.ytmusic-player-bar'); + if (pauseBtn && video && !video.paused) pauseBtn.click(); + + this.cancelFade(); + if (typeof onComplete === 'function') onComplete(); + } + }, 16); + window.__kasetFadeInterval = this.fadeInterval; + return 'fading-out'; + } + + skipWithFade(durationMs = 150, onAction = null) { + const video = this.getVideo(); + if (!video || video.paused || !this.fadingEnabled || durationMs <= 0 || video.volume <= 0.01) { + this.cancelFade(); + if (typeof onAction === 'function') onAction(); + return 'skipped-instant'; + } + + this.cancelFade(); + this.state = 'fading_out'; + this.isSettingVolume = true; + window.__kasetIsSettingVolume = true; + + const startVol = video.volume > 0.0 ? video.volume : this.targetVolume; + const startTime = performance.now(); + + this.fadeInterval = setInterval(() => { + const elapsed = performance.now() - startTime; + const progress = Math.min(1.0, elapsed / durationMs); + const factor = Math.pow(Math.max(0.0, 1.0 - progress), 2.0); + this.applyGain(startVol * factor); + + if (progress >= 1.0) { + this.applyGain(0.0); + this.cancelFade(); + if (typeof onAction === 'function') onAction(); + } + }, 16); + window.__kasetFadeInterval = this.fadeInterval; + return 'fading-skip'; + } + + seekWithFade(durationMs = 300, onAction = null) { + const video = this.getVideo(); + if (!video || video.paused || !this.fadingEnabled || durationMs <= 0) { + this.cancelFade(); + if (typeof onAction === 'function') onAction(); + return 'seeked-instant'; + } + + this.cancelFade(); + this.state = 'fading_out'; + this.isSettingVolume = true; + window.__kasetIsSettingVolume = true; + + const originalVol = this.targetVolume; + const startVol = video.volume > 0.0 ? video.volume : originalVol; + const halfDuration = Math.max(80, Math.round(durationMs / 2)); + const startTime = performance.now(); + + this.fadeInterval = setInterval(() => { + const elapsed = performance.now() - startTime; + const progress = Math.min(1.0, elapsed / halfDuration); + const factor = Math.pow(Math.max(0.0, 1.0 - progress), 2.0); + this.applyGain(startVol * factor); + + if (progress >= 1.0) { + if (this.fadeInterval) { + clearInterval(this.fadeInterval); + this.fadeInterval = null; + } + this.applyGain(0.0); + if (typeof onAction === 'function') onAction(); + + // Seamlessly transition into fade-in without returning to idle + this.state = 'fading_in'; + const rampStartTime = performance.now(); + + this.fadeInterval = setInterval(() => { + const rampElapsed = performance.now() - rampStartTime; + const rampProgress = Math.min(1.0, rampElapsed / halfDuration); + const rampFactor = Math.pow(rampProgress, 2.2); + this.applyGain(originalVol * rampFactor); + + if (rampProgress >= 1.0) { + this.applyGain(originalVol); + this.cancelFade(); + this.enforceVolume(); + } + }, 16); + window.__kasetFadeInterval = this.fadeInterval; + } + }, 16); + window.__kasetFadeInterval = this.fadeInterval; + return 'fading-seek'; + } + + bloom(durationMs = 350) { + if (this.state === 'fading_in' || this.state === 'fading_out') { + return; + } + const video = this.getVideo(); + if (!video) return; + + if (!this.fadingEnabled || durationMs <= 0) { + this.enforceVolume(); + return; + } + + const target = this.targetVolume; + + this.cancelFade(); + this.state = 'fading_in'; + this.isSettingVolume = true; + window.__kasetIsSettingVolume = true; + + const currentVol = video.volume; + const startVol = (currentVol > 0.01 && currentVol < target) ? currentVol : 0.0; + this.applyGain(startVol); + + const startTime = performance.now(); + + this.fadeInterval = setInterval(() => { + const elapsed = performance.now() - startTime; + const progress = Math.min(1.0, elapsed / durationMs); + const factor = Math.pow(progress, 2.2); + this.applyGain(startVol + (this.targetVolume - startVol) * factor); + + if (progress >= 1.0) { + this.applyGain(this.targetVolume); + this.cancelFade(); + this.enforceVolume(); + } + }, 16); + window.__kasetFadeInterval = this.fadeInterval; + } + + fadeRamp(fromVol, toVol, durationMs, isLog, onComplete) { + this.cancelFade(); + const from = Math.max(0.0, Math.min(1.0, fromVol)); + const to = Math.max(0.0, Math.min(1.0, toVol)); + + if (durationMs <= 0) { + this.applyGain(to); + if (typeof onComplete === 'function') onComplete(); + return; + } + + this.state = to < from ? 'fading_out' : 'fading_in'; + this.isSettingVolume = true; + window.__kasetIsSettingVolume = true; + + this.applyGain(from); + const startTime = performance.now(); + + this.fadeInterval = setInterval(() => { + const elapsed = performance.now() - startTime; + const progress = Math.min(1.0, elapsed / durationMs); + const factor = isLog + ? (to < from ? Math.pow(Math.max(0.0, 1.0 - progress), 2.0) : Math.pow(progress, 2.2)) + : progress; + const current = to < from ? from * factor : from + (to - from) * factor; + this.applyGain(current); + + if (progress >= 1.0) { + this.applyGain(to); + this.cancelFade(); + if (typeof onComplete === 'function') onComplete(); + + if (to === 0.0) { + const video = this.getVideo(); + if (video && !video.paused && !window.__kasetPlaybackSuppressed) { + this.bloom(); + } + } + } + }, 16); + window.__kasetFadeInterval = this.fadeInterval; + } + } + + window.__kasetAudio = new KasetAudioEngine(); + + // Eagerly find and attach to video element so we can catch the very first 'loadstart' / 'play' events + const attachEagerly = () => { + const video = document.querySelector('video'); + if (video) { + window.__kasetAudio.attachVideo(video); + return true; + } + return false; + }; + if (!attachEagerly()) { + const observer = new MutationObserver((mutations, obs) => { + if (attachEagerly()) { + // Keep observing in case YouTube replaces the video element during SPA navigation + const video = document.querySelector('video'); + if (video) window.__kasetAudio.attachVideo(video); + } + }); + observer.observe(document, { childList: true, subtree: true }); + } + + // Intercept keyboard shortcuts in the DOM so they trigger our audio fader. + document.addEventListener('keydown', (e) => { + const target = e.target; + const isInput = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable); + if (isInput) return; + + if (e.code === 'Space' || e.key === ' ' || e.code === 'KeyK') { + e.preventDefault(); + e.stopPropagation(); + const video = window.__kasetAudio.getVideo(); + if (video && video.paused) { + window.__kasetAudio.resume(350); + } else if (video) { + window.__kasetAudio.pause(350); + } + return; + } + + if (e.shiftKey && e.code === 'KeyN') { + e.preventDefault(); + e.stopPropagation(); + window.__kasetAudio.skipWithFade(150, () => { + const nextBtn = document.querySelector('.next-button.ytmusic-player-bar'); + if (nextBtn) nextBtn.click(); + }); + return; + } + + if (e.shiftKey && e.code === 'KeyP') { + e.preventDefault(); + e.stopPropagation(); + const video = window.__kasetAudio.getVideo(); + if (video && video.currentTime > 3) { + window.__kasetAudio.seekWithFade(300, () => { + video.currentTime = 0; + }); + } else { + window.__kasetAudio.skipWithFade(150, () => { + const prevBtn = document.querySelector('.previous-button.ytmusic-player-bar'); + if (prevBtn) prevBtn.click(); + }); + } + return; + } + }, true); + })(); + """ + } +} diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift index eb363e1c3..509c35d32 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift @@ -148,24 +148,11 @@ extension SingletonPlayerWebView { const UPDATE_THROTTLE_MS = 500; // Throttle updates to max 2/sec const POLL_INTERVAL_MS = 1000; // Poll at 1Hz during playback (reduced from 250ms) - // Volume enforcement: track target volume set by Swift - // Don't set a default - only enforce when explicitly set by Swift - // window.__kasetTargetVolume is set by volume init script at document start - let isEnforcingVolume = false; // Prevent feedback loops - - // Reusable 3-way volume enforcement (video element + YouTube APIs) + // Volume enforcement: delegate to central window.__kasetAudio engine function enforceVolumeNow() { - const targetVol = window.__kasetTargetVolume; - const v = document.querySelector('video'); - if (!v || typeof targetVol !== 'number' || Math.abs(v.volume - targetVol) <= 0.01) return; - isEnforcingVolume = true; - v.volume = targetVol; - const ytVol = Math.round(targetVol * 100); - const p = document.querySelector('ytmusic-player'); - if (p && p.playerApi) p.playerApi.setVolume(ytVol); - const mp = document.getElementById('movie_player'); - if (mp && mp.setVolume) mp.setVolume(ytVol); - setTimeout(() => { isEnforcingVolume = false; }, 50); + if (window.__kasetAudio) { + window.__kasetAudio.enforceVolume(); + } } function waitForPlayerBar() { @@ -264,7 +251,11 @@ extension SingletonPlayerWebView { window.__kasetAutoplayAttempts = 0; window.__kasetAutoplayRetryScheduled = false; bindVideoIdentity(video, !mediaVideoId); - enforceVolumeNow(); + if (window.__kasetAudio) { + window.__kasetAudio.bloom(350); + } else { + enforceVolumeNow(); + } restartLyricsPoll(false); }); video.addEventListener('pause', stopPolling); @@ -286,7 +277,11 @@ extension SingletonPlayerWebView { setTimeout(() => retryTrackEnded(video, endedPayload), 100); stopPolling(); }); - video.addEventListener('waiting', () => sendUpdate(true)); // Buffer state + video.addEventListener('waiting', () => { + sendUpdate(true); // Buffer state + // No micro-fade on buffer stalls — the user's explicit fade setting + // (pause/resume) already covers all intentional transitions. + }); video.addEventListener('seeked', () => { sendUpdate(true); // Seek completed restartLyricsPoll(true); @@ -334,24 +329,27 @@ extension SingletonPlayerWebView { } // Volume enforcement: immediately revert external volume changes - // No debounce — the isEnforcingVolume flag prevents feedback loops. - // A debounce allowed YouTube's rapid-fire init events to keep pushing - // enforcement later, leaving wrong volume audible for 1-2 seconds. + // Central KasetAudioEngine guards against feedback loops and active fades. video.addEventListener('volumechange', () => { - if (isEnforcingVolume) return; - if (window.__kasetIsSettingVolume) return; enforceVolumeNow(); }); // Enforce volume at media lifecycle events where YouTube resets volume. - // YouTube's player often restores its stored volume at these points. video.addEventListener('loadedmetadata', () => { bindVideoIdentity(video, true); - enforceVolumeNow(); + if (!window.__kasetAudio || !window.__kasetAudio.fadingEnabled) { + enforceVolumeNow(); + } + }); + video.addEventListener('loadeddata', () => { + if (!window.__kasetAudio || !window.__kasetAudio.fadingEnabled) { + enforceVolumeNow(); + } }); - video.addEventListener('loadeddata', () => enforceVolumeNow()); function recoverAutoplayIfNeeded() { - enforceVolumeNow(); + if (!window.__kasetAudio || !window.__kasetAudio.fadingEnabled) { + enforceVolumeNow(); + } // Autoplay recovery: YTM sometimes leaves the video paused // after navigation even with the WebKit autoplay allowance. const btn = document.querySelector('.play-pause-button.ytmusic-player-bar'); @@ -360,8 +358,10 @@ extension SingletonPlayerWebView { video.addEventListener('canplay', recoverAutoplayIfNeeded); - // Apply target volume immediately when video element is first detected - enforceVolumeNow(); + // Apply target volume immediately only when fading is disabled + if (!window.__kasetAudio || !window.__kasetAudio.fadingEnabled) { + enforceVolumeNow(); + } // If the media was already ready before this listener attached, // there may not be another `canplay` event to drive recovery. diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift index c995fba99..a655596b5 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift @@ -144,14 +144,42 @@ extension SingletonPlayerWebView { let generation = self.documentGeneration.currentGeneration guard self.documentGeneration.accepts(generation: generation) else { return } - let script = """ - if (window.__kasetDocumentGeneration === \(generation)) { - \(Self.playPauseCommandScript) + let fadeEnabled = SettingsManager.shared.audioFadingEnabled + + if fadeEnabled { + let script = """ + (function() { + const video = document.querySelector('video'); + if (!video) return 'no-video'; + if (video.paused) { + return 'is-paused'; + } else { + return 'is-playing'; + } + })(); + """ + webView.evaluateJavaScript(script) { [weak self] result, _ in + guard let self else { return } + if let status = result as? String { + if status == "is-paused" { + self.play() + } else { + self.pause() + } + } else { + self.play() + } } - """ - webView.evaluateJavaScript(script) { [weak self] _, error in - if let error { - self?.logger.error("playPause error: \(error.localizedDescription)") + } else { + let script = """ + if (window.__kasetDocumentGeneration === \(generation)) { + \(Self.playPauseCommandScript) + } + """ + webView.evaluateJavaScript(script) { [weak self] _, error in + if let error { + self?.logger.error("playPause error: \(error.localizedDescription)") + } } } } @@ -177,16 +205,17 @@ extension SingletonPlayerWebView { """ } - /// Play (resume). + /// Resume playback with smooth in-browser audio volume fade in. func play() { guard let webView else { return } - let generation = self.documentGeneration.currentGeneration - guard self.documentGeneration.accepts(generation: generation) else { return } - webView.evaluateJavaScript(""" - if (window.__kasetDocumentGeneration === \(generation)) { + let script = """ + if (window.__kasetAudio) { + window.__kasetAudio.resume(350); + } else { \(Self.playCommandScript) } - """, completionHandler: nil) + """ + webView.evaluateJavaScript(script, completionHandler: nil) } /// During restored playback, a paused preroll ad must advance before the @@ -217,67 +246,80 @@ extension SingletonPlayerWebView { """, completionHandler: nil) } - /// Pause. + /// Pause with smooth in-browser audio volume fade out. func pause() { guard let webView else { return } - let script = """ - (function() { - window.__kasetAutoplayPending = false; - window.__kasetPlaybackSuppressed = true; - const video = document.querySelector('video'); - if (video && !video.paused) { video.pause(); return 'paused'; } - return 'already-paused'; - })(); + if (window.__kasetAudio) { + window.__kasetAudio.pause(350); + } else { + (function() { + window.__kasetAutoplayPending = false; + window.__kasetPlaybackSuppressed = true; + const video = document.querySelector('video'); + if (video) video.pause(); + })(); + } """ webView.evaluateJavaScript(script, completionHandler: nil) } - /// Skip to next track. + /// Skip to next track with smooth transition. func next() { guard let webView else { return } - let script = """ (function() { - const nextBtn = document.querySelector('.next-button.ytmusic-player-bar'); - if (nextBtn) { nextBtn.click(); return 'clicked'; } - return 'no-button'; + const action = () => { + const nextBtn = document.querySelector('.next-button.ytmusic-player-bar'); + if (nextBtn) nextBtn.click(); + }; + if (window.__kasetAudio) { + window.__kasetAudio.skipWithFade(150, action); + } else { + action(); + } })(); """ - webView.evaluateJavaScript(script) { [weak self] _, error in - if let error { - self?.logger.error("next error: \(error.localizedDescription)") - } - } + webView.evaluateJavaScript(script, completionHandler: nil) } - /// Go to previous track. + /// Go to previous track with smooth transition. func previous() { guard let webView else { return } - let script = """ (function() { - const prevBtn = document.querySelector('.previous-button.ytmusic-player-bar'); - if (prevBtn) { prevBtn.click(); return 'clicked'; } - return 'no-button'; + const action = () => { + const prevBtn = document.querySelector('.previous-button.ytmusic-player-bar'); + if (prevBtn) prevBtn.click(); + }; + if (window.__kasetAudio) { + window.__kasetAudio.skipWithFade(150, action); + } else { + action(); + } })(); """ - webView.evaluateJavaScript(script) { [weak self] _, error in - if let error { - self?.logger.error("previous error: \(error.localizedDescription)") - } - } + webView.evaluateJavaScript(script, completionHandler: nil) } - /// Seek to a specific time in seconds. - func seek(to time: Double) { + /// Seek to a specific time in seconds, optionally with a fast fade. + func seek(to time: Double, withFade: Bool = false) { guard let webView else { return } let script = """ (function() { - const video = document.querySelector('video'); - if (video) { video.currentTime = \(time); return 'seeked'; } - return 'no-video'; + const action = () => { + const video = document.querySelector('video'); + if (video) { video.currentTime = \(time); } + }; + if (window.__kasetAudio && \(withFade ? "true" : "false")) { + // Smooth 300ms fade down/up for seamless track restart + window.__kasetAudio.seekWithFade(300, action); + return 'fading-seek'; + } else { + action(); + return 'seeked'; + } })(); """ webView.evaluateJavaScript(script, completionHandler: nil) @@ -317,49 +359,14 @@ extension SingletonPlayerWebView { func setVolume(_ volume: Double) { guard let webView else { return } let clampedVolume = max(0, min(1, volume)) - - // Update target volume and set video volume directly - // Also try to set YouTube's internal player volume via their API let script = """ - (function() { + if (window.__kasetAudio) { + window.__kasetAudio.setTargetVolume(\(clampedVolume)); + } else { window.__kasetTargetVolume = \(clampedVolume); - const video = document.querySelector('video'); - let result = []; - - if (video) { - // Set flag to prevent volumechange listener from reverting - window.__kasetIsSettingVolume = true; - video.volume = \(clampedVolume); - result.push('video.volume=' + video.volume); - setTimeout(() => { window.__kasetIsSettingVolume = false; }, 50); - } else { - result.push('no-video'); - } - - // Also try YouTube Music's internal player API - const player = document.querySelector('ytmusic-player'); - if (player && player.playerApi) { - const ytVolume = Math.round(\(clampedVolume) * 100); - player.playerApi.setVolume(ytVolume); - result.push('ytapi.setVolume=' + ytVolume); - } - - // Try movie_player API as fallback - const moviePlayer = document.getElementById('movie_player'); - if (moviePlayer && moviePlayer.setVolume) { - const ytVolume = Math.round(\(clampedVolume) * 100); - moviePlayer.setVolume(ytVolume); - result.push('movie_player.setVolume=' + ytVolume); - } - - return result.join(', '); - })(); - """ - webView.evaluateJavaScript(script) { _, error in - if let error { - self.logger.error("setVolume error: \(error.localizedDescription)") } - } + """ + webView.evaluateJavaScript(script, completionHandler: nil) } /// Show the native AirPlay picker for the WebView's video element. diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift index d8446c93f..94c95b009 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift @@ -195,6 +195,23 @@ extension SingletonPlayerWebView { """ } + // MARK: - Fading Enabled + + /// Syncs the audio-fading-enabled flag to the live page and future bootstrap state. + func setFadingEnabled(_ enabled: Bool) { + self.refreshInstalledUserScripts() + guard let webView = self.webView else { return } + let jsBoolean = enabled ? "true" : "false" + let script = """ + if (window.__kasetAudio) { + window.__kasetAudio.setFadingEnabled(\(jsBoolean)); + } else { + window.__kasetFadingEnabled = \(jsBoolean); + } + """ + webView.evaluateJavaScript(script, completionHandler: nil) + } + // MARK: - Playback Audio Quality /// Updates the current page and the bootstrap state used by future page loads. diff --git a/Sources/Kaset/Views/Spotlight/SpotlightAirPlayPickerView.swift b/Sources/Kaset/Views/Spotlight/SpotlightAirPlayPickerView.swift new file mode 100644 index 000000000..56fb31f0b --- /dev/null +++ b/Sources/Kaset/Views/Spotlight/SpotlightAirPlayPickerView.swift @@ -0,0 +1,92 @@ +import AVKit +import SwiftUI + +/// Wireless AirPlay audio output target selection view for Spotlight mode. +struct SpotlightAirPlayPickerView: View { + @Environment(\.dismiss) private var dismiss + + @State private var availableRoutes: [AirPlayRoute] = [ + AirPlayRoute(id: "system", name: "MacBook Pro Speakers", isCurrent: true, type: .builtIn), + AirPlayRoute(id: "homepod_living", name: "Living Room HomePod", isCurrent: false, type: .homePod), + AirPlayRoute(id: "airplay_tv", name: "Apple TV 4K", isCurrent: false, type: .appleTV), + ] + + struct AirPlayRoute: Identifiable { + let id: String + let name: String + var isCurrent: Bool + let type: RouteType + + enum RouteType { + case builtIn + case homePod + case appleTV + case bluetooth + + var iconName: String { + switch self { + case .builtIn: "laptopcomputer" + case .homePod: "homepod.fill" + case .appleTV: "appletv.fill" + case .bluetooth: "headphones" + } + } + } + } + + var body: some View { + VStack(spacing: 20) { + HStack { + Text("Audio Output Target") + .font(.headline) + Spacer() + Button(action: { self.dismiss() }, label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.secondary) + }) + .buttonStyle(.plain) + } + .padding([.top, .horizontal], 20) + + List(self.$availableRoutes) { $route in + HStack(spacing: 12) { + Image(systemName: route.type.iconName) + .font(.title3) + .foregroundStyle(route.isCurrent ? Color.accentColor : Color.secondary) + .frame(width: 24) + + Text(route.name) + .font(.body) + .foregroundStyle(route.isCurrent ? .primary : .secondary) + + Spacer() + + if route.isCurrent { + Image(systemName: "checkmark.circle.fill") + .font(.body) + .foregroundStyle(Color.accentColor) + } + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { + for i in self.availableRoutes.indices { + self.availableRoutes[i].isCurrent = (self.availableRoutes[i].id == route.id) + } + } + } + .listStyle(.sidebar) + + HStack { + Spacer() + Button("Done") { + self.dismiss() + } + .keyboardShortcut(.defaultAction) + } + .padding([.bottom, .horizontal], 20) + } + .frame(width: 380, height: 320) + } +} diff --git a/Sources/Kaset/Views/Spotlight/SpotlightBackdropVisualizerView.swift b/Sources/Kaset/Views/Spotlight/SpotlightBackdropVisualizerView.swift new file mode 100644 index 000000000..4c429a246 --- /dev/null +++ b/Sources/Kaset/Views/Spotlight/SpotlightBackdropVisualizerView.swift @@ -0,0 +1,77 @@ +import Combine +import SwiftUI + +/// Dynamic ambient particle and audio visualizer canvas backdrop for Spotlight presentation mode. +struct SpotlightBackdropVisualizerView: View { + let isPlaying: Bool + let accentColor: Color + + @State private var phase: Double = 0.0 + @State private var barHeights: [CGFloat] = Array(repeating: 0.2, count: 24) + + private let timer = Timer.publish(every: 0.08, on: .main, in: .common).autoconnect() + + var body: some View { + ZStack { + // Ambient Radial Glow Circles + GeometryReader { geometry in + let width = geometry.size.width + let height = geometry.size.height + + Circle() + .fill(self.accentColor.opacity(0.20)) + .frame(width: width * 0.7, height: width * 0.7) + .blur(radius: 60) + .offset( + x: cos(self.phase * 0.5) * (width * 0.15), + y: sin(self.phase * 0.5) * (height * 0.15) + ) + + Circle() + .fill(Color.purple.opacity(0.15)) + .frame(width: width * 0.6, height: width * 0.6) + .blur(radius: 70) + .offset( + x: sin(self.phase * 0.4) * (width * 0.12), + y: cos(self.phase * 0.4) * (height * 0.12) + ) + } + + // Realtime Ambient Waveform Equalizer Canvas + VStack { + Spacer() + HStack(alignment: .bottom, spacing: 6) { + ForEach(0 ..< self.barHeights.count, id: \.self) { index in + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill( + LinearGradient( + colors: [self.accentColor.opacity(0.6), self.accentColor.opacity(0.1)], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: 4, height: max(6, self.barHeights[index] * 120)) + .animation(.easeInOut(duration: 0.1), value: self.barHeights[index]) + } + } + .padding(.bottom, 24) + } + } + .allowsHitTesting(false) + .onReceive(self.timer) { _ in + guard self.isPlaying else { + for i in self.barHeights.indices { + self.barHeights[i] = 0.05 + } + return + } + + self.phase += 0.1 + for i in self.barHeights.indices { + let randomNoise = Double.random(in: 0.15 ... 0.85) + let harmonic = (sin(self.phase + Double(i) * 0.3) + 1.0) * 0.5 + self.barHeights[i] = CGFloat(randomNoise * harmonic) + } + } + } +} diff --git a/Sources/Kaset/Views/Spotlight/SpotlightControlsSection.swift b/Sources/Kaset/Views/Spotlight/SpotlightControlsSection.swift new file mode 100644 index 000000000..c4c3cdfc0 --- /dev/null +++ b/Sources/Kaset/Views/Spotlight/SpotlightControlsSection.swift @@ -0,0 +1,122 @@ +import SwiftUI + +/// Media playback control section for the Spotlight view including seek bar, repeat, shuffle, and volume controls. +struct SpotlightControlsSection: View { + let isPlaying: Bool + let progress: TimeInterval + let duration: TimeInterval + let volume: Double + let isMuted: Bool + let onPlayPause: () -> Void + let onSeek: (TimeInterval) -> Void + let onNext: () -> Void + let onPrevious: () -> Void + let onVolumeChange: (Double) -> Void + let onToggleMute: () -> Void + + @State private var isShuffleActive = false + @State private var repeatState: Int = 0 // 0: off, 1: all, 2: one + + var body: some View { + VStack(spacing: 20) { + // Scrubber Progress Lane + VStack(spacing: 6) { + Slider( + value: Binding( + get: { self.progress }, + set: { newValue in self.onSeek(newValue) } + ), + in: 0 ... max(1, self.duration) + ) + .tint(.primary) + + HStack { + Text(Self.formatTime(self.progress)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + Spacer() + Text("-" + Self.formatTime(max(0, self.duration - self.progress))) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 48) + + // Primary Playback Controls Bar + HStack(spacing: 36) { + // Shuffle button toggle + Button(action: { self.isShuffleActive.toggle() }, label: { + Image(systemName: "shuffle") + .font(.system(size: 18, weight: self.isShuffleActive ? .bold : .regular)) + .foregroundStyle(self.isShuffleActive ? Color.accentColor : Color.secondary) + }) + .buttonStyle(.plain) + + // Previous track + Button(action: self.onPrevious, label: { + Image(systemName: "backward.fill") + .font(.system(size: 24)) + .foregroundStyle(.primary) + }) + .buttonStyle(.plain) + + // Play / Pause button + Button(action: self.onPlayPause, label: { + Image(systemName: self.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.primary) + }) + .buttonStyle(.plain) + + // Next track + Button(action: self.onNext, label: { + Image(systemName: "forward.fill") + .font(.system(size: 24)) + .foregroundStyle(.primary) + }) + .buttonStyle(.plain) + + // Repeat button toggle + Button(action: { self.repeatState = (self.repeatState + 1) % 3 }, label: { + Image(systemName: self.repeatState == 2 ? "repeat.1" : "repeat") + .font(.system(size: 18, weight: self.repeatState > 0 ? .bold : .regular)) + .foregroundStyle(self.repeatState > 0 ? Color.accentColor : Color.secondary) + }) + .buttonStyle(.plain) + } + + // Volume Control Slider Lane + HStack(spacing: 12) { + Button(action: self.onToggleMute) { + Image(systemName: self.isMuted || self.volume == 0 ? "speaker.slash.fill" : "speaker.wave.1.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + Slider( + value: Binding( + get: { self.isMuted ? 0.0 : self.volume }, + set: { newValue in self.onVolumeChange(newValue) } + ), + in: 0.0 ... 1.0 + ) + .tint(.primary) + .frame(width: 140) + + Image(systemName: "speaker.wave.3.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 8) + } + } + + private static func formatTime(_ time: TimeInterval) -> String { + guard time.isFinite, !time.isNaN else { return "0:00" } + let totalSeconds = Int(max(0, time)) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} diff --git a/Sources/Kaset/Views/Spotlight/SpotlightHeaderView.swift b/Sources/Kaset/Views/Spotlight/SpotlightHeaderView.swift new file mode 100644 index 000000000..94194ddbb --- /dev/null +++ b/Sources/Kaset/Views/Spotlight/SpotlightHeaderView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +/// Top navigation header for the Now-Playing Spotlight presentation view. +struct SpotlightHeaderView: View { + let onDismiss: () -> Void + let onAirPlay: () -> Void + + var body: some View { + HStack { + // Left brand pill badge + HStack(spacing: 6) { + Image(systemName: "music.note.house.fill") + .font(.caption) + .foregroundStyle(.secondary) + Text("SPOTLIGHT") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.quaternary, in: Capsule()) + + Spacer() + + // Header Action Buttons + HStack(spacing: 16) { + Button(action: self.onAirPlay) { + Image(systemName: "airplayaudio") + .font(.system(size: 18)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("AirPlay & Wireless Audio") + + Button(action: self.onDismiss) { + Image(systemName: "chevron.down.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Close Spotlight View") + } + } + .padding(.horizontal, 24) + .padding(.top, 16) + } +} diff --git a/Sources/Kaset/Views/Spotlight/SpotlightKeyboardShortcutsView.swift b/Sources/Kaset/Views/Spotlight/SpotlightKeyboardShortcutsView.swift new file mode 100644 index 000000000..a2e045594 --- /dev/null +++ b/Sources/Kaset/Views/Spotlight/SpotlightKeyboardShortcutsView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +/// Keyboard shortcuts cheat sheet overlay view for Spotlight presentation mode. +struct SpotlightKeyboardShortcutsView: View { + @Environment(\.dismiss) private var dismiss + + struct ShortcutItem: Identifiable { + let id = UUID() + let keyCombination: String + let description: String + let category: ShortcutCategory + + enum ShortcutCategory: String, CaseIterable { + case playback = "Playback" + case navigation = "Navigation" + case viewMode = "View Controls" + } + } + + private let shortcuts: [ShortcutItem] = [ + ShortcutItem(keyCombination: "Space", description: "Toggle Play / Pause", category: .playback), + ShortcutItem(keyCombination: "⌘ →", description: "Skip to Next Track", category: .playback), + ShortcutItem(keyCombination: "⌘ ←", description: "Skip to Previous Track", category: .playback), + ShortcutItem(keyCombination: "⌘ ↑", description: "Volume Up (+10%)", category: .playback), + ShortcutItem(keyCombination: "⌘ ↓", description: "Volume Down (-10%)", category: .playback), + ShortcutItem(keyCombination: "M", description: "Toggle Audio Mute", category: .playback), + ShortcutItem(keyCombination: "L", description: "Toggle Synced Lyrics Side Drawer", category: .navigation), + ShortcutItem(keyCombination: "Q", description: "Toggle Up-Next Queue Side Drawer", category: .navigation), + ShortcutItem(keyCombination: "F", description: "Toggle Fullscreen Spotlight View", category: .viewMode), + ShortcutItem(keyCombination: "Esc", description: "Dismiss Spotlight Presentation", category: .viewMode), + ] + + var body: some View { + VStack(spacing: 20) { + // Header + HStack { + Label("Spotlight Keyboard Shortcuts", systemImage: "command") + .font(.headline) + Spacer() + Button(action: { self.dismiss() }, label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.secondary) + }) + .buttonStyle(.plain) + } + .padding([.top, .horizontal], 20) + + // Shortcuts List grouped by Category + List { + ForEach(ShortcutItem.ShortcutCategory.allCases, id: \.self) { category in + Section(header: Text(category.rawValue).font(.caption.weight(.bold))) { + ForEach(self.shortcuts.filter { $0.category == category }) { shortcut in + HStack { + Text(shortcut.description) + .font(.body) + Spacer() + Text(shortcut.keyCombination) + .font(.system(.subheadline, design: .monospaced, weight: .bold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .padding(.vertical, 2) + } + } + } + } + .listStyle(.sidebar) + + HStack { + Spacer() + Button("Done") { + self.dismiss() + } + .keyboardShortcut(.defaultAction) + } + .padding([.bottom, .horizontal], 20) + } + .frame(width: 440, height: 480) + } +} diff --git a/Sources/Kaset/Views/Spotlight/SpotlightLyricsDisplayView.swift b/Sources/Kaset/Views/Spotlight/SpotlightLyricsDisplayView.swift new file mode 100644 index 000000000..48417296e --- /dev/null +++ b/Sources/Kaset/Views/Spotlight/SpotlightLyricsDisplayView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// Line-by-line synced lyrics viewer for Spotlight presentation mode. +struct SpotlightLyricsDisplayView: View { + let lyricsText: String? + let currentTime: TimeInterval + let onSeekToLine: (TimeInterval) -> Void + + @State private var parsedLines: [LyricLine] = [] + + struct LyricLine: Identifiable { + let id = UUID() + let timestamp: TimeInterval + let text: String + } + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 16) { + if self.parsedLines.isEmpty { + if let lyrics = self.lyricsText, !lyrics.isEmpty { + Text(lyrics) + .font(.system(size: 20, weight: .medium)) + .lineSpacing(12) + .multilineTextAlignment(.center) + .padding(24) + } else { + ContentUnavailableView( + "No Lyrics Available", + systemImage: "quote.bubble", + description: Text("Synced lyrics are not available for this track.") + ) + .padding(.top, 48) + } + } else { + ForEach(self.parsedLines) { line in + let isActive = self.isLineActive(line) + Text(line.text) + .font(.system(size: isActive ? 24 : 18, weight: isActive ? .bold : .regular)) + .foregroundStyle(isActive ? Color.primary : Color.secondary.opacity(0.6)) + .scaleEffect(isActive ? 1.05 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isActive) + .multilineTextAlignment(.center) + .id(line.id) + .contentShape(Rectangle()) + .onTapGesture { + self.onSeekToLine(line.timestamp) + } + } + .padding(.vertical, 4) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 32) + } + .onChange(of: self.currentTime) { _, newTime in + if let activeLine = self.parsedLines.last(where: { $0.timestamp <= newTime }) { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(activeLine.id, anchor: .center) + } + } + } + } + .task(id: self.lyricsText) { + self.parseLRCContent() + } + } + + private func isLineActive(_ line: LyricLine) -> Bool { + guard let index = self.parsedLines.firstIndex(where: { $0.id == line.id }) else { return false } + let currentTimestamp = line.timestamp + let nextTimestamp = (index + 1 < self.parsedLines.count) ? self.parsedLines[index + 1].timestamp : Double.infinity + return self.currentTime >= currentTimestamp && self.currentTime < nextTimestamp + } + + private func parseLRCContent() { + guard let rawText = self.lyricsText else { + self.parsedLines = [] + return + } + + var lines: [LyricLine] = [] + let rawLines = rawText.components(separatedBy: .newlines) + + for line in rawLines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("["), trimmed.contains("]") { + let parts = trimmed.split(separator: "]", maxSplits: 1) + if parts.count == 2 { + let timestampString = String(parts[0]).dropFirst() + let lyricString = String(parts[1]).trimmingCharacters(in: .whitespaces) + + if let seconds = Self.parseTimestamp(String(timestampString)) { + lines.append(LyricLine(timestamp: seconds, text: lyricString)) + } + } + } + } + + self.parsedLines = lines.sorted(by: { $0.timestamp < $1.timestamp }) + } + + private static func parseTimestamp(_ timestamp: String) -> TimeInterval? { + let components = timestamp.split(separator: ":") + guard components.count == 2, + let minutes = Double(components[0]), + let seconds = Double(components[1]) else { return nil } + return (minutes * 60.0) + seconds + } +} diff --git a/Sources/Kaset/Views/Spotlight/SpotlightQueueListView.swift b/Sources/Kaset/Views/Spotlight/SpotlightQueueListView.swift new file mode 100644 index 000000000..6a4b2240b --- /dev/null +++ b/Sources/Kaset/Views/Spotlight/SpotlightQueueListView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +/// Reorderable up-next queue manager view for Spotlight presentation mode. +struct SpotlightQueueListView: View { + @Binding var queueSongs: [Song] + let currentSong: Song? + let onSelectSong: (Song) -> Void + let onRemoveSong: (IndexSet) -> Void + let onMoveSong: (IndexSet, Int) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header Stats + HStack { + Text("UP NEXT") + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + Spacer() + Text("\(self.queueSongs.count) tracks") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 20) + .padding(.top, 16) + + List { + // Now Playing Current Track Section + if let song = self.currentSong { + Section(header: Text("NOW PLAYING").font(.caption2)) { + HStack(spacing: 12) { + Image(systemName: "speaker.wave.3.fill") + .foregroundStyle(Color.accentColor) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(song.title) + .font(.body.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .lineLimit(1) + + Text(song.artists.map(\.name).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + } + .padding(.vertical, 4) + } + } + + // Queue Tracks Reorderable Section + Section(header: Text("QUEUE LIST").font(.caption2)) { + ForEach(Array(self.queueSongs.enumerated()), id: \.element.id) { index, song in + HStack(spacing: 12) { + Text("\(index + 1)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(song.title) + .font(.body.weight(.medium)) + .lineLimit(1) + + Text(song.artists.map(\.name).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Image(systemName: "line.3.horizontal") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + self.onSelectSong(song) + } + } + .onDelete(perform: self.onRemoveSong) + .onMove(perform: self.onMoveSong) + } + } + .listStyle(.sidebar) + } + } +} diff --git a/Sources/Kaset/Views/Spotlight/SpotlightSideDrawer.swift b/Sources/Kaset/Views/Spotlight/SpotlightSideDrawer.swift new file mode 100644 index 000000000..0ff622604 --- /dev/null +++ b/Sources/Kaset/Views/Spotlight/SpotlightSideDrawer.swift @@ -0,0 +1,95 @@ +import SwiftUI + +/// Side drawer container inside Spotlight View for switching between Synced Lyrics and Queue tabs. +struct SpotlightSideDrawer: View { + enum Tab: String, CaseIterable, Identifiable { + case lyrics = "Lyrics" + case queue = "Queue" + + var id: String { + self.rawValue + } + + var iconName: String { + switch self { + case .lyrics: "quote.bubble.fill" + case .queue: "list.bullet.rectangle.portrait.fill" + } + } + } + + @Binding var selectedTab: Tab + let isVisible: Bool + let lyricsText: String? + let queueSongs: [Song] + + var body: some View { + if self.isVisible { + VStack(spacing: 16) { + // Segmented tab selector + Picker("Drawer View", selection: self.$selectedTab) { + ForEach(Tab.allCases) { tab in + Label(tab.rawValue, systemImage: tab.iconName) + .tag(tab) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, 16) + .padding(.top, 12) + + // Tab Content Body + switch self.selectedTab { + case .lyrics: + ScrollView { + if let lyrics = self.lyricsText, !lyrics.isEmpty { + Text(lyrics) + .font(.system(size: 18, weight: .medium)) + .lineSpacing(10) + .multilineTextAlignment(.center) + .padding(20) + } else { + ContentUnavailableView( + "No Lyrics Available", + systemImage: "quote.bubble", + description: Text("Lyrics could not be loaded for the current track.") + ) + .padding(.top, 40) + } + } + case .queue: + List { + Section(header: Text("UP NEXT")) { + ForEach(Array(self.queueSongs.enumerated()), id: \.offset) { index, song in + HStack(spacing: 12) { + Text("\(index + 1)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(song.title) + .font(.body.weight(.medium)) + .lineLimit(1) + + Text(song.artists.map(\.name).joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + } + .padding(.vertical, 4) + } + } + } + .listStyle(.sidebar) + } + } + .frame(width: 340) + .compatGlass(in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } +} diff --git a/Sources/Kaset/Views/Spotlight/SpotlightVolumeSliderView.swift b/Sources/Kaset/Views/Spotlight/SpotlightVolumeSliderView.swift new file mode 100644 index 000000000..0f6d1ea7a --- /dev/null +++ b/Sources/Kaset/Views/Spotlight/SpotlightVolumeSliderView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// Custom logarithmic volume slider view with decibel indicators and gain boost toggle. +struct SpotlightVolumeSliderView: View { + @Binding var volume: Double + @Binding var isMuted: Bool + + @State private var isBoostActive = false + + private var decibelText: String { + if self.isMuted || self.volume == 0 { + return "-∞ dB" + } + let db = 20.0 * log10(max(0.001, self.volume)) + return String(format: "%.1f dB", db + (self.isBoostActive ? 3.0 : 0.0)) + } + + var body: some View { + HStack(spacing: 16) { + // Mute Button Toggle + Button(action: { + self.isMuted.toggle() + }, label: { + Image(systemName: self.isMuted || self.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") + .font(.body) + .foregroundStyle(self.isMuted ? Color.red : Color.secondary) + }) + .buttonStyle(.plain) + .help("Mute / Unmute Volume") + + // Slider Lane + VStack(spacing: 4) { + Slider( + value: Binding( + get: { self.isMuted ? 0.0 : self.volume }, + set: { newValue in + self.isMuted = (newValue == 0) + self.volume = newValue + } + ), + in: 0.0 ... 1.0 + ) + .tint(self.isBoostActive ? Color.orange : Color.primary) + + HStack { + Text("0%") + .font(.caption2) + .foregroundStyle(.tertiary) + Spacer() + Text(self.decibelText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + Spacer() + Text(self.isBoostActive ? "125%" : "100%") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .frame(width: 180) + + // Gain Boost Toggle Button + Button(action: { + self.isBoostActive.toggle() + }, label: { + Text("+3dB") + .font(.caption2.weight(.bold)) + .foregroundStyle(self.isBoostActive ? Color.orange : Color.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(self.isBoostActive ? AnyShapeStyle(Color.orange.opacity(0.15)) : AnyShapeStyle(.quaternary), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + }) + .buttonStyle(.plain) + .help("Toggle +3dB Audio Gain Boost") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.ultraThinMaterial, in: Capsule()) + } +} diff --git a/Sources/Kaset/Views/YouTube/YouTubeExploreView.swift b/Sources/Kaset/Views/YouTube/YouTubeExploreView.swift index 149c9fc08..15c9fbab4 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeExploreView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeExploreView.swift @@ -51,7 +51,7 @@ struct YouTubeExploreView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .navigationTitle(Text("Explore", comment: "YouTube explore title")) + .navigationTitle("") .task(id: self.viewModel.selectedDestination) { await self.viewModel.load() } diff --git a/Sources/Kaset/Views/YouTube/YouTubeHistoryView.swift b/Sources/Kaset/Views/YouTube/YouTubeHistoryView.swift index fcbbdd7bf..0dcf7cbf1 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeHistoryView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeHistoryView.swift @@ -31,7 +31,7 @@ struct YouTubeHistoryView: View { } } } - .navigationTitle(Text("History", comment: "YouTube history title")) + .navigationTitle("") // Keyed on the view-model identity so a cold-launch account swap (which // rebuilds the model) re-fires the load instead of leaving the fresh, // idle model stuck. See YouTubeHomeView for the full rationale. diff --git a/Sources/Kaset/Views/YouTube/YouTubeHomeView.swift b/Sources/Kaset/Views/YouTube/YouTubeHomeView.swift index 1fe06c121..b56c93dd3 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeHomeView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeHomeView.swift @@ -46,7 +46,7 @@ struct YouTubeHomeView: View { } } } - .navigationTitle(Text("Home", comment: "YouTube home feed title")) + .navigationTitle("") .accessibilityIdentifier(AccessibilityID.YouTubeContent.homeGrid) // Key on the view-model identity, not a bare `.task`. On cold launch the // account resolves after first paint and `resetForAccountChange()` swaps diff --git a/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift b/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift index ca6a30bb0..2dc8f864f 100644 --- a/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift +++ b/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift @@ -125,19 +125,8 @@ struct YouTubePlayerBar: View { } private var playerAreaFade: some View { - LinearGradient( - colors: [ - Color(nsColor: .windowBackgroundColor).opacity(0), - Color(nsColor: .windowBackgroundColor).opacity(0.22), - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 44) - .frame(maxWidth: .infinity) - .padding(.bottom, -8) - .allowsHitTesting(false) - .accessibilityHidden(true) + LiquidGlassFade(edge: .bottom, height: 135) + .padding(.bottom, -8) } // MARK: - Updated Player Layout diff --git a/Sources/Kaset/Views/YouTube/YouTubePlaylistsView.swift b/Sources/Kaset/Views/YouTube/YouTubePlaylistsView.swift index 5c7efc741..eba29542f 100644 --- a/Sources/Kaset/Views/YouTube/YouTubePlaylistsView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubePlaylistsView.swift @@ -31,7 +31,7 @@ struct YouTubePlaylistsView: View { } } } - .navigationTitle(Text("Playlists", comment: "YouTube playlists title")) + .navigationTitle("") // Keyed on the view-model identity so a cold-launch account swap (which // rebuilds the model) re-fires the load instead of leaving the fresh, // idle model stuck. See YouTubeHomeView for the full rationale. diff --git a/Sources/Kaset/Views/YouTube/YouTubeSearchView.swift b/Sources/Kaset/Views/YouTube/YouTubeSearchView.swift index 8af003013..85883b519 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeSearchView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeSearchView.swift @@ -37,7 +37,7 @@ struct YouTubeSearchView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .navigationTitle(Text("Search", comment: "YouTube search title")) + .navigationTitle("") } // MARK: - Header diff --git a/Sources/Kaset/Views/YouTube/YouTubeShortsView.swift b/Sources/Kaset/Views/YouTube/YouTubeShortsView.swift index 4de6050b8..205fa761c 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeShortsView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeShortsView.swift @@ -41,7 +41,7 @@ struct YouTubeShortsView: View { } } } - .navigationTitle(Text("Shorts", comment: "YouTube Shorts title")) + .navigationTitle("") // Keyed on the view-model identity so a cold-launch account swap (which // rebuilds the model) re-fires the load instead of leaving the fresh, // idle model stuck. See YouTubeHomeView for the full rationale. diff --git a/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift b/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift index ec77618ea..dc77fff7b 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeSidebar.swift @@ -45,6 +45,7 @@ struct YouTubeSidebar: View { .safeAreaInset(edge: .bottom, spacing: 0) { SidebarFooterView() } + .toolbar(removing: .sidebarToggle) .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 300) } diff --git a/Sources/Kaset/Views/YouTube/YouTubeSubscriptionsView.swift b/Sources/Kaset/Views/YouTube/YouTubeSubscriptionsView.swift index f9cc1fb32..3e4784c7c 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeSubscriptionsView.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeSubscriptionsView.swift @@ -30,7 +30,7 @@ struct YouTubeSubscriptionsView: View { self.content } } - .navigationTitle(Text("Subscriptions", comment: "YouTube subscriptions title")) + .navigationTitle("") // Keyed on the view-model identity so a cold-launch account swap (which // rebuilds the model) re-fires the load instead of leaving the fresh, // idle model stuck. See YouTubeHomeView for the full rationale. diff --git a/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift b/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift index a98d28ed0..47064d86c 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeVideoWindowController.swift @@ -601,55 +601,3 @@ private struct YouTubeVideoWindowContent: View { } } } - -// MARK: - WindowDragHandle - -/// Transparent native strip that lets the user move the floating window by -/// dragging along the top. The hosted WebView reports -/// `mouseDownCanMoveWindow == false` and consumes `mouseDown`, defeating the -/// window's `isMovableByWindowBackground` everywhere it covers; this strip sits -/// above the WebView and drives the move explicitly through -/// `NSWindow.performDrag(with:)`. Scoped to the floating window only — the -/// shared `YouTubeWatchSurfaceView` is untouched. -private struct WindowDragHandle: NSViewRepresentable { - func makeNSView(context _: Context) -> NSView { - WindowDragNSView() - } - - func updateNSView(_: NSView, context _: Context) {} -} - -// MARK: - WindowDragNSView - -/// Backing view for `WindowDragHandle`. -private final class WindowDragNSView: NSView { - /// Take `mouseDown` ourselves instead of letting AppKit's background-drag - /// heuristics intercept it, so the move is driven deterministically. - override var mouseDownCanMoveWindow: Bool { - false - } - - /// Drag even when the floating window is not key — it is ordered front - /// without stealing focus, so a first click must move it, not just activate. - override func acceptsFirstMouse(for _: NSEvent?) -> Bool { - true - } - - override func mouseDown(with event: NSEvent) { - // Preserve the standard titlebar gesture: a double-click performs the - // user's configured "double-click a window's title bar to" action - // (Zoom / Minimize / None); a single click starts the window drag. - if event.clickCount == 2 { - switch UserDefaults.standard.string(forKey: "AppleActionOnDoubleClick") { - case "Minimize": - self.window?.miniaturize(nil) - case "None": - break - default: // "Maximize" (zoom) is the macOS default. - self.window?.performZoom(nil) - } - } else { - self.window?.performDrag(with: event) - } - } -} diff --git a/Sources/Kaset/Views/YouTubeSettingsView.swift b/Sources/Kaset/Views/YouTubeSettingsView.swift index c661d2393..b1bc1afc9 100644 --- a/Sources/Kaset/Views/YouTubeSettingsView.swift +++ b/Sources/Kaset/Views/YouTubeSettingsView.swift @@ -26,6 +26,8 @@ struct YouTubeSettingsView: View { Text(String(localized: "A soft color glow drawn from the video plays behind the player. Applies to YouTube videos, not Music.")) .font(.caption) .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) } Section { @@ -40,6 +42,8 @@ struct YouTubeSettingsView: View { Text(String(localized: "When off, navigating back from a playing video stops it instead of opening the floating player. The pop-out and full-view buttons still work.")) .font(.caption) .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) } } .formStyle(.grouped) diff --git a/Tests/KasetTests/AIErrorHandlerMacOS27CompatibilityTests.swift b/Tests/KasetTests/AIErrorHandlerMacOS27CompatibilityTests.swift index e14786e58..a787d9393 100644 --- a/Tests/KasetTests/AIErrorHandlerMacOS27CompatibilityTests.swift +++ b/Tests/KasetTests/AIErrorHandlerMacOS27CompatibilityTests.swift @@ -59,7 +59,6 @@ import Testing debugDescription: "test" ))) == .contentBlocked) #expect(mappedError(LanguageModelError.refusal(.init( - explanation: "test", debugDescription: "test" ))) == .contentBlocked) #expect(mappedError(LanguageModelError.unsupportedLanguageOrLocale(.init( diff --git a/Tests/KasetTests/AudioFaderServiceTests.swift b/Tests/KasetTests/AudioFaderServiceTests.swift new file mode 100644 index 000000000..f0d1b6f00 --- /dev/null +++ b/Tests/KasetTests/AudioFaderServiceTests.swift @@ -0,0 +1,51 @@ +import Testing +@testable import Kaset + +// MARK: - AudioFaderServiceTests + +@MainActor +struct AudioFaderServiceTests { + @Test("Default fader instance is accessible") + func defaultFaderInstance() { + let fader = AudioFaderService.shared + #expect(fader.idForTesting == "AudioFaderService.shared") + } + + @Test("FadeCurve displayName values are non-empty") + func fadeCurveDisplayNames() { + for curve in AudioFaderService.FadeCurve.allCases { + #expect(!curve.displayName.isEmpty) + #expect(!curve.id.isEmpty) + } + } + + @Test("Linear curve calculation preserves linear progression") + func linearCurveCalculation() { + #expect(AudioFaderService.calculateFactor(progress: 0.0, curve: .linear) == 0.0) + #expect(AudioFaderService.calculateFactor(progress: 0.5, curve: .linear) == 0.5) + #expect(AudioFaderService.calculateFactor(progress: 1.0, curve: .linear) == 1.0) + } + + @Test("Logarithmic curve calculation follows acoustic power progression") + func logarithmicCurveCalculation() { + #expect(AudioFaderService.calculateFactor(progress: 0.0, curve: .logarithmic) == 0.0) + let mid = AudioFaderService.calculateFactor(progress: 0.5, curve: .logarithmic) + #expect(mid < 0.3) // Exponential ease-in has low early energy + #expect(AudioFaderService.calculateFactor(progress: 1.0, curve: .logarithmic) == 1.0) + } + + @Test("Nil WebView completes callback immediately without crashing") + func nilWebViewCompletion() { + var completed = false + AudioFaderService.shared.fadeOut(webView: nil) { + completed = true + } + #expect(completed) + } +} + +extension AudioFaderService { + var idForTesting: String { + "AudioFaderService.shared" + } +} diff --git a/Tests/KasetTests/MainWindowLayoutTests.swift b/Tests/KasetTests/MainWindowLayoutTests.swift index 28b7ef74e..b68c72794 100644 --- a/Tests/KasetTests/MainWindowLayoutTests.swift +++ b/Tests/KasetTests/MainWindowLayoutTests.swift @@ -36,4 +36,44 @@ struct MainWindowLayoutTests { #expect(MainWindowLayout.isPrimaryWindowIdentity(title: "Settings", frameAutosaveName: MainWindowLayout.autosaveName)) #expect(!MainWindowLayout.isPrimaryWindowIdentity(title: "Settings", frameAutosaveName: "")) } + + @Test("Configure primary window sets transparent titlebar properties") + @MainActor + func configurePrimaryWindow() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1000, height: 700), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = MainWindowLayout.windowTitle + + MainWindowLayout.configure(window) + + #expect(window.titleVisibility == .hidden) + #expect(window.titlebarAppearsTransparent == true) + #expect(window.titlebarSeparatorStyle == .none) + #expect(window.styleMask.contains(.fullSizeContentView)) + #expect(window.isMovableByWindowBackground == false) + } + + @Test("Restore windowed appearance sets transparent titlebar properties") + @MainActor + func restoreWindowedAppearance() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1000, height: 700), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = MainWindowLayout.windowTitle + + MainWindowLayout.restoreWindowedAppearance(window) + + #expect(window.titleVisibility == .hidden) + #expect(window.titlebarAppearsTransparent == true) + #expect(window.titlebarSeparatorStyle == .none) + #expect(window.styleMask.contains(.fullSizeContentView)) + #expect(window.isMovableByWindowBackground == false) + } } diff --git a/docs/audio/fade.wav b/docs/audio/fade.wav new file mode 100644 index 000000000..6c9fed18d Binary files /dev/null and b/docs/audio/fade.wav differ diff --git a/docs/audio/no-fade.wav b/docs/audio/no-fade.wav new file mode 100644 index 000000000..e003e0527 Binary files /dev/null and b/docs/audio/no-fade.wav differ diff --git a/docs/screenshots/app-icon.png b/docs/screenshots/app-icon.png new file mode 100644 index 000000000..281edb34d Binary files /dev/null and b/docs/screenshots/app-icon.png differ diff --git a/docs/screenshots/homescreen-dark.png b/docs/screenshots/homescreen-dark.png new file mode 100644 index 000000000..8c7958c26 Binary files /dev/null and b/docs/screenshots/homescreen-dark.png differ diff --git a/docs/screenshots/homescreen-legacy.png b/docs/screenshots/homescreen-legacy.png new file mode 100644 index 000000000..e982014aa Binary files /dev/null and b/docs/screenshots/homescreen-legacy.png differ diff --git a/docs/screenshots/homescreen-light.png b/docs/screenshots/homescreen-light.png new file mode 100644 index 000000000..fd16fb8e2 Binary files /dev/null and b/docs/screenshots/homescreen-light.png differ diff --git a/docs/screenshots/jump-back-in-dark.png b/docs/screenshots/jump-back-in-dark.png new file mode 100644 index 000000000..62b361944 Binary files /dev/null and b/docs/screenshots/jump-back-in-dark.png differ diff --git a/docs/screenshots/jump-back-in-light.png b/docs/screenshots/jump-back-in-light.png new file mode 100644 index 000000000..aad78d49b Binary files /dev/null and b/docs/screenshots/jump-back-in-light.png differ diff --git a/docs/screenshots/kaset-demo.gif b/docs/screenshots/kaset-demo.gif new file mode 100644 index 000000000..98501e79a Binary files /dev/null and b/docs/screenshots/kaset-demo.gif differ diff --git a/docs/screenshots/kaset-homescreen-demo.mp4 b/docs/screenshots/kaset-homescreen-demo.mp4 new file mode 100644 index 000000000..69869c966 Binary files /dev/null and b/docs/screenshots/kaset-homescreen-demo.mp4 differ diff --git a/docs/screenshots/playing-card-dark.png b/docs/screenshots/playing-card-dark.png new file mode 100644 index 000000000..e6580e21d Binary files /dev/null and b/docs/screenshots/playing-card-dark.png differ diff --git a/docs/screenshots/playing-card-light.png b/docs/screenshots/playing-card-light.png new file mode 100644 index 000000000..0d9d004a5 Binary files /dev/null and b/docs/screenshots/playing-card-light.png differ diff --git a/docs/screenshots/settings-audio-dark.png b/docs/screenshots/settings-audio-dark.png new file mode 100644 index 000000000..0a5fe8590 Binary files /dev/null and b/docs/screenshots/settings-audio-dark.png differ diff --git a/docs/screenshots/settings-audio-light.png b/docs/screenshots/settings-audio-light.png new file mode 100644 index 000000000..7f05d59d7 Binary files /dev/null and b/docs/screenshots/settings-audio-light.png differ diff --git a/docs/screenshots/topbar-liquid-glass.png b/docs/screenshots/topbar-liquid-glass.png new file mode 100644 index 000000000..6abbb277a Binary files /dev/null and b/docs/screenshots/topbar-liquid-glass.png differ