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 []
}
}
63 changes: 63 additions & 0 deletions LyricFever/LyricProvider/AppleMusic/TTMLParser.swift
Original file line number Diff line number Diff line change
@@ -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 <p> 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())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>Lyrics 2.xcdatamodel</string>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24299" systemVersion="25A5349a" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="IDToColor" representedClassName="IDToColor" syncable="YES" codeGenerationType="class">
<attribute name="id" attributeType="String"/>
<attribute name="songColor" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="PersistentIDToSpotify" representedClassName="PersistentIDToSpotify" syncable="YES" codeGenerationType="class">
<attribute name="persistentID" optional="YES" attributeType="String"/>
<attribute name="spotifyID" optional="YES" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="persistentID"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="SongObject" representedClassName="SongObject" syncable="YES">
<attribute name="downloadDate" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/>
<attribute name="lyricsTimestamps" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[TimeInterval]"/>
<attribute name="lyricsWords" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[String]"/>
<attribute name="title" attributeType="String"/>
<attribute name="appleMusicID" optional="YES" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="SongToLocale" representedClassName="SongToLocale" syncable="YES" codeGenerationType="class">
<attribute name="id" attributeType="String"/>
<attribute name="locale" attributeType="String"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>
1 change: 1 addition & 0 deletions LyricFever/Models/CoreData/SongObjectExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

}

Expand Down
11 changes: 10 additions & 1 deletion LyricFever/Players/AppleMusic/AppleMusicPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading