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