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") + } }