diff --git a/CHANGELOG.md b/CHANGELOG.md index c22de94..3cfa0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed + +- `@MainActor` isolation for `getCurrentSongInfo()` / `getLastSongInfo()` Twitch bot callbacks β€” replaced `DispatchQueue.main.sync` with `MainActor.assumeIsolated` to satisfy Swift strict concurrency. + ## [1.1.0] - 2026-03-31 ### Added diff --git a/FEATURE_IDEAS.md b/FEATURE_IDEAS.md new file mode 100644 index 0000000..c288e40 --- /dev/null +++ b/FEATURE_IDEAS.md @@ -0,0 +1,75 @@ +# WolfWave β€” Feature Ideas + +Future feature ideas for WolfWave. These are not committed to any release β€” just a running list of things worth building. + +--- + +## Song Request Queue + +Let Twitch viewers request songs via `!request `. Requests appear in a queue visible from the menu bar. Streamer can approve, skip, or clear. + +**Complexity**: Medium +**Builds on**: Existing `BotCommand` protocol, `TwitchChatService` + +--- + +## Listening History & Stats + +Track play counts, listening time, and most-played artists locally. Store in a lightweight SQLite database. Add a "Stats" section to the settings sidebar. + +**Complexity**: Medium +**Builds on**: `MusicPlaybackMonitor` delegate callbacks, existing settings sidebar pattern + +--- + +## Custom Bot Commands + +Let users define their own Twitch bot command responses via the settings UI. For example: `!dj` β†’ "DJ WolfWave in the house 🎧". Stored in UserDefaults as a key-value map. + +**Complexity**: Low +**Builds on**: `BotCommand` protocol, `BotCommandDispatcher.registerDefaultCommands()` + +--- + +## Overlay Themes + +Ship multiple pre-built WebSocket overlay themes (minimal, retro, neon, glassmorphism). Users pick a theme in settings. Overlay clients receive the theme name and render accordingly. + +**Complexity**: Medium +**Builds on**: Existing WebSocket server, browser source overlay + +--- + +## Global Hotkeys + +Global keyboard shortcuts to toggle tracking, skip song, or show/hide the overlay β€” even when WolfWave is in the background. + +**Complexity**: Low–Medium +**Approach**: `CGEvent` tap or a lightweight hotkey library + +--- + +## Last.fm Scrobbling + +Optional Last.fm integration to automatically scrobble tracks as they play. Uses Last.fm API with OAuth device flow (same pattern as Twitch auth). + +**Complexity**: Medium +**Builds on**: `TwitchDeviceAuth` OAuth pattern, `KeychainService` + +--- + +## Menu Bar Now-Playing Ticker + +Optionally scroll the current track name in the macOS menu bar status item, like a marquee. Configurable max width. Falls back to static icon when nothing is playing. + +**Complexity**: Low +**Builds on**: Existing `NSStatusItem` setup in `AppDelegate` + +--- + +## Multi-Platform Chat Support + +Extend the bot beyond Twitch to YouTube Live chat and Kick. Same `BotCommand` protocol, different transport layer per platform. + +**Complexity**: High +**Builds on**: `BotCommand` protocol, `BotCommandDispatcher` diff --git a/apps/docs/content/docs/changelog.mdx b/apps/docs/content/docs/changelog.mdx index 8e6a246..9e2776e 100644 --- a/apps/docs/content/docs/changelog.mdx +++ b/apps/docs/content/docs/changelog.mdx @@ -7,6 +7,26 @@ description: Release history for WolfWave All notable changes to WolfWave are documented here. + +## v1.1.0 β€” March 31, 2026 + +### Added +- **Discord buttons** β€” Rich Presence now shows two clickable buttons: Open in Apple Music (direct track link) and song.link (opens on Spotify, YouTube Music, Tidal, and more) +- **Launch at Login** β€” new toggle in Settings β†’ App Visibility. Uses SMAppService, appears in System Settings β†’ General β†’ Login Items +- **Custom DMG background** β€” installer window has a polished dark background with WolfWave brand colors +- **Homebrew auto-update** β€” GitHub Actions now automatically opens a pull request on the Homebrew tap on new releases + +### Fixed +- iTunes Search API URL encoding β€” track/artist names with &, +, or = no longer break artwork lookups +- Launch at Login toggle now reverts if SMAppService registration fails + +## v1.0.2 β€” March 31, 2026 + +### Fixed +- App icon missing in CI-built releases +- Sparkle updater unable to detect new versions (build number now incremented per release) +- Sparkle initialization race condition fixed + ## v1.0.1 β€” March 30, 2026 ### Changed diff --git a/apps/native/WolfWaveTests/MusicPlaybackMonitorTests.swift b/apps/native/WolfWaveTests/MusicPlaybackMonitorTests.swift index 1833071..a4b3242 100644 --- a/apps/native/WolfWaveTests/MusicPlaybackMonitorTests.swift +++ b/apps/native/WolfWaveTests/MusicPlaybackMonitorTests.swift @@ -1,5 +1,5 @@ // -// MusicPlaybackMonitorTests.swift +// AppleMusicSourceTests.swift // WolfWaveTests // // Created by MrDemonWolf, Inc. on 2/27/26. @@ -8,12 +8,12 @@ import XCTest @testable import WolfWave -final class MusicPlaybackMonitorTests: XCTestCase { - var monitor: MusicPlaybackMonitor! +final class AppleMusicSourceTests: XCTestCase { + var monitor: AppleMusicSource! override func setUp() { super.setUp() - monitor = MusicPlaybackMonitor() + monitor = AppleMusicSource() } override func tearDown() { diff --git a/apps/native/WolfWaveTests/PlaybackSourceManagerTests.swift b/apps/native/WolfWaveTests/PlaybackSourceManagerTests.swift new file mode 100644 index 0000000..40255bd --- /dev/null +++ b/apps/native/WolfWaveTests/PlaybackSourceManagerTests.swift @@ -0,0 +1,86 @@ +// +// PlaybackSourceManagerTests.swift +// WolfWaveTests + +import XCTest +@testable import WolfWave + +final class PlaybackSourceManagerTests: XCTestCase { + + // MARK: - Default Mode + + func testDefaultModeIsAppleMusic() { + let manager = PlaybackSourceManager() + XCTAssertEqual(manager.currentMode, .appleMusic) + } + + func testInvalidPersistedModeFallsBackToAppleMusic() { + UserDefaults.standard.set("invalidMode", forKey: "playbackSourceMode") + let manager = PlaybackSourceManager() + XCTAssertEqual(manager.currentMode, .appleMusic) + } + + // MARK: - Mode Switching + + // MARK: - Delegate Forwarding + + func testDelegateReceivesTrackUpdate() { + let manager = PlaybackSourceManager() + let spy = PlaybackSourceDelegateSpy() + manager.delegate = spy + + // Simulate a source calling back into the manager + manager.playbackSource(MockPlaybackSource(), didUpdateTrack: "Song", artist: "Artist", album: "Album", duration: 200, elapsed: 30) + + XCTAssertTrue(spy.didReceiveTrackUpdate) + XCTAssertEqual(spy.lastTrack, "Song") + XCTAssertEqual(spy.lastArtist, "Artist") + } + + func testDelegateReceivesStatusUpdate() { + let manager = PlaybackSourceManager() + let spy = PlaybackSourceDelegateSpy() + manager.delegate = spy + + manager.playbackSource(MockPlaybackSource(), didUpdateStatus: "No track playing") + + XCTAssertTrue(spy.didReceiveStatusUpdate) + XCTAssertEqual(spy.lastStatus, "No track playing") + } + + // MARK: - updateCheckInterval + + func testUpdateCheckIntervalDoesNotCrashWhenNotTracking() { + let manager = PlaybackSourceManager() + // Should not crash when no active source + manager.updateCheckInterval(10.0) + } +} + +// MARK: - Test Helpers + +private class PlaybackSourceDelegateSpy: PlaybackSourceDelegate { + var didReceiveTrackUpdate = false + var lastTrack: String? + var lastArtist: String? + var didReceiveStatusUpdate = false + var lastStatus: String? + + func playbackSource(_ source: any PlaybackSource, didUpdateTrack track: String, artist: String, album: String, duration: TimeInterval, elapsed: TimeInterval) { + didReceiveTrackUpdate = true + lastTrack = track + lastArtist = artist + } + + func playbackSource(_ source: any PlaybackSource, didUpdateStatus status: String) { + didReceiveStatusUpdate = true + lastStatus = status + } +} + +private class MockPlaybackSource: PlaybackSource { + weak var delegate: PlaybackSourceDelegate? + func startTracking() {} + func stopTracking() {} + func updateCheckInterval(_ interval: TimeInterval) {} +} diff --git a/apps/native/wolfwave.xcodeproj/project.pbxproj b/apps/native/wolfwave.xcodeproj/project.pbxproj index d859531..cfd4596 100644 --- a/apps/native/wolfwave.xcodeproj/project.pbxproj +++ b/apps/native/wolfwave.xcodeproj/project.pbxproj @@ -21,7 +21,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - D40965CA2F0F906C00B292B6 /* WolfWave.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WolfWave.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D40965CA2F0F906C00B292B6 /* WolfWave Dev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "WolfWave Dev.app"; sourceTree = BUILT_PRODUCTS_DIR; }; D4A1B2012F0F906C00B292B6 /* WolfWaveTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WolfWaveTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D4CFGXCC00000000000000F1 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Config.xcconfig; path = wolfwave/Config.xcconfig; sourceTree = ""; }; D4CFGXCC00000000000000F2 /* Config.Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Config.Debug.xcconfig; path = wolfwave/Config.Debug.xcconfig; sourceTree = ""; }; @@ -81,18 +81,27 @@ D4A1B2022F0F906C00B292B6 /* WolfWaveTests */, D40965CB2F0F906C00B292B6 /* Products */, D4CFGXCC00000000000000F1 /* Config.xcconfig */, + D46E2E062F7F05E800089923 /* Recovered References */, ); sourceTree = ""; }; D40965CB2F0F906C00B292B6 /* Products */ = { isa = PBXGroup; children = ( - D40965CA2F0F906C00B292B6 /* WolfWave.app */, + D40965CA2F0F906C00B292B6 /* WolfWave Dev.app */, D4A1B2012F0F906C00B292B6 /* WolfWaveTests.xctest */, ); name = Products; sourceTree = ""; }; + D46E2E062F7F05E800089923 /* Recovered References */ = { + isa = PBXGroup; + children = ( + D4CFGXCC00000000000000F2 /* Config.Debug.xcconfig */, + ); + name = "Recovered References"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -116,7 +125,7 @@ D4FB5E3E2F6808870085EB41 /* Sparkle */, ); productName = WolfWave; - productReference = D40965CA2F0F906C00B292B6 /* WolfWave.app */; + productReference = D40965CA2F0F906C00B292B6 /* WolfWave Dev.app */; productType = "com.apple.product-type.application"; }; D4A1B2082F0F906C00B292B6 /* WolfWaveTests */ = { @@ -363,7 +372,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = HBB7T99U79; ENABLE_APP_SANDBOX = YES; @@ -394,7 +403,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.mrdemonwolf.wolfwave.dev; PRODUCT_MODULE_NAME = WolfWave; PRODUCT_NAME = "WolfWave Dev"; @@ -424,7 +433,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = HBB7T99U79; ENABLE_APP_SANDBOX = YES; @@ -455,7 +464,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.mrdemonwolf.wolfwave; PRODUCT_NAME = WolfWave; REGISTER_APP_GROUPS = YES; @@ -479,12 +488,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = HBB7T99U79; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.mrdemonwolf.wolfwave.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -501,12 +510,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = HBB7T99U79; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.mrdemonwolf.wolfwave.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_APPROACHABLE_CONCURRENCY = YES; diff --git a/apps/native/wolfwave/Core/AppConstants.swift b/apps/native/wolfwave/Core/AppConstants.swift index eece069..bfcb3ec 100644 --- a/apps/native/wolfwave/Core/AppConstants.swift +++ b/apps/native/wolfwave/Core/AppConstants.swift @@ -65,6 +65,7 @@ enum AppConstants { /// Posted when Twitch chat connection state changes. UserInfo contains "isConnected" Bool. static let twitchConnectionStateChanged = "TwitchChatConnectionStateChanged" + } // MARK: - UserDefaults Keys @@ -151,6 +152,7 @@ enum AppConstants { /// Whether the widget HTTP server is enabled (Bool, default: false) static let widgetHTTPEnabled = "widgetHTTPEnabled" + } // MARK: - Dock Visibility Modes @@ -388,6 +390,8 @@ enum AppConstants { /// Queue for WebSocket server operations static let websocketServer = "com.mrdemonwolf.wolfwave.websocketserver" + + static let systemNowPlaying = "com.mrdemonwolf.wolfwave.systemnowplaying" } // MARK: - UI Dimensions diff --git a/apps/native/wolfwave/Monitors/MusicPlaybackMonitor.swift b/apps/native/wolfwave/Monitors/AppleMusicSource.swift similarity index 57% rename from apps/native/wolfwave/Monitors/MusicPlaybackMonitor.swift rename to apps/native/wolfwave/Monitors/AppleMusicSource.swift index d04dd23..9edc605 100644 --- a/apps/native/wolfwave/Monitors/MusicPlaybackMonitor.swift +++ b/apps/native/wolfwave/Monitors/AppleMusicSource.swift @@ -1,61 +1,27 @@ -// -// MusicPlaybackMonitor.swift -// wolfwave -// -// Created by MrDemonWolf, Inc. on 1/17/26. -// - import Foundation import AppKit import ScriptingBridge -/// Delegate protocol for receiving music playback updates. -protocol MusicPlaybackMonitorDelegate: AnyObject { - /// Called when a new track starts playing. - func musicPlaybackMonitor(_ monitor: MusicPlaybackMonitor, didUpdateTrack track: String, artist: String, album: String, duration: TimeInterval, elapsed: TimeInterval) - - /// Called when the playback status changes (not running, not playing, etc.). - func musicPlaybackMonitor(_ monitor: MusicPlaybackMonitor, didUpdateStatus status: String) -} +class AppleMusicSource: PlaybackSource { -/// Monitors Apple Music playback using ScriptingBridge and distributed notifications. -/// -/// Uses ScriptingBridge to communicate with Music.app directly without spawning osascript. -/// Subscribes to distributed notifications for real-time updates. -/// Delegate callbacks are delivered on the main thread. -/// -/// Usage: -/// ```swift -/// let monitor = MusicPlaybackMonitor() -/// monitor.delegate = self -/// monitor.startTracking() -/// ``` -/// -/// Requirements: -/// - Entitlements: `com.apple.security.automation.apple-events` -/// - Info.plist: `NSAppleEventsUsageDescription` -class MusicPlaybackMonitor { - private enum Constants { static let musicBundleIdentifier = "com.apple.Music" static let notificationName = "com.apple.Music.playerInfo" static let queueLabel = "com.mrdemonwolf.wolfwave.musicplaybackmonitor" - // Fallback polling interval. Distributed notifications handle real-time - // changes; this only catches missed events, so 5s is sufficient. static let checkInterval: TimeInterval = 5.0 static let trackSeparator = " | " static let notificationDedupWindow: TimeInterval = 0.75 static let idleGraceWindow: TimeInterval = 2.0 static let playerStatePlaying: UInt32 = 1800426320 - + enum Status { static let notRunning = "NOT_RUNNING" static let notPlaying = "NOT_PLAYING" static let errorPrefix = "ERROR:" } } - - weak var delegate: MusicPlaybackMonitorDelegate? + + weak var delegate: PlaybackSourceDelegate? private var currentCheckInterval: TimeInterval = Constants.checkInterval private var timer: DispatchSourceTimer? @@ -65,37 +31,25 @@ class MusicPlaybackMonitor { private var isTracking = false private var pendingDuration: TimeInterval = 0 private var pendingElapsed: TimeInterval = 0 - - private let backgroundQueue = DispatchQueue( - label: Constants.queueLabel, - qos: .utility - ) - + + private let backgroundQueue = DispatchQueue(label: Constants.queueLabel, qos: .utility) + func startTracking() { guard !isTracking else { return } isTracking = true - subscribeToMusicNotifications() performInitialTrackCheck() setupFallbackTimer() } - + func stopTracking() { - guard isTracking else { - return - } + guard isTracking else { return } DistributedNotificationCenter.default().removeObserver(self) timer?.cancel() timer = nil isTracking = false } - /// Updates the fallback polling interval and reschedules the timer. - /// - /// Distributed notifications still provide real-time updates; this only - /// affects how often the fallback timer fires to catch missed events. - /// - /// - Parameter interval: New polling interval in seconds. func updateCheckInterval(_ interval: TimeInterval) { guard isTracking else { return } currentCheckInterval = max(interval, 1.0) @@ -103,27 +57,21 @@ class MusicPlaybackMonitor { timer = nil setupFallbackTimer() } - + @objc private func musicPlayerInfoChanged(_ notification: Notification) { let now = Date() - guard now.timeIntervalSince(lastNotificationAt) >= Constants.notificationDedupWindow else { - return - } + guard now.timeIntervalSince(lastNotificationAt) >= Constants.notificationDedupWindow else { return } lastNotificationAt = now - Log.debug("MusicPlaybackMonitor: Music notification received", category: "Music") + Log.debug("AppleMusicSource: Music notification received", category: "Music") scheduleTrackCheck(reason: "notification") } - + private func checkCurrentTrack() { - let isRunning = NSRunningApplication - .runningApplications(withBundleIdentifier: Constants.musicBundleIdentifier) - .first != nil - + let isRunning = NSRunningApplication.runningApplications(withBundleIdentifier: Constants.musicBundleIdentifier).first != nil guard isRunning else { handleTrackInfo(Constants.Status.notRunning) return } - guard let musicApp = SBApplication(bundleIdentifier: Constants.musicBundleIdentifier) else { notifyDelegate(status: "No track info") return @@ -132,17 +80,12 @@ class MusicPlaybackMonitor { notifyDelegate(status: "No track info") return } - - // Step 4: Check if currently playing - // playerState returns a FourCharCode: 'kPSP' (0x6b505350 = 1800426320) = playing let isPlaying: Bool if let stateNum = stateObj as? NSNumber { isPlaying = (stateNum.uint32Value == Constants.playerStatePlaying) } else { isPlaying = false } - - // Step 5: If playing, retrieve track information if isPlaying { if let track = musicApp.value(forKey: "currentTrack") as? SBObject { let name = track.value(forKey: "name") as? String ?? "" @@ -150,7 +93,6 @@ class MusicPlaybackMonitor { let album = track.value(forKey: "album") as? String ?? "" let duration = (track.value(forKey: "duration") as? Double) ?? 0 let elapsed = (musicApp.value(forKey: "playerPosition") as? Double) ?? 0 - let combined = name + Constants.trackSeparator + artist + Constants.trackSeparator + album + Constants.trackSeparator + String(duration) + Constants.trackSeparator + String(elapsed) handleTrackInfo(combined) } else { @@ -160,46 +102,34 @@ class MusicPlaybackMonitor { handleTrackInfo(Constants.Status.notPlaying) } } - - // MARK: - Delegate Notifications - - /// Notifies the delegate of a status change on the main thread. + private func notifyDelegate(status: String) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - self.delegate?.musicPlaybackMonitor(self, didUpdateStatus: status) + self.delegate?.playbackSource(self, didUpdateStatus: status) } } - - /// Notifies the delegate of a track change on the main thread. + private func notifyDelegate(track: String, artist: String, album: String, duration: TimeInterval, elapsed: TimeInterval) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - self.delegate?.musicPlaybackMonitor(self, didUpdateTrack: track, artist: artist, album: album, duration: duration, elapsed: elapsed) + self.delegate?.playbackSource(self, didUpdateTrack: track, artist: artist, album: album, duration: duration, elapsed: elapsed) } } - - // MARK: - Track Info Processing - - /// Processes track info string received from Music.app and notifies the delegate. + private func processTrackInfoString(_ trackInfo: String) { let components = trackInfo.components(separatedBy: Constants.trackSeparator) - guard components.count >= 3 else { - return - } - + guard components.count >= 3 else { return } let trackName = components[0] let artist = components[1] let album = components[2] let duration = components.count > 3 ? (Double(components[3]) ?? 0) : 0 let elapsed = components.count > 4 ? (Double(components[4]) ?? 0) : 0 - lastTrackSeenAt = Date() notifyDelegate(track: trackName, artist: artist, album: album, duration: duration, elapsed: elapsed) logTrackIfNew(trackInfo, trackName: trackName, artist: artist, album: album) } - - /// Handles track info string and routes to appropriate handler based on status. + private func handleTrackInfo(_ trackInfo: String) { if trackInfo.hasPrefix(Constants.Status.errorPrefix) { notifyDelegate(status: "Script error") @@ -211,8 +141,7 @@ class MusicPlaybackMonitor { processTrackInfoString(trackInfo) } } - - /// Handles the "not playing" state with grace period to avoid transient stops. + private func handleNotPlayingState() { let idleDuration = Date().timeIntervalSince(lastTrackSeenAt) if idleDuration < Constants.idleGraceWindow { @@ -221,36 +150,22 @@ class MusicPlaybackMonitor { } notifyDelegate(status: "No track playing") } - - /// Logs track information if it's different from the last logged track. + private func logTrackIfNew(_ trackInfo: String, trackName: String, artist: String, album: String) { let dedupKey = trackName + Constants.trackSeparator + artist + Constants.trackSeparator + album guard lastLoggedTrack != dedupKey else { return } - Log.debug("MusicPlaybackMonitor: Now Playing β†’ \(trackName) β€” \(artist) [\(album)]", category: "Music") + Log.debug("AppleMusicSource: Now Playing β†’ \(trackName) β€” \(artist) [\(album)]", category: "Music") lastLoggedTrack = dedupKey } - - // MARK: - Setup & Scheduling - /// Subscribes to distributed notifications from Music.app. private func subscribeToMusicNotifications() { - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(musicPlayerInfoChanged), - name: NSNotification.Name(Constants.notificationName), - object: nil - ) + DistributedNotificationCenter.default().addObserver(self, selector: #selector(musicPlayerInfoChanged), name: NSNotification.Name(Constants.notificationName), object: nil) } - - /// Performs an initial track check when monitoring starts. + private func performInitialTrackCheck() { scheduleTrackCheck(reason: "initial") } - - /// Sets up a fallback timer that periodically checks track status. - /// - /// The timer acts as a fallback in case distributed notifications are missed. - /// It fires every `checkInterval` seconds on the background queue. + private func setupFallbackTimer() { let timer = DispatchSource.makeTimerSource(queue: backgroundQueue) timer.schedule(deadline: .now() + currentCheckInterval, repeating: currentCheckInterval) @@ -262,28 +177,13 @@ class MusicPlaybackMonitor { self.timer = timer } - /// Schedules an immediate track check on the background queue. - /// - Parameter reason: A descriptive reason for logging purposes (e.g., "notification", "timer"). private func scheduleTrackCheck(reason: String) { - backgroundQueue.async { [weak self] in - self?.checkCurrentTrack() - } + backgroundQueue.async { [weak self] in self?.checkCurrentTrack() } } - /// Schedules a delayed track check on the background queue. - /// - Parameters: - /// - delay: The delay in seconds before executing the check. - /// - reason: A descriptive reason for logging purposes (e.g., "idle-grace-recheck"). private func scheduleTrackCheck(after delay: TimeInterval, reason: String) { - backgroundQueue.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.checkCurrentTrack() - } - } - - // MARK: - Lifecycle - - /// Automatically stops tracking when the monitor is deallocated. - deinit { - stopTracking() + backgroundQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.checkCurrentTrack() } } + + deinit { stopTracking() } } diff --git a/apps/native/wolfwave/Monitors/PlaybackSource.swift b/apps/native/wolfwave/Monitors/PlaybackSource.swift new file mode 100644 index 0000000..2ecd324 --- /dev/null +++ b/apps/native/wolfwave/Monitors/PlaybackSource.swift @@ -0,0 +1,33 @@ +import Foundation + +// MARK: - PlaybackSourceMode + +/// The user's chosen music source mode. +enum PlaybackSourceMode: String { + case appleMusic = "appleMusic" +} + +// MARK: - PlaybackSourceDelegate + +/// Delegate protocol for receiving playback updates from any music source. +protocol PlaybackSourceDelegate: AnyObject { + func playbackSource( + _ source: any PlaybackSource, + didUpdateTrack track: String, + artist: String, + album: String, + duration: TimeInterval, + elapsed: TimeInterval + ) + func playbackSource(_ source: any PlaybackSource, didUpdateStatus status: String) +} + +// MARK: - PlaybackSource + +/// Contract that every music source must satisfy. +protocol PlaybackSource: AnyObject { + var delegate: PlaybackSourceDelegate? { get set } + func startTracking() + func stopTracking() + func updateCheckInterval(_ interval: TimeInterval) +} diff --git a/apps/native/wolfwave/Monitors/PlaybackSourceManager.swift b/apps/native/wolfwave/Monitors/PlaybackSourceManager.swift new file mode 100644 index 0000000..73211f8 --- /dev/null +++ b/apps/native/wolfwave/Monitors/PlaybackSourceManager.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Manages the active music playback source and switches between them based on user preference. +/// +/// AppDelegate owns a single PlaybackSourceManager and interacts with it instead of +/// individual sources directly. The manager persists the chosen mode to UserDefaults +/// and handles clean start/stop transitions when switching. +class PlaybackSourceManager: PlaybackSourceDelegate { + + // MARK: - Properties + + /// The delegate that receives forwarded playback callbacks. + weak var delegate: PlaybackSourceDelegate? + + /// The currently active playback mode. + private(set) var currentMode: PlaybackSourceMode + + private lazy var appleMusicSource = AppleMusicSource() + private var activeSource: (any PlaybackSource)? + private var isTracking = false + + // MARK: - Init + + init() { + currentMode = .appleMusic + } + + // MARK: - Public Methods + + /// Starts tracking with the current mode's source. + func startTracking() { + stopTracking() + appleMusicSource.delegate = self + activeSource = appleMusicSource + isTracking = true + appleMusicSource.startTracking() + } + + /// Stops the active source. + func stopTracking() { + activeSource?.stopTracking() + appleMusicSource.delegate = nil + activeSource = nil + isTracking = false + } + + /// Updates the fallback polling interval on the active source. + func updateCheckInterval(_ interval: TimeInterval) { + activeSource?.updateCheckInterval(interval) + } + + // MARK: - PlaybackSourceDelegate (forwarding) + + func playbackSource(_ source: any PlaybackSource, didUpdateTrack track: String, artist: String, album: String, duration: TimeInterval, elapsed: TimeInterval) { + delegate?.playbackSource(source, didUpdateTrack: track, artist: artist, album: album, duration: duration, elapsed: elapsed) + } + + func playbackSource(_ source: any PlaybackSource, didUpdateStatus status: String) { + delegate?.playbackSource(source, didUpdateStatus: status) + } +} diff --git a/apps/native/wolfwave/Services/Discord/DiscordRPCService.swift b/apps/native/wolfwave/Services/Discord/DiscordRPCService.swift index 960bf72..c2a45b5 100644 --- a/apps/native/wolfwave/Services/Discord/DiscordRPCService.swift +++ b/apps/native/wolfwave/Services/Discord/DiscordRPCService.swift @@ -296,8 +296,8 @@ final class DiscordRPCService: @unchecked Sendable { /// - track: Song title. /// - artist: Artist name. /// - album: Album name (used as large image tooltip). - /// - artworkURL: Optional iTunes artwork URL. If nil, falls back to the - /// static "apple_music" asset uploaded in the Discord Developer Portal. + /// - artworkURL: Optional iTunes artwork URL. If nil, falls back to a source-specific + /// asset uploaded in the Discord Developer Portal. /// - duration: Total track duration in seconds (0 if unknown). /// - elapsed: Elapsed time in seconds (0 if unknown). private func sendPresenceActivity( @@ -316,17 +316,14 @@ final class DiscordRPCService: @unchecked Sendable { "state": "by \(artist)", ] - // Assets β€” prefer dynamic artwork URL from iTunes, fall back to static asset + // Assets β€” prefer dynamic artwork URL, fall back to Apple Music Discord asset let largeImage = artworkURL ?? "apple_music" var assets: [String: Any] = [ "large_image": largeImage, "large_text": album, ] - // Show Apple Music branding as small icon when we have album art - if artworkURL != nil { - assets["small_image"] = "apple_music" - assets["small_text"] = "Apple Music" - } + assets["small_image"] = "apple_music" + assets["small_text"] = "Apple Music" activity["assets"] = assets // Timestamps β€” show a progress bar if duration is known @@ -340,7 +337,7 @@ final class DiscordRPCService: @unchecked Sendable { ] } - // Buttons β€” shown on the Discord profile card (max 2) + // Buttons β€” "Open in Apple Music" and song.link when available var buttons: [[String: String]] = [] if let appleMusicURL { buttons.append(["label": "Open in Apple Music", "url": appleMusicURL]) diff --git a/apps/native/wolfwave/Views/MusicMonitor/MusicMonitorSettingsView.swift b/apps/native/wolfwave/Views/MusicMonitor/MusicMonitorSettingsView.swift index e7149ba..e2e6ec2 100644 --- a/apps/native/wolfwave/Views/MusicMonitor/MusicMonitorSettingsView.swift +++ b/apps/native/wolfwave/Views/MusicMonitor/MusicMonitorSettingsView.swift @@ -42,7 +42,7 @@ struct MusicMonitorSettingsView: View { .sectionSubHeader() .accessibilityLabel("Music Playback Monitor") - Text("Connects to Apple Music and shares what's playing everywhere.") + Text("Detects what's playing and shares it everywhere.") .font(.system(size: 13)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/native/wolfwave/Views/Onboarding/OnboardingWelcomeStepView.swift b/apps/native/wolfwave/Views/Onboarding/OnboardingWelcomeStepView.swift index 988162c..c121ea7 100644 --- a/apps/native/wolfwave/Views/Onboarding/OnboardingWelcomeStepView.swift +++ b/apps/native/wolfwave/Views/Onboarding/OnboardingWelcomeStepView.swift @@ -27,7 +27,7 @@ struct OnboardingWelcomeStepView: View { Feature( icon: .brand(name: "AppleMusicLogo", renderOriginal: true), title: "Music Sync", - description: "Automatically detects what's playing in Apple Music." + description: "Detects what's playing in Apple Music." ), Feature( icon: .brand(name: "TwitchLogo", renderOriginal: true), diff --git a/apps/native/wolfwave/Views/Shared/WhatsNewView.swift b/apps/native/wolfwave/Views/Shared/WhatsNewView.swift index fdfd2f4..789d8b7 100644 --- a/apps/native/wolfwave/Views/Shared/WhatsNewView.swift +++ b/apps/native/wolfwave/Views/Shared/WhatsNewView.swift @@ -21,12 +21,12 @@ struct WhatsNewView: View { private static let discordIndigo = Color(red: 0.35, green: 0.40, blue: 0.95) private let features: [(icon: String, iconColor: Color, title: String, description: String)] = [ - ("apple.logo", .primary, "macOS Tahoe", "Built exclusively for macOS 26"), - ("cpu", .blue, "Apple Silicon Only", "Optimized for M-series chips"), - ("shield.checkered", .green, "Security Hardened", "Tighter entitlements and token validation"), - ("figure.stand", twitchPurple, "Accessibility", "Full VoiceOver support across all settings"), - ("swift", .orange, "Modern Swift", "Async/await, @Observable, and actor isolation"), - ("testtube.2", .mint, "Better Testing", "End-to-end test coverage for all major flows") + ("music.note.2", .pink, "Discord Buttons", "Open in Apple Music or jump to song.link from your Discord status"), + ("arrow.up.right.circle", .blue, "Launch at Login", "WolfWave starts automatically when your Mac does"), + ("sparkle", twitchPurple, "Homebrew Auto-Update", "Homebrew tap stays in sync whenever a new release ships"), + ("rectangle.and.arrow.up.right.and.arrow.down.left", .green, "Custom DMG", "Polished installer background with WolfWave branding"), + ("paintbrush", discordIndigo, "Artwork & Links", "Album art and song.link resolved automatically for every track"), + ("checkmark.shield", .orange, "Stability", "Tighter entitlements, fixed Sparkle updates, and smarter reconnects") ] // MARK: - Body diff --git a/apps/native/wolfwave/WolfWaveApp.swift b/apps/native/wolfwave/WolfWaveApp.swift index 814325a..8cc7303 100644 --- a/apps/native/wolfwave/WolfWaveApp.swift +++ b/apps/native/wolfwave/WolfWaveApp.swift @@ -40,7 +40,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele // MARK: - Properties var statusItem: NSStatusItem? - var musicMonitor: MusicPlaybackMonitor? + var playbackSourceManager: PlaybackSourceManager? var settingsWindow: NSWindow? var onboardingWindow: NSWindow? var whatsNewWindow: NSWindow? @@ -57,7 +57,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele private var currentElapsed: TimeInterval = 0 private var lastSong: String? private var lastArtist: String? - private var currentDockVisibilityMode: String { UserDefaults.standard.string(forKey: AppConstants.UserDefaults.dockVisibility) ?? AppConstants.DockVisibility.default @@ -186,12 +185,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele @objc func trackingSettingChanged(_ notification: Notification) { guard let enabled = notification.userInfo?["enabled"] as? Bool else { return } - enabled ? musicMonitor?.startTracking() : stopTrackingAndUpdate() + enabled ? playbackSourceManager?.startTracking() : stopTrackingAndUpdate() } /// Stops the music monitor and clears the now-playing display. private func stopTrackingAndUpdate() { - musicMonitor?.stopTracking() + playbackSourceManager?.stopTracking() postNowPlayingUpdate(song: nil, artist: nil, album: nil) } @@ -374,7 +373,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele guard isMusicAppOpen() else { return "🐺 Please open Apple Music" } - guard let song = currentSong, let artist = currentArtist else { return "🐺 No music playing" } @@ -386,10 +384,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele guard isMusicAppOpen() else { return "🐺 Please open Apple Music" } - guard let song = lastSong, let artist = lastArtist else { return "🐺 No previous tracks yet, keep the music flowing!" - } + } return "🐺 Previous: \(song) by \(artist)" } @@ -686,7 +683,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele @objc func powerStateChanged(_ notification: Notification) { let reduced = PowerStateMonitor.shared.isReducedMode - musicMonitor?.updateCheckInterval( + playbackSourceManager?.updateCheckInterval( reduced ? AppConstants.PowerManagement.reducedMusicCheckInterval : 5.0 ) discordService?.updatePollInterval( @@ -703,10 +700,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele // MARK: - Service Initialization - /// Creates the music playback monitor and sets this delegate. + /// Creates the playback source manager and sets this delegate. private func setupMusicMonitor() { - musicMonitor = MusicPlaybackMonitor() - musicMonitor?.delegate = self + playbackSourceManager = PlaybackSourceManager() + playbackSourceManager?.delegate = self } /// Creates the Twitch chat service and wires up song info callbacks. @@ -719,21 +716,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele twitchService?.getCurrentSongInfo = { [weak self] in if Thread.isMainThread { - return self?.getCurrentSongInfo() ?? "Nothing playing right now" + return MainActor.assumeIsolated { self?.getCurrentSongInfo() ?? "Nothing playing right now" } } var result = "Nothing playing right now" DispatchQueue.main.sync { - result = self?.getCurrentSongInfo() ?? "Nothing playing right now" + result = MainActor.assumeIsolated { self?.getCurrentSongInfo() ?? "Nothing playing right now" } } return result } twitchService?.getLastSongInfo = { [weak self] in if Thread.isMainThread { - return self?.getLastSongInfo() ?? "No previous track yet" + return MainActor.assumeIsolated { self?.getLastSongInfo() ?? "No previous track yet" } } var result = "No previous track yet" DispatchQueue.main.sync { - result = self?.getLastSongInfo() ?? "No previous track yet" + result = MainActor.assumeIsolated { self?.getLastSongInfo() ?? "No previous track yet" } } return result } @@ -921,6 +918,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele self?.handleUpdateStateChanged(notification) } ) + } // MARK: - Tracking State @@ -932,7 +930,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSMenuDele } if isTrackingEnabled() { - musicMonitor?.startTracking() + playbackSourceManager?.startTracking() } else { postNowPlayingUpdate(song: nil, artist: nil, album: nil) } @@ -1183,12 +1181,12 @@ extension AppDelegate { } } -// MARK: - Music Playback Monitor Delegate +// MARK: - Playback Source Delegate -extension AppDelegate: MusicPlaybackMonitorDelegate { +extension AppDelegate: PlaybackSourceDelegate { /// Updates track history, broadcasts to all services, and fetches artwork. - func musicPlaybackMonitor( - _ monitor: MusicPlaybackMonitor, didUpdateTrack track: String, artist: String, album: String, duration: TimeInterval, elapsed: TimeInterval + func playbackSource( + _ source: any PlaybackSource, didUpdateTrack track: String, artist: String, album: String, duration: TimeInterval, elapsed: TimeInterval ) { if currentSong != track { lastSong = currentSong @@ -1223,7 +1221,7 @@ extension AppDelegate: MusicPlaybackMonitorDelegate { } /// Clears track state and notifies services when playback stops. - func musicPlaybackMonitor(_ monitor: MusicPlaybackMonitor, didUpdateStatus status: String) { + func playbackSource(_ source: any PlaybackSource, didUpdateStatus status: String) { if status == "No track playing" { currentSong = nil currentArtist = nil diff --git a/discord-assets/README.md b/discord-assets/README.md new file mode 100644 index 0000000..c9c732e --- /dev/null +++ b/discord-assets/README.md @@ -0,0 +1,27 @@ +# Discord Rich Presence Assets + +Upload these PNG files to your Discord Developer Portal as Rich Presence Art Assets. + +## How to upload + +1. Go to https://discord.com/developers/applications +2. Select your WolfWave application +3. Click **Rich Presence** β†’ **Art Assets** +4. Upload each file below with the exact asset name shown + +## Assets + +| File | Asset Name | Description | Status | +|------|-----------|-------------|--------| +| `apple_music.png` | `apple_music` | Apple Music logo | Already uploaded βœ… | +| `spotify.png` | `spotify` | Spotify logo | **Upload needed** ⬆️ | +| `youtube.png` | `youtube` | YouTube/browser icon | **Upload needed** ⬆️ | +| `music_generic.png` | `music_generic` | Generic music fallback | **Upload needed** ⬆️ | + +## Notes + +- `spotify.png` is a **placeholder** (solid Spotify green). Replace it with the official Spotify logo from https://developer.spotify.com/documentation/design#using-our-logo +- `youtube.png` was extracted from your installed Chrome app icon β€” replace with YouTube's official logo from https://www.youtube.com/howyoutubeworks/resources/brand-resources/ if you prefer +- `music_generic.png` is a placeholder dark square β€” replace with any music note icon you like +- All assets should be at least **512Γ—512px PNG** (square) +- Asset names must match **exactly** (lowercase, underscores) β€” the app references them by these exact names diff --git a/discord-assets/music_generic.png b/discord-assets/music_generic.png new file mode 100644 index 0000000..bf57c09 Binary files /dev/null and b/discord-assets/music_generic.png differ diff --git a/discord-assets/spotify.png b/discord-assets/spotify.png new file mode 100644 index 0000000..5289e86 Binary files /dev/null and b/discord-assets/spotify.png differ diff --git a/discord-assets/youtube.png b/discord-assets/youtube.png new file mode 100644 index 0000000..5fff448 Binary files /dev/null and b/discord-assets/youtube.png differ