Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions FEATURE_IDEAS.md
Original file line number Diff line number Diff line change
@@ -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 <song name>`. 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`
20 changes: 20 additions & 0 deletions apps/docs/content/docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions apps/native/WolfWaveTests/MusicPlaybackMonitorTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// MusicPlaybackMonitorTests.swift
// AppleMusicSourceTests.swift
// WolfWaveTests
//
// Created by MrDemonWolf, Inc. on 2/27/26.
Expand All @@ -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() {
Expand Down
86 changes: 86 additions & 0 deletions apps/native/WolfWaveTests/PlaybackSourceManagerTests.swift
Original file line number Diff line number Diff line change
@@ -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) {}
}
31 changes: 20 additions & 11 deletions apps/native/wolfwave.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<group>"; };
D4CFGXCC00000000000000F2 /* Config.Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Config.Debug.xcconfig; path = wolfwave/Config.Debug.xcconfig; sourceTree = "<group>"; };
Expand Down Expand Up @@ -81,18 +81,27 @@
D4A1B2022F0F906C00B292B6 /* WolfWaveTests */,
D40965CB2F0F906C00B292B6 /* Products */,
D4CFGXCC00000000000000F1 /* Config.xcconfig */,
D46E2E062F7F05E800089923 /* Recovered References */,
);
sourceTree = "<group>";
};
D40965CB2F0F906C00B292B6 /* Products */ = {
isa = PBXGroup;
children = (
D40965CA2F0F906C00B292B6 /* WolfWave.app */,
D40965CA2F0F906C00B292B6 /* WolfWave Dev.app */,
D4A1B2012F0F906C00B292B6 /* WolfWaveTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
D46E2E062F7F05E800089923 /* Recovered References */ = {
isa = PBXGroup;
children = (
D4CFGXCC00000000000000F2 /* Config.Debug.xcconfig */,
);
name = "Recovered References";
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */
Expand All @@ -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 */ = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions apps/native/wolfwave/Core/AppConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ enum AppConstants {

/// Posted when Twitch chat connection state changes. UserInfo contains "isConnected" Bool.
static let twitchConnectionStateChanged = "TwitchChatConnectionStateChanged"

}

// MARK: - UserDefaults Keys
Expand Down Expand Up @@ -151,6 +152,7 @@ enum AppConstants {

/// Whether the widget HTTP server is enabled (Bool, default: false)
static let widgetHTTPEnabled = "widgetHTTPEnabled"

}

// MARK: - Dock Visibility Modes
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading