diff --git a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift index 5dc1dc1..5920acb 100644 --- a/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift +++ b/Sources/EeveeSpotify/DataLoaderServiceHooks.x.swift @@ -5,17 +5,71 @@ class SPTDataLoaderServiceHook: ClassHook { static let targetName = "SPTDataLoaderService" + // orion:new + func shouldModify(_ url: URL) -> Bool { + let isModifyingCustomizeResponse = UserDefaults.patchType == .requests + return url.isLyrics || (url.isCustomize && isModifyingCustomizeResponse) + } + func URLSession( _ session: URLSession, task: URLSessionDataTask, didCompleteWithError error: Error? ) { - if let url = task.currentRequest?.url { - if url.isLyrics || (UserDefaults.patchType == .requests && url.isCustomize) { - return + guard + let request = task.currentRequest, + let url = request.url + else { + return + } + + if error == nil && shouldModify(url) { + + if let buffer = URLSessionHelper.shared.obtainData(for: url) { + + if url.isLyrics { + + do { + orig.URLSession( + session, + dataTask: task, + didReceiveData: try getCurrentTrackLyricsData( + originalLyrics: try? Lyrics(serializedData: buffer) + ) + ) + + orig.URLSession(session, task: task, didCompleteWithError: nil) + } + catch { + orig.URLSession(session, task: task, didCompleteWithError: error) + } + + return + } + + do { + var customizeMessage = try CustomizeMessage(serializedData: buffer) + modifyRemoteConfiguration(&customizeMessage.response) + + orig.URLSession( + session, + dataTask: task, + didReceiveData: try customizeMessage.serializedData() + ) + + orig.URLSession(session, task: task, didCompleteWithError: nil) + + NSLog("[EeveeSpotify] Modified customize data") + return + } + catch { + NSLog("[EeveeSpotify] Unable to modify customize data: \(error)") + } } } + orig.URLSession(session, task: task, didCompleteWithError: error) + } func URLSession( @@ -25,7 +79,7 @@ class SPTDataLoaderServiceHook: ClassHook { completionHandler handler: Any ) { let url = response.url! - + if url.isLyrics, response.statusCode != 200 { let okResponse = HTTPURLResponse( @@ -34,17 +88,17 @@ class SPTDataLoaderServiceHook: ClassHook { httpVersion: "2.0", headerFields: [:] )! - + do { let lyricsData = try getCurrentTrackLyricsData() - + orig.URLSession( session, dataTask: task, didReceiveResponse: okResponse, completionHandler: handler ) - + orig.URLSession(session, dataTask: task, didReceiveData: lyricsData) orig.URLSession(session, task: task, didCompleteWithError: nil) @@ -73,63 +127,14 @@ class SPTDataLoaderServiceHook: ClassHook { ) { guard let request = task.currentRequest, - let response = task.response, let url = request.url else { return } - if url.isLyrics { - - do { - orig.URLSession( - session, - dataTask: task, - didReceiveData: try getCurrentTrackLyricsData( - originalLyrics: try? Lyrics(serializedData: data) - ) - ) - - orig.URLSession(session, task: task, didCompleteWithError: nil) - return - } - catch { - NSLog("[EeveeSpotify] Unable to load lyrics: \(error)") - orig.URLSession(session, task: task, didCompleteWithError: error) - - return - } - } - - if url.isCustomize && UserDefaults.patchType == .requests { - - do { - guard let buffer = OfflineHelper.appendDataAndReturnIfFull( - data, - response: response - ) else { - return - } - - OfflineHelper.dataBuffer = Data() - - var customizeMessage = try CustomizeMessage(serializedData: buffer) - modifyRemoteConfiguration(&customizeMessage.response) - - orig.URLSession( - session, - dataTask: task, - didReceiveData: try customizeMessage.serializedData() - ) - - orig.URLSession(session, task: task, didCompleteWithError: nil) - - NSLog("[EeveeSpotify] Modified customize data") - return - } - catch { - NSLog("[EeveeSpotify] Unable to modify customize data: \(error)") - } + if shouldModify(url) { + URLSessionHelper.shared.setOrAppend(data, for: url) + return } orig.URLSession(session, dataTask: task, didReceiveData: data) diff --git a/Sources/EeveeSpotify/Helpers/URLSessionHelper.swift b/Sources/EeveeSpotify/Helpers/URLSessionHelper.swift new file mode 100644 index 0000000..e818607 --- /dev/null +++ b/Sources/EeveeSpotify/Helpers/URLSessionHelper.swift @@ -0,0 +1,23 @@ +import UIKit + +class URLSessionHelper { + + static let shared = URLSessionHelper() + + private var requestsMap: [URL:Data] + + private init() { + self.requestsMap = [:] + } + + func setOrAppend(_ data: Data, for url: URL) { + var loadedData = requestsMap[url] ?? Data() + loadedData.append(data) + + requestsMap[url] = loadedData + } + + func obtainData(for url: URL) -> Data? { + return requestsMap.removeValue(forKey: url) + } +} diff --git a/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift b/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift index 498c51a..ac77481 100644 --- a/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift +++ b/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift @@ -5,11 +5,11 @@ class SPTPlayerTrackHook: ClassHook { static let targetName = "SPTPlayerTrack" - func setMetadata(_ metadata: [String:String]) { - var meta = metadata - + func metadata() -> [String:String] { + var meta = orig.metadata() + meta["has_lyrics"] = "true" - orig.setMetadata(meta) + return meta } } @@ -29,24 +29,24 @@ class EncoreButtonHook: ClassHook { } func getCurrentTrackLyricsData(originalLyrics: Lyrics? = nil) throws -> Data { - + guard let track = HookedInstances.currentTrack else { throw LyricsError.NoCurrentTrack } - + var source = UserDefaults.lyricsSource - + let plainLyrics: PlainLyrics? - + do { plainLyrics = try LyricsRepository.getLyrics( - title: track.trackTitle(), - artist: track.artistTitle(), + title: track.trackTitle(), + artist: track.artistTitle(), spotifyTrackId: track.URI().spt_trackIdentifier(), source: source ) } - + catch let error as LyricsError { switch error { @@ -67,10 +67,10 @@ func getCurrentTrackLyricsData(originalLyrics: Lyrics? = nil) throws -> Data { if source == .genius || !UserDefaults.geniusFallback { throw error } - + NSLog("[EeveeSpotify] Unable to load lyrics from \(source): \(error), trying Genius as fallback") source = .genius - + plainLyrics = try LyricsRepository.getLyrics( title: track.trackTitle(), artist: track.artistTitle(), @@ -80,13 +80,28 @@ func getCurrentTrackLyricsData(originalLyrics: Lyrics? = nil) throws -> Data { } let lyrics = try Lyrics.with { - $0.colors = originalLyrics?.colors ?? LyricsColors.with { - $0.backgroundColor = Color(hex: track.extractedColorHex()).normalized.uInt32 - $0.lineColor = Color.black.uInt32 - $0.activeLineColor = Color.white.uInt32 - } + $0.colors = getLyricsColors() $0.data = try LyricsHelper.composeLyricsData(plainLyrics!, source: source) } return try lyrics.serializedData() + + func getLyricsColors() -> LyricsColors { + + let lyricsColorsSettings = UserDefaults.lyricsColors + + if lyricsColorsSettings.displayOriginalColors, let originalLyrics = originalLyrics { + return originalLyrics.colors + } + + return LyricsColors.with { + $0.backgroundColor = lyricsColorsSettings.useStaticColor + ? Color(hex: lyricsColorsSettings.staticColor).uInt32 + : Color(hex: track.extractedColorHex()) + .normalized(lyricsColorsSettings.normalizationFactor) + .uInt32 + $0.lineColor = Color.black.uInt32 + $0.activeLineColor = Color.white.uInt32 + } + } } diff --git a/Sources/EeveeSpotify/Lyrics/Helpers/LyricsHelper.swift b/Sources/EeveeSpotify/Lyrics/Helpers/LyricsHelper.swift index 82a14bd..f191796 100644 --- a/Sources/EeveeSpotify/Lyrics/Helpers/LyricsHelper.swift +++ b/Sources/EeveeSpotify/Lyrics/Helpers/LyricsHelper.swift @@ -54,10 +54,6 @@ class LyricsHelper { captures[name] = String(line[substringRange]) } } - - if captures.count != 3 { - return nil - } let minute = Int(captures["minute"]!)! let seconds = Float(captures["seconds"]!)! diff --git a/Sources/EeveeSpotify/Lyrics/Models/LyricsColorsSettings.swift b/Sources/EeveeSpotify/Lyrics/Models/LyricsColorsSettings.swift new file mode 100644 index 0000000..d695783 --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Models/LyricsColorsSettings.swift @@ -0,0 +1,8 @@ +import Foundation + +struct LyricsColorsSettings: Codable, Equatable { + var displayOriginalColors: Bool + var useStaticColor: Bool + var staticColor: String + var normalizationFactor: CGFloat +} diff --git a/Sources/EeveeSpotify/Models/Extensions/Color+Extension.swift b/Sources/EeveeSpotify/Models/Extensions/Color+Extension.swift index 818bf96..6cdb24d 100644 --- a/Sources/EeveeSpotify/Models/Extensions/Color+Extension.swift +++ b/Sources/EeveeSpotify/Models/Extensions/Color+Extension.swift @@ -53,10 +53,19 @@ extension Color { ) / 1000 } - var normalized: Color { - brightness < 0.5 - ? self.lighter(by: 0.5 - brightness) - : self.darker(by: brightness - 0.5) + func normalized(_ by: CGFloat) -> Color { + brightness < 0.5 + ? self.lighter(by: max(by - brightness, 0)) + : self.darker(by: max(brightness - by, 0)) + } + + var hexString: String { + String( + format: "%02X%02X%02X", + Int(components.red * 255), + Int(components.green * 255), + Int(components.blue * 255) + ) } var uInt32: UInt32 { diff --git a/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift b/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift index 04b17c5..196b851 100644 --- a/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift +++ b/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift @@ -10,6 +10,7 @@ extension UserDefaults { private static let darkPopUpsKey = "darkPopUps" private static let patchTypeKey = "patchType" private static let overwriteConfigurationKey = "overwriteConfiguration" + private static let lyricsColorsKey = "lyricsColors" static var lyricsSource: LyricsSource { get { @@ -72,4 +73,22 @@ extension UserDefaults { defaults.set(overwriteConfiguration, forKey: overwriteConfigurationKey) } } + + static var lyricsColors: LyricsColorsSettings { + get { + if let data = defaults.object(forKey: lyricsColorsKey) as? Data { + return try! JSONDecoder().decode(LyricsColorsSettings.self, from: data) + } + + return LyricsColorsSettings( + displayOriginalColors: true, + useStaticColor: false, + staticColor: "", + normalizationFactor: 0.5 + ) + } + set (lyricsColors) { + defaults.set(try! JSONEncoder().encode(lyricsColors), forKey: lyricsColorsKey) + } + } } diff --git a/Sources/EeveeSpotify/Premium/DynamicPremium.x.swift b/Sources/EeveeSpotify/Premium/DynamicPremium.x.swift index ffea8c4..fda4f7f 100644 --- a/Sources/EeveeSpotify/Premium/DynamicPremium.x.swift +++ b/Sources/EeveeSpotify/Premium/DynamicPremium.x.swift @@ -1,6 +1,6 @@ import Orion -func showHavePremiumPopUp() { +private func showHavePremiumPopUp() { PopUpHelper.showPopUp( delayed: true, message: "It looks like you have an active Premium subscription, so the tweak won't patch the data or restrict the use of Premium server-sided features. You can manage this in the EeveeSpotify settings.", @@ -8,18 +8,6 @@ func showHavePremiumPopUp() { ) } -func showOfflineBnkMethodSetPopUp() { - PopUpHelper.showPopUp( - delayed: true, - message: "App restart is needed to get Premium. You can manage the Premium patching method in the EeveeSpotify settings.", - buttonText: "Restart Now", - secondButtonText: "Restart Later", - onPrimaryClick: { - exitApplication() - } - ) -} - class SPTCoreURLSessionDataDelegateHook: ClassHook { static let targetName = "SPTCoreURLSessionDataDelegate" @@ -29,37 +17,18 @@ class SPTCoreURLSessionDataDelegateHook: ClassHook { task: URLSessionDataTask, didCompleteWithError error: Error? ) { - if let url = task.currentRequest?.url, UserDefaults.patchType == .requests && url.isBootstrap { - return - } - orig.URLSession(session, task: task, didCompleteWithError: error) - } - - func URLSession( - _ session: URLSession, - dataTask task: URLSessionDataTask, - didReceiveData data: Data - ) { - guard + guard let request = task.currentRequest, - let response = task.response, let url = request.url else { return } - - if url.isBootstrap { - + + if error == nil && url.isBootstrap { + + let buffer = URLSessionHelper.shared.obtainData(for: url)! + do { - guard let buffer = OfflineHelper.appendDataAndReturnIfFull( - data, - response: response - ) else { - return - } - - OfflineHelper.dataBuffer = Data() - var bootstrapMessage = try BootstrapMessage(serializedData: buffer) if UserDefaults.patchType == .notSet { @@ -98,6 +67,28 @@ class SPTCoreURLSessionDataDelegateHook: ClassHook { NSLog("[EeveeSpotify] Unable to modify bootstrap data: \(error)") } } + + orig.URLSession(session, task: task, didCompleteWithError: error) + } + + func URLSession( + _ session: URLSession, + dataTask task: URLSessionDataTask, + didReceiveData data: Data + ) { + guard + let request = task.currentRequest, + let url = request.url + else { + return + } + + let isModifyingBootstrapResponse = UserDefaults.patchType == .requests + + if url.isBootstrap && isModifyingBootstrapResponse { + URLSessionHelper.shared.setOrAppend(data, for: url) + return + } orig.URLSession(session, dataTask: task, didReceiveData: data) } diff --git a/Sources/EeveeSpotify/Premium/Helpers/OfflineHelper.swift b/Sources/EeveeSpotify/Premium/Helpers/OfflineHelper.swift index 78ec753..3334463 100755 --- a/Sources/EeveeSpotify/Premium/Helpers/OfflineHelper.swift +++ b/Sources/EeveeSpotify/Premium/Helpers/OfflineHelper.swift @@ -26,21 +26,6 @@ class OfflineHelper { get throws { try Data(contentsOf: eeveeBnkPath) } } - // - - static var dataBuffer = Data() - - static func appendDataAndReturnIfFull(_ data: Data, response: URLResponse) -> Data? { - - dataBuffer.append(data) - - if dataBuffer.count == response.expectedContentLength { - return dataBuffer - } - - return nil - } - // private static func writeOfflineBnkData(_ data: Data) throws { diff --git a/Sources/EeveeSpotify/Premium/ServerSidedReminder.x.swift b/Sources/EeveeSpotify/Premium/ServerSidedReminder.x.swift index bc059b4..9e8021e 100644 --- a/Sources/EeveeSpotify/Premium/ServerSidedReminder.x.swift +++ b/Sources/EeveeSpotify/Premium/ServerSidedReminder.x.swift @@ -21,7 +21,7 @@ class StreamQualitySettingsSectionHook: ClassHook { // -func showOfflineModePopUp() { +private func showOfflineModePopUp() { PopUpHelper.showPopUp( message: "Native playlist downloading is server-sided and is not available with this tweak. You can download podcast episodes though.", buttonText: "OK" diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+LyricsColorsSection.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+LyricsColorsSection.swift new file mode 100644 index 0000000..561d796 --- /dev/null +++ b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+LyricsColorsSection.swift @@ -0,0 +1,53 @@ +import SwiftUI + +extension EeveeSettingsView { + + @ViewBuilder func LyricsColorsSection() -> some View { + + Section( + header: Text("Lyrics Background Color"), + footer: Text(""" +If you turn on Display Original Colors, the lyrics will appear in the original Spotify colors for tracks that have them. + +You can set a static color or a normalization factor based on the extracted track cover's color. This factor determines how much dark colors are lightened and light colors are darkened. Generally, you will see lighter colors with a higher normalization factor. +""")) { + Toggle( + "Display Original Colors", + isOn: $lyricsColors.displayOriginalColors + ) + + Toggle( + "Use Static Color", + isOn: $lyricsColors.useStaticColor + ) + + if lyricsColors.useStaticColor { + ColorPicker( + "Static Color", + selection: Binding( + get: { Color(hex: lyricsColors.staticColor) }, + set: { lyricsColors.staticColor = $0.hexString } + ), + + supportsOpacity: false + ) + } + else { + VStack(alignment: .leading, spacing: 5) { + + Text("Color Normalization Factor") + + Slider( + value: $lyricsColors.normalizationFactor, + in: 0.2...0.8, + step: 0.1 + ) + } + } + } + + .onChange(of: lyricsColors) { newLyricsColors in + UserDefaults.lyricsColors = newLyricsColors + } + } +} diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+PremiumSections.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+PremiumSections.swift index f298ce0..f54dcd5 100644 --- a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+PremiumSections.swift +++ b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+PremiumSections.swift @@ -32,6 +32,30 @@ If you have an active Premium subscription, you can turn on Do Not Patch Premium } } + .onChange(of: patchType) { newPatchType in + + UserDefaults.patchType = newPatchType + + do { + try OfflineHelper.resetOfflineBnk() + } + catch { + NSLog("Unable to reset offline.bnk: \(error)") + } + } + + .onChange(of: overwriteConfiguration) { overwriteConfiguration in + + UserDefaults.overwriteConfiguration = overwriteConfiguration + + do { + try OfflineHelper.resetOfflineBnk() + } + catch { + NSLog("Unable to reset offline.bnk: \(error)") + } + } + if patchType == .requests { Section( footer: Text("Replace remote configuration with the dumped Premium one. It might fix some issues, such as appearing ads, but it's not guaranteed.") diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift index e04028b..e1dc140 100644 --- a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift +++ b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift @@ -7,50 +7,7 @@ struct EeveeSettingsView: View { @State var patchType = UserDefaults.patchType @State var lyricsSource = UserDefaults.lyricsSource @State var overwriteConfiguration = UserDefaults.overwriteConfiguration - - private func getMusixmatchToken(_ input: String) -> String? { - - if let match = input.firstMatch("\\[UserToken\\]: ([a-f0-9]+)"), - let tokenRange = Range(match.range(at: 1), in: input) { - return String(input[tokenRange]) - } - else if input ~= "^[a-f0-9]+$" { - return input - } - - return nil - } - - private func showMusixmatchTokenAlert(_ oldSource: LyricsSource) { - - let alert = UIAlertController( - title: "Enter User Token", - message: "In order to use Musixmatch, you need to retrieve your user token from the official app. Download Musixmatch from the App Store, sign up, then go to Settings > Get help > Copy debug info, and paste it here. You can also extract the token using MITM.", - preferredStyle: .alert - ) - - alert.addTextField() { textField in - textField.placeholder = "---- Debug Info ---- [Device]: iPhone" - } - - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in - lyricsSource = oldSource - }) - - alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in - let text = alert.textFields!.first!.text! - - guard let token = getMusixmatchToken(text) else { - lyricsSource = oldSource - return - } - - musixmatchToken = token - UserDefaults.lyricsSource = .musixmatch - }) - - WindowHelper.shared.present(alert) - } + @State var lyricsColors = UserDefaults.lyricsColors var body: some View { @@ -59,6 +16,8 @@ struct EeveeSettingsView: View { PremiumSections() LyricsSections() + + LyricsColorsSection() Section { Toggle( @@ -87,54 +46,8 @@ struct EeveeSettingsView: View { .animation(.default, value: lyricsSource) .animation(.default, value: patchType) - - .onChange(of: musixmatchToken) { input in - - if input.isEmpty { return } - - if let token = getMusixmatchToken(input) { - UserDefaults.musixmatchToken = token - self.musixmatchToken = token - } - else { - self.musixmatchToken = "" - } - } - - .onChange(of: lyricsSource) { [lyricsSource] newSource in - - if newSource == .musixmatch && musixmatchToken.isEmpty { - showMusixmatchTokenAlert(lyricsSource) - return - } - - UserDefaults.lyricsSource = newSource - } + .animation(.default, value: lyricsColors) - .onChange(of: patchType) { newPatchType in - - UserDefaults.patchType = newPatchType - - do { - try OfflineHelper.resetOfflineBnk() - } - catch { - NSLog("Unable to reset offline.bnk: \(error)") - } - } - - .onChange(of: overwriteConfiguration) { overwriteConfiguration in - - UserDefaults.overwriteConfiguration = overwriteConfiguration - - do { - try OfflineHelper.resetOfflineBnk() - } - catch { - NSLog("Unable to reset offline.bnk: \(error)") - } - } - .onAppear { UIView.appearance( whenContainedInInstancesOf: [UIAlertController.self] diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsViewController+LyricsSections.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsViewController+LyricsSections.swift index 5070322..e4e426a 100644 --- a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsViewController+LyricsSections.swift +++ b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsViewController+LyricsSections.swift @@ -2,6 +2,51 @@ import SwiftUI extension EeveeSettingsView { + private func getMusixmatchToken(_ input: String) -> String? { + + if let match = input.firstMatch("\\[UserToken\\]: ([a-f0-9]+)"), + let tokenRange = Range(match.range(at: 1), in: input) { + return String(input[tokenRange]) + } + else if input ~= "^[a-f0-9]+$" { + return input + } + + return nil + } + + private func showMusixmatchTokenAlert(_ oldSource: LyricsSource) { + + let alert = UIAlertController( + title: "Enter User Token", + message: "In order to use Musixmatch, you need to retrieve your user token from the official app. Download Musixmatch from the App Store, sign up, then go to Settings > Get help > Copy debug info, and paste it here. You can also extract the token using MITM.", + preferredStyle: .alert + ) + + alert.addTextField() { textField in + textField.placeholder = "---- Debug Info ---- [Device]: iPhone" + } + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + lyricsSource = oldSource + }) + + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in + let text = alert.textFields!.first!.text! + + guard let token = getMusixmatchToken(text) else { + lyricsSource = oldSource + return + } + + musixmatchToken = token + UserDefaults.lyricsSource = .musixmatch + }) + + WindowHelper.shared.present(alert) + } + + @ViewBuilder func LyricsSections() -> some View { Section(footer: Text(""" @@ -36,6 +81,29 @@ If the tweak is unable to find a song or process the lyrics, you'll see a "Could .frame(maxWidth: .infinity, alignment: .leading) } } + + .onChange(of: musixmatchToken) { input in + + if input.isEmpty { return } + + if let token = getMusixmatchToken(input) { + UserDefaults.musixmatchToken = token + self.musixmatchToken = token + } + else { + self.musixmatchToken = "" + } + } + + .onChange(of: lyricsSource) { [lyricsSource] newSource in + + if newSource == .musixmatch && musixmatchToken.isEmpty { + showMusixmatchTokenAlert(lyricsSource) + return + } + + UserDefaults.lyricsSource = newSource + } if lyricsSource != .genius { Section(