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/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..1424570
--- /dev/null
+++ b/LyricFever/Models/CoreData/Lyrics.xcdatamodeld/.xccurrentversion
@@ -0,0 +1,8 @@
+
+
+
+
+ _XCCurrentVersionName
+ Lyrics 2.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/SongObjectExtensions.swift b/LyricFever/Models/CoreData/SongObjectExtensions.swift
index 54e9007..b93c950 100644
--- a/LyricFever/Models/CoreData/SongObjectExtensions.swift
+++ b/LyricFever/Models/CoreData/SongObjectExtensions.swift
@@ -22,6 +22,7 @@ extension SongObject {
@NSManaged public var language: String
@NSManaged public var lyricsWords: [String]
@NSManaged public var lyricsTimestamps: [TimeInterval]
+ @NSManaged public var appleMusicID: 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/ViewModel.swift b/LyricFever/ViewModel.swift
index 7eb6658..6430bfa 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,63 @@ 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
+ }
+ }
+ // ── 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 +227,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 +319,23 @@ import MediaRemoteAdapter
var lRCLyricProvider = LRCLIBLyricProvider()
var netEaseLyricProvider = NetEaseLyricProvider()
#if os(macOS)
+ @ObservationIgnored lazy var appleMusicLyricProvider = 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 +428,19 @@ 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
saveCoreData()
return lyrics
} else if networkLyricProvider is SpotifyLyricProvider {
@@ -383,6 +453,7 @@ import MediaRemoteAdapter
print("Caught exception on \(networkLyricProvider.providerName): \(error)")
}
}
+ saveCoreData()
return NetworkFetchReturn(lyrics: [], colorData: nil)
}
@@ -921,7 +992,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/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")
+ }
+}