diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..df250fa
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,18 @@
+name: CI
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ build:
+ runs-on: macos-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Build
+ run: swift build
+ - name: Run Tests
+ run: swift test
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..9607298
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,27 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: macos-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build App
+ run: make app
+
+ - name: Zip App
+ run: zip -r Vinyl.zip Vinyl.app
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v3
+ with:
+ files: Vinyl.zip
diff --git a/Info.plist.template b/Info.plist.template
new file mode 100644
index 0000000..9bfbc76
--- /dev/null
+++ b/Info.plist.template
@@ -0,0 +1,22 @@
+
+
+
+
+ CFBundleIconFile
+ AppIcon
+ CFBundleExecutable
+ __APP_NAME__
+ CFBundleIdentifier
+ __BUNDLE_ID__
+ CFBundleName
+ __APP_NAME__
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ __VERSION__
+ LSUIElement
+
+ NSAppleEventsUsageDescription
+ __APP_NAME__ requires AppleScript to read currently playing track details from Apple Music and Spotify.
+
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f8735f3
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 VariableThe
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
index 0e072ef..d060e93 100644
--- a/Makefile
+++ b/Makefile
@@ -25,28 +25,10 @@ app: build
@if [ -d $(BUILD_DIR)/Vinyl_Vinyl.bundle ]; then cp -R $(BUILD_DIR)/Vinyl_Vinyl.bundle $(APP_DIR)/Contents/Resources/; fi
@if [ -f AppIcon.icns ]; then cp AppIcon.icns $(APP_DIR)/Contents/Resources/; fi
- @echo '' > $(INFO_PLIST)
- @echo '' >> $(INFO_PLIST)
- @echo '' >> $(INFO_PLIST)
- @echo '' >> $(INFO_PLIST)
- @echo ' CFBundleIconFile' >> $(INFO_PLIST)
- @echo ' AppIcon' >> $(INFO_PLIST)
- @echo ' CFBundleExecutable' >> $(INFO_PLIST)
- @echo ' $(APP_NAME)' >> $(INFO_PLIST)
- @echo ' CFBundleIdentifier' >> $(INFO_PLIST)
- @echo ' $(BUNDLE_ID)' >> $(INFO_PLIST)
- @echo ' CFBundleName' >> $(INFO_PLIST)
- @echo ' $(APP_NAME)' >> $(INFO_PLIST)
- @echo ' CFBundlePackageType' >> $(INFO_PLIST)
- @echo ' APPL' >> $(INFO_PLIST)
- @echo ' CFBundleShortVersionString' >> $(INFO_PLIST)
- @echo ' $(VERSION)' >> $(INFO_PLIST)
- @echo ' LSUIElement' >> $(INFO_PLIST)
- @echo ' ' >> $(INFO_PLIST)
- @echo ' NSAppleEventsUsageDescription' >> $(INFO_PLIST)
- @echo ' $(APP_NAME) requires AppleScript to read currently playing track details from Apple Music and Spotify.' >> $(INFO_PLIST)
- @echo '' >> $(INFO_PLIST)
- @echo '' >> $(INFO_PLIST)
+ @sed -e 's/__APP_NAME__/$(APP_NAME)/g' \
+ -e 's/__BUNDLE_ID__/$(BUNDLE_ID)/g' \
+ -e 's/__VERSION__/$(VERSION)/g' \
+ Info.plist.template > $(INFO_PLIST)
@codesign --force --deep --sign "$(SIGN_IDENTITY)" $(APP_DIR)
@echo "$(APP_NAME).app successfully built."
diff --git a/README.md b/README.md
index 96486ee..2ca1f3b 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ A native, lightweight macOS menu bar app that displays synchronized scrolling ly
- 🎛️ **Interactive Dropdown & Media Controls:** Left-click the menu bar to reveal a beautiful popover with full scrolling lyrics, a seek bar, and playback controls.
- 💾 **Smart Local Caching:** Saves lyrics to a local JSON cache to completely eliminate API calls for songs you've played before.
- 🛜 **Offline Mode:** Falls back to fetching native plain-text lyrics from Apple Music via AppleScript when the internet or `lrclib.net` is unavailable.
-- 📜 **Native & LRCLIB Support:** Queries Apple Music natively for lyrics or falls back to `lrclib.net` to automatically fetch synchronized lyrics (`[mm:ss.xx]`).
+- 📜 **Native & LRCLIB Support:** Queries Apple Music natively for lyrics or falls back to `lrclib.net` to automatically fetch synchronized lyrics (`[mm:ss.xx]`). *(Note: Spotify removed its lyrics API, so Vinyl relies exclusively on `lrclib.net` for fetching Spotify lyrics.)*
- ✨ **Smooth Marquee Scrolling:** Dynamically and smoothly scrolls long lyric lines within your menu bar without jittering or overflowing into the notch.
- ⚙️ **Customizable Preferences:** Adjust the marquee scroll speed, tracking polling interval, menu bar text appearance, and toggle the dropdown UI from a native SwiftUI settings window.
- 🌓 **Adaptive Icon:** Uses a custom vinyl logo that dynamically adapts to macOS Light and Dark modes.
diff --git a/Sources/MenuBarEngine.swift b/Sources/MenuBarEngine.swift
index 8c6aa44..d6b0a84 100644
--- a/Sources/MenuBarEngine.swift
+++ b/Sources/MenuBarEngine.swift
@@ -211,7 +211,6 @@ public final class MenuBarEngine: NSObject {
let title: String
var showOnlyIcon = false
- var needsScroll = false
if !areLyricsEnabled {
title = ""
@@ -264,7 +263,6 @@ public final class MenuBarEngine: NSObject {
// Pass 200.0 as maxWidth for the text calculation to leave room for the icon, padding, and leading spaces.
let scrollResult = getScrolledTitle(lyricText, maxWidth: 200.0, font: font, elapsed: elapsed, duration: lineDuration)
title = scrollResult.title
- needsScroll = scrollResult.needsScrolling
}
} else {
title = ""
diff --git a/Sources/VinylApp.swift b/Sources/VinylApp.swift
index 6c47e73..7acbb1f 100644
--- a/Sources/VinylApp.swift
+++ b/Sources/VinylApp.swift
@@ -10,6 +10,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var pollingTask: Task?
private var updateTask: Task?
private var artworkTask: Task?
+ private var notificationFetchTask: Task?
+ private var lyricsFetchTask: Task?
+ private var currentTrackKey = ""
func applicationDidFinishLaunching(_ notification: Notification) {
stateActor = AppStateActor()
@@ -24,94 +27,130 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
updateTask = Task {
await startUpdateLoop()
}
+
+ DistributedNotificationCenter.default().addObserver(
+ self,
+ selector: #selector(playerStateDidChange),
+ name: NSNotification.Name("com.apple.Music.playerInfo"),
+ object: nil
+ )
+ DistributedNotificationCenter.default().addObserver(
+ self,
+ selector: #selector(playerStateDidChange),
+ name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
+ object: nil
+ )
+ }
+
+ @objc private func playerStateDidChange(_ notification: Notification) {
+ notificationFetchTask?.cancel()
+ notificationFetchTask = Task {
+ await performFetch()
+ }
}
func applicationWillTerminate(_ notification: Notification) {
pollingTask?.cancel()
updateTask?.cancel()
+ artworkTask?.cancel()
+ notificationFetchTask?.cancel()
+ lyricsFetchTask?.cancel()
+ DistributedNotificationCenter.default().removeObserver(self)
}
- private func startPollingLoop() async {
- var currentTrackKey = ""
-
- while true {
- guard !Task.isCancelled else { break }
- do {
- let state = try await bridge.fetchCurrentState()
- await stateActor.updatePlayback(state: state)
-
- let track: Track?
- switch state {
- case .playing(let t, _), .paused(let t, _):
- track = t
- default:
- track = nil
- }
-
- if let track = track {
- let newTrackKey = track.trackKey
- if newTrackKey != currentTrackKey && !newTrackKey.isEmpty {
- currentTrackKey = newTrackKey
- print("Now playing: \(track.title) by \(track.artist)")
+ deinit {
+ DistributedNotificationCenter.default().removeObserver(self)
+ }
+
+ private func performFetch() async {
+ do {
+ let state = try await bridge.fetchCurrentState()
+ await stateActor.updatePlayback(state: state)
+
+ let track: Track?
+ switch state {
+ case .playing(let t, _), .paused(let t, _):
+ track = t
+ default:
+ track = nil
+ }
+
+ if let track = track {
+ let newTrackKey = track.trackKey
+ if newTrackKey != currentTrackKey && !newTrackKey.isEmpty {
+ currentTrackKey = newTrackKey
+ print("Now playing: \(track.title) by \(track.artist)")
+
+ artworkTask?.cancel()
+ artworkTask = Task {
+ let artworkData = await bridge.fetchArtwork(for: track.player)
+ guard !Task.isCancelled else { return }
+ await stateActor.setArtwork(artworkData)
+ }
+
+ if let cached = await stateActor.getCachedLyrics(forKey: newTrackKey) {
+ await stateActor.setLyricsLoaded(cached, forKey: newTrackKey)
+ } else {
+ await stateActor.setLyricsLoading()
- artworkTask?.cancel()
- artworkTask = Task {
- let artworkData = await bridge.fetchArtwork(for: track.player)
+ lyricsFetchTask?.cancel()
+ lyricsFetchTask = Task {
+ var lyricsLines: [LyricLine]? = nil
+ var fetchError: Error? = nil
+
+ // 1. Try LRCLIB
+ do {
+ print("Fetching lrclib lyrics for \(track.title)")
+ lyricsLines = try await client.fetchLyrics(
+ track: track.title,
+ artist: track.artist,
+ album: track.album,
+ duration: track.duration
+ )
+ print("Successfully fetched lrclib lyrics")
+ } catch {
+ if Task.isCancelled { return }
+ print("Failed to fetch lrclib lyrics: \(error)")
+ fetchError = error
+ }
+
guard !Task.isCancelled else { return }
- await stateActor.setArtwork(artworkData)
- }
-
- if let cached = await stateActor.getCachedLyrics(forKey: newTrackKey) {
- await stateActor.setLyricsLoaded(cached, forKey: newTrackKey)
- } else {
- await stateActor.setLyricsLoading()
- Task {
- var lyricsLines: [LyricLine]? = nil
- var fetchError: Error? = nil
-
- // 1. Try LRCLIB
- do {
- print("Fetching lrclib lyrics for \(track.title)")
- lyricsLines = try await client.fetchLyrics(
- track: track.title,
- artist: track.artist,
- album: track.album,
- duration: track.duration
- )
- print("Successfully fetched lrclib lyrics")
- } catch {
- print("Failed to fetch lrclib lyrics: \(error)")
- fetchError = error
- }
-
- // 2. Fallback to Apple Music Native Offline
- if lyricsLines == nil && track.player == "Music" {
- if let nativeText = await bridge.fetchAppleMusicLyrics() {
- print("Fallback: Fetched native lyrics for \(track.title)")
- lyricsLines = client.parseLyrics(nativeText)
- } else {
- print("Fallback: No native lyrics found for \(track.title)")
- }
- }
-
- if let finalLyrics = lyricsLines {
- await stateActor.setLyricsLoaded(finalLyrics, forKey: newTrackKey)
- } else if let error = fetchError {
- await stateActor.setLyricsError(error.localizedDescription, forKey: newTrackKey)
+ // 2. Fallback to Apple Music Native Offline
+ if lyricsLines == nil && track.player == "Music" {
+ if let nativeText = await bridge.fetchAppleMusicLyrics() {
+ print("Fallback: Fetched native lyrics for \(track.title)")
+ lyricsLines = client.parseLyrics(nativeText)
} else {
- await stateActor.setLyricsError("Lyrics not found", forKey: newTrackKey)
+ print("Fallback: No native lyrics found for \(track.title)")
}
}
+
+ guard !Task.isCancelled else { return }
+
+ if let finalLyrics = lyricsLines {
+ await stateActor.setLyricsLoaded(finalLyrics, forKey: newTrackKey)
+ } else if let error = fetchError {
+ await stateActor.setLyricsError(error.localizedDescription, forKey: newTrackKey)
+ } else {
+ await stateActor.setLyricsError("Lyrics not found", forKey: newTrackKey)
+ }
}
}
- } else {
- currentTrackKey = ""
}
- } catch {
- print("Polling error: \(error)")
- await stateActor.updatePlayback(state: .notRunning)
+ } else {
+ currentTrackKey = ""
}
+ } catch {
+ print("Polling error: \(error)")
+ await stateActor.updatePlayback(state: .notRunning)
+ }
+ }
+
+ private func startPollingLoop() async {
+ while true {
+ guard !Task.isCancelled else { break }
+ await performFetch()
do {
var currentInterval = UserDefaults.standard.double(forKey: "pollingInterval")
diff --git a/Tests/VinylTests/VinylTests.swift b/Tests/VinylTests/VinylTests.swift
index c181d9d..f850c97 100644
--- a/Tests/VinylTests/VinylTests.swift
+++ b/Tests/VinylTests/VinylTests.swift
@@ -50,4 +50,23 @@ final class VinylTests: XCTestCase {
let state = MediaBridge.parseState(from: "stopped", appName: "Spotify")
XCTAssertEqual(state, .stopped)
}
+
+ func testLyricLineCodable() throws {
+ let line1 = LyricLine(timestamp: 12.5, text: "Hello World")
+ let line2 = LyricLine(timestamp: 20.0, text: "Testing Codable")
+ let cache: [String: [LyricLine]] = ["Track1": [line1, line2]]
+
+ let encoder = JSONEncoder()
+ let data = try encoder.encode(cache)
+
+ let decoder = JSONDecoder()
+ let decoded = try decoder.decode([String: [LyricLine]].self, from: data)
+
+ XCTAssertEqual(decoded.keys.count, 1)
+ XCTAssertEqual(decoded["Track1"]?.count, 2)
+ XCTAssertEqual(decoded["Track1"]?[0].timestamp, 12.5)
+ XCTAssertEqual(decoded["Track1"]?[0].text, "Hello World")
+ XCTAssertEqual(decoded["Track1"]?[1].timestamp, 20.0)
+ XCTAssertEqual(decoded["Track1"]?[1].text, "Testing Codable")
+ }
}