Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Lyric Fever.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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" */ = {
Expand Down
65 changes: 61 additions & 4 deletions LyricFever/LyricFever.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ struct LyricFever: App {
.animation(.easeIn(duration: 0.2))
.environment(viewmodel)
}
.modifier(AppleMusicAuthModifier(viewmodel: viewmodel, openWindow: openWindow))
.onAppear {
viewmodel.onAppear(openWindow)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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.")
}
}
}

Expand Down
35 changes: 35 additions & 0 deletions LyricFever/LyricProvider/AppleMusic/AppleMusicAuthManager.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
110 changes: 110 additions & 0 deletions LyricFever/LyricProvider/AppleMusic/AppleMusicLyricProvider.swift
Original file line number Diff line number Diff line change
@@ -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": "<?xml..." } } ] }
struct LyricsEnvelope: Decodable {
struct Datum: Decodable {
struct Attributes: Decodable {
let ttml: String?
}
let attributes: Attributes
}
let data: [Datum]
}

let envelope: LyricsEnvelope
do {
envelope = try JSONDecoder().decode(LyricsEnvelope.self, from: response.data)
} catch {
print("AppleMusicLyricProvider: JSON decode error: \(error)")
return NetworkFetchReturn(lyrics: [], colorData: nil)
}

guard let ttmlString = envelope.data.first?.attributes.ttml,
let ttmlData = ttmlString.data(using: .utf8) else {
print("AppleMusicLyricProvider: no TTML in response for trackID=\(trackID)")
return NetworkFetchReturn(lyrics: [], colorData: nil)
}

let lines: [LyricLine]
do {
lines = try TTMLParser.parse(ttmlData)
} catch {
print("AppleMusicLyricProvider: TTMLParser error: \(error)")
return NetworkFetchReturn(lyrics: [], colorData: nil)
}

guard !lines.isEmpty else {
print("AppleMusicLyricProvider: TTMLParser returned 0 lines (instrumental?) for trackID=\(trackID)")
return NetworkFetchReturn(lyrics: [], colorData: nil)
}

print("AppleMusicLyricProvider: \(lines.count) lines for trackID=\(trackID)")
return NetworkFetchReturn(lyrics: lines, colorData: nil)
}

func search(trackName: String, artistName: String) async throws -> [SongResult] {
return []
}
}
135 changes: 135 additions & 0 deletions LyricFever/LyricProvider/AppleMusic/AppleMusicPrefetcher.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
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()
}
}
Loading