diff --git a/Lyric Fever.xcodeproj/project.pbxproj b/Lyric Fever.xcodeproj/project.pbxproj index 1026c9e..3998888 100644 --- a/Lyric Fever.xcodeproj/project.pbxproj +++ b/Lyric Fever.xcodeproj/project.pbxproj @@ -552,10 +552,10 @@ }; 8F2922BE2E94DE0700B286FC /* XCRemoteSwiftPackageReference "mediaremote-adapter" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ejbills/mediaremote-adapter"; + repositoryURL = "https://github.com/koto9x/mediaremote-adapter"; requirement = { - branch = master; - kind = branch; + kind = revision; + revision = 682582165dbf60294604a5ee66e1826b18d4f250; }; }; 8F37323B2E6EC9C90075CDA7 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */ = { diff --git a/LyricFever/LyricFever.swift b/LyricFever/LyricFever.swift index 6ee4903..fee8f12 100644 --- a/LyricFever/LyricFever.swift +++ b/LyricFever/LyricFever.swift @@ -92,6 +92,7 @@ struct LyricFever: App { .animation(.easeIn(duration: 0.2)) .environment(viewmodel) } + .modifier(AppleMusicAuthModifier(viewmodel: viewmodel, openWindow: openWindow)) .onAppear { viewmodel.onAppear(openWindow) } @@ -236,7 +237,30 @@ struct LyricFever: App { } } .windowResizability(.contentSize) - Window("Lyric Fever: Update 2.3", id: "update") { // << here !! + auxiliaryScenes + } + + // Extracted to keep `body` under SwiftUI's @SceneBuilder type-check budget. + // The body was already at the limit; adding a 6th inline scene tipped it + // into "unable to type-check this expression in reasonable time." Bundling + // the auth + update windows together drops the body's scene count back to 5. + @SceneBuilder + private var auxiliaryScenes: some Scene { + Window("Connect Apple Music", id: "applemusic-auth") { + AppleMusicAuthView(authManager: AppleMusicAuthManager.shared) + .environment(viewmodel) + .preferredColorScheme(.dark) + .onAppear { + NSApp.setActivationPolicy(.regular) + } + .onDisappear { + if !viewmodel.fullscreen { + NSApp.setActivationPolicy(.accessory) + } + } + } + .windowResizability(.contentSize) + Window("Lyric Fever: Update 2.3", id: "update") { UpdateWindow().frame(minWidth: 700, maxWidth: 700, alignment: .center) .environment(viewmodel) .preferredColorScheme(.dark) @@ -249,9 +273,42 @@ struct LyricFever: App { } } } - .windowResizability(.contentSize) - .windowStyle(.hiddenTitleBar) - .windowLevel(.floating) + .windowResizability(.contentSize) + .windowStyle(.hiddenTitleBar) + .windowLevel(.floating) + } +} + +/// Pulled off the MenuBarExtra label's modifier chain so the @SceneBuilder body +/// can type-check in reasonable time. Bundles the auth-sheet trigger (routes to +/// the `applemusic-auth` Window scene) and the denied-state alert. Sheet-style +/// presentation doesn't work in a MenuBarExtra context — there's no NSWindow +/// host for it to attach to — so we openWindow instead. +private struct AppleMusicAuthModifier: ViewModifier { + @Bindable var viewmodel: ViewModel + let openWindow: OpenWindowAction + + func body(content: Content) -> some View { + content + .onChange(of: viewmodel.showAppleMusicAuthSheet) { _, newValue in + if newValue { + NSApp.setActivationPolicy(.regular) + NSApplication.shared.activate(ignoringOtherApps: true) + openWindow(id: "applemusic-auth") + // Reset so the trigger can re-arm if the user dismisses without authorizing. + viewmodel.showAppleMusicAuthSheet = false + } + } + .alert("Apple Music access disabled", isPresented: $viewmodel.showAppleMusicDeniedToast) { + Button("Open System Settings") { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Media") { + NSWorkspace.shared.open(url) + } + } + Button("Dismiss", role: .cancel) { } + } message: { + Text("LyricFever can't fetch Apple Music's synced lyrics until you grant access in System Settings → Privacy & Security → Media & Apple Music.") + } } } diff --git a/LyricFever/LyricProvider/AppleMusic/AppleMusicAuthManager.swift b/LyricFever/LyricProvider/AppleMusic/AppleMusicAuthManager.swift new file mode 100644 index 0000000..c68e419 --- /dev/null +++ b/LyricFever/LyricProvider/AppleMusic/AppleMusicAuthManager.swift @@ -0,0 +1,35 @@ +import Foundation +import MusicKit +import Observation + +@MainActor +@Observable +final class AppleMusicAuthManager { + static let shared = AppleMusicAuthManager() + + private(set) var status: MusicAuthorization.Status = .notDetermined + private(set) var hasShownDeniedToast: Bool = false + + private init() { + self.status = MusicAuthorization.currentStatus + } + + var isAuthorized: Bool { status == .authorized } + + /// Show the system prompt. Call from `AppleMusicAuthView`. + func requestAuthorization() async { + let result = await MusicAuthorization.request() + self.status = result + } + + /// Called when a MusicDataRequest returns a 401/403 — re-prompts next track. + func invalidate() { + self.status = .notDetermined + self.hasShownDeniedToast = false + } + + /// Called by the toast UI once. + func markDeniedToastShown() { + self.hasShownDeniedToast = true + } +} diff --git a/LyricFever/LyricProvider/AppleMusic/AppleMusicLyricProvider.swift b/LyricFever/LyricProvider/AppleMusic/AppleMusicLyricProvider.swift new file mode 100644 index 0000000..da26702 --- /dev/null +++ b/LyricFever/LyricProvider/AppleMusic/AppleMusicLyricProvider.swift @@ -0,0 +1,110 @@ +// +// AppleMusicLyricProvider.swift +// Lyric Fever +// +// Top-tier lyric source for tracks playing through Music.app that have a +// known Apple Music catalog (Adam) ID. Returns Apple's TTML parsed into +// LyricLine values via TTMLParser. +// + +import Foundation +import MusicKit + +@MainActor +final class AppleMusicLyricProvider: LyricProvider { + let providerName: String = "apple_music" + + /// `trackID` is expected to be the Apple Music catalog ID captured by + /// AppleMusicMetadataObserver. If empty we return an empty result rather + /// than throwing, so the ViewModel chain falls through to the next provider. + func fetchNetworkLyrics( + trackName: String, + trackID: String, + currentlyPlayingArtist: String?, + currentAlbumName: String? + ) async throws -> NetworkFetchReturn { + guard !trackID.isEmpty else { + print("AppleMusicLyricProvider: no catalog ID, skipping") + return NetworkFetchReturn(lyrics: [], colorData: nil) + } + + let auth = AppleMusicAuthManager.shared + guard auth.isAuthorized else { + print("AppleMusicLyricProvider: not authorized, skipping") + return NetworkFetchReturn(lyrics: [], colorData: nil) + } + + // MusicDataRequest handles developer token + user token injection. + // {{storefront}} is substituted by MusicKit with the signed-in user's + // storefront code (e.g. "us", "jp"). If MusicKit doesn't substitute it, + // fall back to the literal "us" in the caller-fix path. + guard let url = URL(string: "https://api.music.apple.com/v1/catalog/{{storefront}}/songs/\(trackID)/lyrics") else { + print("AppleMusicLyricProvider: could not construct URL for trackID=\(trackID)") + return NetworkFetchReturn(lyrics: [], colorData: nil) + } + + let request = MusicDataRequest(urlRequest: URLRequest(url: url)) + let response: MusicDataResponse + + do { + response = try await request.response() + } catch { + print("AppleMusicLyricProvider: MusicDataRequest error: \(error)") + return NetworkFetchReturn(lyrics: [], colorData: nil) + } + + let statusCode = response.urlResponse.statusCode + guard statusCode == 200 else { + print("AppleMusicLyricProvider: HTTP \(statusCode) for trackID=\(trackID)") + if statusCode == 401 || statusCode == 403 { + auth.invalidate() + } + return NetworkFetchReturn(lyrics: [], colorData: nil) + } + + // Apple wraps TTML in: { "data": [ { "attributes": { "ttml": " [SongResult] { + return [] + } +} diff --git a/LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift b/LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift new file mode 100644 index 0000000..955b9aa --- /dev/null +++ b/LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift @@ -0,0 +1,135 @@ +// +// AppleMusicPrefetcher.swift +// Lyric Fever +// +// Background album-wide lyric prefetcher for Apple Music. On every track +// change, warmAlbum enumerates the album's tracks via the MusicKit catalog +// API, diffs against CoreData, and fetches lyrics for any uncached track using +// a bounded TaskGroup (cap = 4 concurrent fetches). All failures are silent — +// demand-fetch on the next play of an uncached track recovers automatically. +// + +import Foundation +import MusicKit +import CoreData + +actor AppleMusicPrefetcher { + private let container: NSPersistentContainer + private let provider: AppleMusicLyricProvider + private let concurrencyCap = 4 + + init(container: NSPersistentContainer, provider: AppleMusicLyricProvider) { + self.container = container + self.provider = provider + } + + /// Fetch lyrics for every track on the album that isn't already cached. + func warmAlbum(albumID: String) async { + guard await AppleMusicAuthManager.shared.isAuthorized else { return } + guard !albumID.isEmpty else { return } + + // 1. Enumerate album tracks via MusicDataRequest + let albumTracks = await enumerateAlbumTracks(albumID: albumID) + guard !albumTracks.isEmpty else { return } + + // 2. Diff against CoreData + let alreadyCached = await cachedAppleMusicIDs(in: albumTracks.map { $0.id }) + let toFetch = albumTracks.filter { !alreadyCached.contains($0.id) } + guard !toFetch.isEmpty else { return } + + print("AppleMusicPrefetcher.warmAlbum: \(toFetch.count) new tracks for album \(albumID)") + + // 3. Bounded TaskGroup with concurrency cap + await withTaskGroup(of: Void.self) { group in + var active = 0 + var iter = toFetch.makeIterator() + while let next = iter.next() { + if active >= concurrencyCap { + await group.next() + active -= 1 + } + group.addTask { [provider] in + let result = try? await provider.fetchNetworkLyrics( + trackName: next.name, + trackID: next.id, + currentlyPlayingArtist: next.artistName, + currentAlbumName: next.albumName + ) + await self.persist(result: result, albumTrack: next, albumID: albumID) + } + active += 1 + } + } + } + + // MARK: - Helpers + + private struct AlbumTrack { + let id: String + let name: String + let artistName: String? + let albumName: String? + } + + private func enumerateAlbumTracks(albumID: String) async -> [AlbumTrack] { + guard let url = URL(string: "https://api.music.apple.com/v1/catalog/{{storefront}}/albums/\(albumID)?include=tracks") else { + return [] + } + let request = MusicDataRequest(urlRequest: URLRequest(url: url)) + do { + let response = try await request.response() + guard response.urlResponse.statusCode == 200 else { return [] } + struct AlbumEnvelope: Decodable { + struct Datum: Decodable { + struct Relationships: Decodable { + struct Tracks: Decodable { + struct Item: Decodable { + struct Attributes: Decodable { + let name: String + let artistName: String? + let albumName: String? + } + let id: String + let attributes: Attributes + } + let data: [Item] + } + let tracks: Tracks + } + let relationships: Relationships + } + let data: [Datum] + } + let envelope = try JSONDecoder().decode(AlbumEnvelope.self, from: response.data) + return envelope.data.first?.relationships.tracks.data.map { + AlbumTrack( + id: $0.id, + name: $0.attributes.name, + artistName: $0.attributes.artistName, + albumName: $0.attributes.albumName + ) + } ?? [] + } catch { + return [] + } + } + + @MainActor + private func cachedAppleMusicIDs(in candidates: [String]) -> Set { + let ctx = container.viewContext + let request = SongObject.fetchRequest() + request.predicate = NSPredicate(format: "appleMusicID IN %@", candidates) + let existing = (try? ctx.fetch(request)) ?? [] + return Set(existing.compactMap { $0.appleMusicID }) + } + + @MainActor + private func persist(result: NetworkFetchReturn?, albumTrack: AlbumTrack, albumID: String) { + let ctx = container.viewContext + let lines = result?.lyrics ?? [] + let song = SongObject(from: lines, with: ctx, trackID: albumTrack.id, trackName: albumTrack.name) + song.appleMusicID = albumTrack.id + song.albumID = albumID + try? ctx.save() + } +} diff --git a/LyricFever/LyricProvider/AppleMusic/TTMLParser.swift b/LyricFever/LyricProvider/AppleMusic/TTMLParser.swift new file mode 100644 index 0000000..201037a --- /dev/null +++ b/LyricFever/LyricProvider/AppleMusic/TTMLParser.swift @@ -0,0 +1,63 @@ +// +// TTMLParser.swift +// Lyric Fever +// + +import Foundation + +enum TTMLParserError: Error, Equatable { + case invalidXML +} + +struct TTMLParser { + /// Parse Apple's TTML lyric document into LyricLine values. Synced timestamps + /// when present; otherwise startTimeMS == 0 for every line (caller can decide + /// to display them as an unsynced block). Returns [] for empty/instrumental + /// documents (no

elements) — the caller-chain falls through naturally. + static func parse(_ data: Data) throws -> [LyricLine] { + let doc: XMLDocument + do { + doc = try XMLDocument(data: data) + } catch { + throw TTMLParserError.invalidXML + } + + // local-name() so we don't fight the TTML namespace prefix + let paragraphs = try doc.nodes(forXPath: "//*[local-name()='p']") + + return paragraphs.compactMap { node -> LyricLine? in + guard let el = node as? XMLElement else { return nil } + let beginAttr = el.attribute(forName: "begin")?.stringValue + let startTimeMS = beginAttr.flatMap { parseTimecode($0) } ?? 0 + let text = (el.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + // Note: LyricLine.startTimeMS is TimeInterval but the codebase convention + // uses milliseconds, not seconds. parseTimecode returns ms; pass through. + return LyricLine(startTime: TimeInterval(startTimeMS), words: text) + } + } + + /// Parse `HH:MM:SS.mmm`, `MM:SS.mmm`, or `S.mmm` → milliseconds. + static func parseTimecode(_ s: String) -> Int? { + let parts = s.split(separator: ":").map(String.init) + let secondsStr: String + var hours = 0 + var minutes = 0 + switch parts.count { + case 1: + secondsStr = parts[0] + case 2: + minutes = Int(parts[0]) ?? 0 + secondsStr = parts[1] + case 3: + hours = Int(parts[0]) ?? 0 + minutes = Int(parts[1]) ?? 0 + secondsStr = parts[2] + default: + return nil + } + guard let seconds = Double(secondsStr) else { return nil } + let total = Double(hours) * 3_600_000 + Double(minutes) * 60_000 + seconds * 1000 + return Int(total.rounded()) + } +} diff --git a/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion new file mode 100644 index 0000000..a91fe48 --- /dev/null +++ b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + Lyrics 3.xcdatamodel + + diff --git a/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 2.xcdatamodel/contents b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 2.xcdatamodel/contents new file mode 100644 index 0000000..d134231 --- /dev/null +++ b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 2.xcdatamodel/contents @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 3.xcdatamodel/contents b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 3.xcdatamodel/contents new file mode 100644 index 0000000..f98e371 --- /dev/null +++ b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/Lyrics 3.xcdatamodel/contents @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LyricFever/Models/CoreData/SongObjectExtensions.swift b/LyricFever/Models/CoreData/SongObjectExtensions.swift index 54e9007..72b35a0 100644 --- a/LyricFever/Models/CoreData/SongObjectExtensions.swift +++ b/LyricFever/Models/CoreData/SongObjectExtensions.swift @@ -22,6 +22,8 @@ extension SongObject { @NSManaged public var language: String @NSManaged public var lyricsWords: [String] @NSManaged public var lyricsTimestamps: [TimeInterval] + @NSManaged public var appleMusicID: String? + @NSManaged public var albumID: String? } diff --git a/LyricFever/Players/AppleMusic/AppleMusicPlayer.swift b/LyricFever/Players/AppleMusic/AppleMusicPlayer.swift index 4fe4b81..da353e1 100644 --- a/LyricFever/Players/AppleMusic/AppleMusicPlayer.swift +++ b/LyricFever/Players/AppleMusic/AppleMusicPlayer.swift @@ -94,8 +94,17 @@ class AppleMusicPlayer: Player { appleMusicScript?.nextTrack?() } + /// Most recently observed Apple Music catalog ID (Adam ID), sourced from + /// MediaRemote's now-playing payload. Nil for tracks not in the catalog + /// (imported MP3s, audiobooks, etc). + var lastObservedCatalogID: String? + + /// Most recently observed album catalog ID. Nil if the payload didn't + /// surface one or the album isn't in the catalog. + var lastObservedAlbumCatalogID: String? + var artworkImage: NSImage? - + // var artworkImage: NSImage? { // guard let artworkImage = (appleMusicScript?.currentTrack?.artworks?().firstObject as? MusicArtwork)?.data else { // print("AppleMusicPlayer artworkImage: nil data") diff --git a/LyricFever/Support Files/Info.plist b/LyricFever/Support Files/Info.plist index bb98ca6..7ed8c19 100644 --- a/LyricFever/Support Files/Info.plist +++ b/LyricFever/Support Files/Info.plist @@ -4,6 +4,8 @@ + NSAppleMusicUsageDescription + LyricFever uses your Apple Music access to fetch synchronized lyrics for songs in Apple's catalog. SUAllowsAutomaticUpdates SUAutomaticallyUpdate diff --git a/LyricFever/Support Files/UserDefaultStorage.swift b/LyricFever/Support Files/UserDefaultStorage.swift index 5821d02..182c529 100644 --- a/LyricFever/Support Files/UserDefaultStorage.swift +++ b/LyricFever/Support Files/UserDefaultStorage.swift @@ -71,4 +71,12 @@ class UserDefaultStorage { @ObservationIgnored var hasTranslated: Bool @ObservableUserDefault(.init(key: "truncationLength", defaultValue: 40, store: .standard)) @ObservationIgnored var truncationLength: Int + + #if os(macOS) + /// When enabled, LyricFever pre-fetches lyrics for every track on the + /// currently-playing album in the background (Apple Music only). + /// Off by default — background network activity is opt-in. + @ObservableUserDefault(.init(key: "prefetchAlbumLyrics", defaultValue: false, store: .standard)) + @ObservationIgnored var prefetchAlbumLyrics: Bool + #endif } diff --git a/LyricFever/ViewModel.swift b/LyricFever/ViewModel.swift index 7eb6658..1784fe8 100644 --- a/LyricFever/ViewModel.swift +++ b/LyricFever/ViewModel.swift @@ -24,7 +24,8 @@ import MediaRemoteAdapter static let shared = ViewModel() // Apple Music Tahoe broken AppleScript workaround - let musicController = MediaController(bundleIdentifier: "com.apple.Music") + // bundleIdentifier param removed from MediaController.init in adapter b8ce5d1 + let musicController = MediaController() // var appleMusicUniqueIdentifier: String? var currentlyPlaying: String? @@ -59,17 +60,76 @@ import MediaRemoteAdapter guard self.currentPlayer == .appleMusic else { return } - guard let artwork = data?.payload.artwork else { - if self.currentlyPlaying == nil { - self.artworkImage = nil - } - print("Apple Music Artwork Workaround: Ignoring No Artwork") + guard let payload = data?.payload else { return } + guard payload.applicationName == "Music" else { return } - guard data?.payload.applicationName == "Music" else { - return + // ── Source-of-truth track-change detection ────────────── + // MediaRemoteAdapter delivers the freshest "what's actually + // playing" snapshot — way more reliable than Music.app's + // com.apple.Music.playerInfo notification, which can lag or + // drop entirely on track transitions. If the title in the + // payload doesn't match our cached currentlyPlayingName, kick + // a re-detect through appleMusicNetworkFetch (which re-reads + // Music.app via AppleScript and re-maps to Spotify ID). + if let payloadTitle = payload.title, + !payloadTitle.isEmpty, + payloadTitle != self.currentlyPlayingName { + print("MediaRemote: track change detected (\(self.currentlyPlayingName ?? "nil") → \(payloadTitle)) — forcing chain refresh") + self.currentlyPlayingName = payloadTitle + self.currentlyPlayingArtist = payload.artist + self.currentAlbumName = payload.album + if let durationMicros = payload.durationMicros { + self.duration = Int(durationMicros / 1000) + } + // Refresh persistent ID directly off AppleScript — it + // catches up by the time MediaRemote fires (slightly + // after the playerInfo notification path). + let freshPID = self.appleMusicPlayer.persistentID + if let freshPID, freshPID != self.currentlyPlayingAppleMusicPersistentID { + self.currentlyPlayingAppleMusicPersistentID = freshPID + } + // Capture Apple Music catalog (Adam) IDs from MediaRemote payload. + // Nil for local files, audiobooks, or non-catalog tracks. + self.appleMusicPlayer.lastObservedCatalogID = payload.contentItemIdentifier + self.appleMusicPlayer.lastObservedAlbumCatalogID = payload.albumiTunesStoreAdamIdentifier + Task { + await self.appleMusicStarter() + } + // ── Auth prompts (once per install) ───────────────────── + // Only fire for Apple Music player — catalog ID is already + // captured above so we know this is a real streaming track. + if !self.hasOfferedAppleMusicAuth, + AppleMusicAuthManager.shared.status == .notDetermined { + self.hasOfferedAppleMusicAuth = true + self.showAppleMusicAuthSheet = true + } + if !AppleMusicAuthManager.shared.hasShownDeniedToast, + (AppleMusicAuthManager.shared.status == .denied || + AppleMusicAuthManager.shared.status == .restricted) { + AppleMusicAuthManager.shared.markDeniedToastShown() + self.showAppleMusicDeniedToast = true + } + // ── Album-wide background lyric prefetch ───────────────── + // Only fires when the user has enabled "Prefetch album lyrics + // in background" in Settings (off by default). + if self.userDefaultStorage.prefetchAlbumLyrics, + let albumID = self.appleMusicPlayer.lastObservedAlbumCatalogID, + !albumID.isEmpty, + self.currentPlayer == .appleMusic, + AppleMusicAuthManager.shared.isAuthorized { + Task.detached { [weak self] in + guard let self else { return } + await self.appleMusicPrefetcher.warmAlbum(albumID: albumID) + } + } + } + // ── Artwork ───────────────────────────────────────────── + if let artwork = payload.artwork { + self.artworkImage = artwork + } else if self.currentlyPlaying == nil { + self.artworkImage = nil } - self.artworkImage = artwork } // This will only be called for Apple Music events } @@ -180,6 +240,9 @@ import MediaRemoteAdapter var chineseConversionLyrics: [String] = [] var translatedLyric: [String] = [] var showLyrics = true + var showAppleMusicAuthSheet: Bool = false + var hasOfferedAppleMusicAuth: Bool = false + var showAppleMusicDeniedToast: Bool = false #if os(macOS) var fullscreen = false var spotifyConnectDelay: Bool = false @@ -269,10 +332,27 @@ import MediaRemoteAdapter var lRCLyricProvider = LRCLIBLyricProvider() var netEaseLyricProvider = NetEaseLyricProvider() #if os(macOS) + @ObservationIgnored lazy var appleMusicLyricProvider = AppleMusicLyricProvider() + @ObservationIgnored lazy var appleMusicPrefetcher = AppleMusicPrefetcher( + container: coreDataContainer, + provider: appleMusicLyricProvider + ) var localFileUploadProvider = LocalFileUploadProvider() #endif - @ObservationIgnored lazy var allNetworkLyricProviders: [LyricProvider] = [spotifyLyricProvider, lRCLyricProvider, netEaseLyricProvider] - + var allNetworkLyricProviders: [LyricProvider] { + #if os(macOS) + if currentPlayer == .appleMusic, + let amID = appleMusicPlayer.lastObservedCatalogID, !amID.isEmpty, + AppleMusicAuthManager.shared.isAuthorized { + return [appleMusicLyricProvider, spotifyLyricProvider, lRCLyricProvider, netEaseLyricProvider] + } + return [spotifyLyricProvider, lRCLyricProvider, netEaseLyricProvider] + #else + return [spotifyLyricProvider, lRCLyricProvider, netEaseLyricProvider] + #endif + } + + // custom order because LRCLIB is tweaking for the time being @ObservationIgnored lazy var allNetworkLyricProvidersForSearch: [LyricProvider] = [spotifyLyricProvider, netEaseLyricProvider, lRCLyricProvider] @@ -365,12 +445,20 @@ import MediaRemoteAdapter for networkLyricProvider in allNetworkLyricProviders { do { print("FetchAllNetworkLyrics: fetching from \(networkLyricProvider.providerName)") - let lyrics = try await networkLyricProvider.fetchNetworkLyrics(trackName: currentlyPlayingName, trackID: currentlyPlaying, currentlyPlayingArtist: currentlyPlayingArtist, currentAlbumName: currentAlbumName) + let providerTrackID: String = { + if networkLyricProvider.providerName == "apple_music" { + return appleMusicPlayer.lastObservedCatalogID ?? "" + } + return currentlyPlaying + }() + let lyrics = try await networkLyricProvider.fetchNetworkLyrics(trackName: currentlyPlayingName, trackID: providerTrackID, currentlyPlayingArtist: currentlyPlayingArtist, currentAlbumName: currentAlbumName) if !lyrics.lyrics.isEmpty { amplitude.track(eventType: "\(networkLyricProvider.providerName) Fetch") print("FetchAllNetworkLyrics: returning lyrics from \(networkLyricProvider.providerName)") // thats how i save to coredata - let _ = SongObject(from: lyrics.lyrics, with: coreDataContainer.viewContext, trackID: currentlyPlaying, trackName: currentlyPlayingName) + let song = SongObject(from: lyrics.lyrics, with: coreDataContainer.viewContext, trackID: currentlyPlaying, trackName: currentlyPlayingName) + song.appleMusicID = appleMusicPlayer.lastObservedCatalogID + song.albumID = appleMusicPlayer.lastObservedAlbumCatalogID saveCoreData() return lyrics } else if networkLyricProvider is SpotifyLyricProvider { @@ -383,6 +471,7 @@ import MediaRemoteAdapter print("Caught exception on \(networkLyricProvider.providerName): \(error)") } } + saveCoreData() return NetworkFetchReturn(lyrics: [], colorData: nil) } @@ -921,7 +1010,27 @@ import MediaRemoteAdapter func fetchLyrics(for trackID: String, _ trackName: String, checkCoreDataFirst: Bool) async throws -> [LyricLine] { let initiatingTrackID = trackID - if checkCoreDataFirst, let lyrics = fetchFromCoreData(for: trackID) { + // AppleMusic catalog-ID CoreData lookup: when the player is Apple Music + // and we have a catalog ID, check by appleMusicID first — this covers the + // case where the same track was previously fetched under a different + // Spotify/Plexamp trackID but is now playing via Apple Music. + if checkCoreDataFirst, + let amID = appleMusicPlayer.lastObservedCatalogID, !amID.isEmpty { + let request = SongObject.fetchRequest() + request.predicate = NSPredicate(format: "appleMusicID == %@", amID) + if let existing = try? coreDataContainer.viewContext.fetch(request).first, + !existing.lyricsWords.isEmpty { + let lyrics = zip(existing.lyricsTimestamps, existing.lyricsWords).map { LyricLine(startTime: $0, words: $1) } + try Task.checkCancellation() + amplitude.track(eventType: "CoreData Fetch (appleMusicID)") + if initiatingTrackID != self.currentlyPlaying { + throw FetchError.staleTrack + } + return lyrics + } + } + + if checkCoreDataFirst, let lyrics = fetchFromCoreData(for: trackID), !lyrics.isEmpty { print("ViewModel FetchLyrics: got lyrics from core data :D \(trackID) \(trackName)") try Task.checkCancellation() amplitude.track(eventType: "CoreData Fetch") diff --git a/LyricFever/Views/AppleMusicAuthView.swift b/LyricFever/Views/AppleMusicAuthView.swift new file mode 100644 index 0000000..91ad451 --- /dev/null +++ b/LyricFever/Views/AppleMusicAuthView.swift @@ -0,0 +1,55 @@ +import SwiftUI +import MusicKit + +struct AppleMusicAuthView: View { + @Environment(\.dismiss) private var dismiss + let authManager: AppleMusicAuthManager + + @State private var isRequesting = false + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "music.note") + .font(.system(size: 48)) + .foregroundStyle(.pink) + + Text("Connect Apple Music") + .font(.title2.weight(.semibold)) + + Text("LyricFever can fetch synced lyrics directly from Apple Music for songs in the catalog. This is far more accurate than third-party sources for recent releases.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 20) + + Text("Sign-in happens once and is stored by macOS in Keychain.") + .font(.caption) + .foregroundStyle(.tertiary) + + HStack { + Button("Not now") { dismiss() } + .keyboardShortcut(.cancelAction) + Spacer() + Button { + isRequesting = true + Task { + await authManager.requestAuthorization() + isRequesting = false + dismiss() + } + } label: { + if isRequesting { + ProgressView().scaleEffect(0.7) + } else { + Text("Connect") + } + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(isRequesting) + } + .padding(.top, 12) + } + .padding(28) + .frame(width: 380) + } +} diff --git a/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift b/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift index d27c66a..971d6a4 100644 --- a/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift +++ b/LyricFever/Views/MenubarWindowView/MenubarWindowView.swift @@ -517,6 +517,10 @@ struct MenubarWindowView: View { var otherOptions: some View { @Bindable var viewmodel = viewmodel Toggle("Show Song Details in Menubar", isOn: $viewmodel.userDefaultStorage.showSongDetailsInMenubar) + if viewmodel.currentPlayer == .appleMusic { + Toggle("Prefetch album lyrics in background", isOn: $viewmodel.userDefaultStorage.prefetchAlbumLyrics) + .help("When enabled, LyricFever pre-fetches lyrics for every track on the currently-playing album so skips and auto-advance load instantly. Off by default.") + } Divider() streamingDelayView Button("Settings (New Karaoke Settings!)") { diff --git a/LyricFeverTests/Fixtures/ttml-malformed.xml b/LyricFeverTests/Fixtures/ttml-malformed.xml new file mode 100644 index 0000000..7468a73 --- /dev/null +++ b/LyricFeverTests/Fixtures/ttml-malformed.xml @@ -0,0 +1 @@ + valid XML at all diff --git a/LyricFeverTests/Fixtures/ttml-plain.ttml b/LyricFeverTests/Fixtures/ttml-plain.ttml new file mode 100644 index 0000000..ea48fca --- /dev/null +++ b/LyricFeverTests/Fixtures/ttml-plain.ttml @@ -0,0 +1,9 @@ + + + +

+

Line without timing

+

Another line without timing

+
+ + diff --git a/LyricFeverTests/Fixtures/ttml-synced-spans.ttml b/LyricFeverTests/Fixtures/ttml-synced-spans.ttml new file mode 100644 index 0000000..d86596d --- /dev/null +++ b/LyricFeverTests/Fixtures/ttml-synced-spans.ttml @@ -0,0 +1,14 @@ + + + +
+

+ Hello + world + again +

+
+ +
diff --git a/LyricFeverTests/Fixtures/ttml-synced.ttml b/LyricFeverTests/Fixtures/ttml-synced.ttml new file mode 100644 index 0000000..81a0f37 --- /dev/null +++ b/LyricFeverTests/Fixtures/ttml-synced.ttml @@ -0,0 +1,10 @@ + + + +
+

First line of lyrics

+

Second line of lyrics

+

Third line of lyrics

+
+ +
diff --git a/LyricFeverTests/TTMLParserTests.swift b/LyricFeverTests/TTMLParserTests.swift new file mode 100644 index 0000000..c95080a --- /dev/null +++ b/LyricFeverTests/TTMLParserTests.swift @@ -0,0 +1,50 @@ +// +// TTMLParserTests.swift +// LyricFeverTests +// + +import XCTest +@testable import Lyric_Fever + +final class TTMLParserTests: XCTestCase { + private func fixture(_ name: String) throws -> Data { + let bundle = Bundle(for: type(of: self)) + let url = try XCTUnwrap(bundle.url(forResource: name, withExtension: nil), "\(name) not found in test bundle") + return try Data(contentsOf: url) + } + + func test_synced_returnsLinesWithCorrectTimestamps() throws { + let lines = try TTMLParser.parse(try fixture("ttml-synced.ttml")) + XCTAssertEqual(lines.count, 3) + XCTAssertEqual(lines[0].words, "First line of lyrics") + XCTAssertEqual(lines[0].startTimeMS, 12500) + XCTAssertEqual(lines[1].startTimeMS, 14500) + XCTAssertEqual(lines[2].startTimeMS, 16300) + } + + func test_plain_returnsLinesWithZeroTimestamps() throws { + let lines = try TTMLParser.parse(try fixture("ttml-plain.ttml")) + XCTAssertEqual(lines.count, 2) + XCTAssertEqual(lines[0].startTimeMS, 0) + XCTAssertEqual(lines[1].startTimeMS, 0) + } + + func test_malformed_throws() throws { + XCTAssertThrowsError(try TTMLParser.parse(try fixture("ttml-malformed.xml"))) { error in + XCTAssertEqual(error as? TTMLParserError, .invalidXML) + } + } + + func test_syncedWithSpans_assemblesLineText() throws { + let lines = try TTMLParser.parse(try fixture("ttml-synced-spans.ttml")) + XCTAssertEqual(lines.count, 1) + XCTAssertEqual(lines[0].startTimeMS, 20000) + // XMLElement.stringValue concatenates text nodes (including whitespace + // between spans), so we expect "Hello world again" or similar. + let text = lines[0].words + XCTAssertTrue(text.contains("Hello")) + XCTAssertTrue(text.contains("world")) + XCTAssertTrue(text.contains("again")) + XCTAssertFalse(text.contains("<"), "no XML should leak through") + } +}