diff --git a/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift b/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift index 0107637..9bf2a15 100644 --- a/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift +++ b/Sources/EeveeSpotify/Lyrics/CustomLyrics.x.swift @@ -30,6 +30,7 @@ class EncoreButtonHook: ClassHook { // +private var lastLyricsLanguageLabel: String? = nil private var lastLyricsError: LyricsError? = nil private var hasShownRestrictedPopUp = false @@ -51,10 +52,6 @@ class LyricsOnlyViewControllerHook: ClassHook { orig.viewDidLoad() - if !UserDefaults.fallbackReasons { - return - } - guard let lyricsHeaderViewController = target.parent?.children.first else { @@ -83,18 +80,19 @@ class LyricsOnlyViewControllerHook: ClassHook { encoreLabel.text().firstObject ] - if let description = lastLyricsError?.description { - - let attributes = Dynamic.SPTEncoreAttributes - .alloc(interface: SPTEncoreAttributes.self) - .`init`({ attributes in - attributes.setForegroundColor(.white.withAlphaComponent(0.5)) - }) - - let typeStyle = type( - of: Dynamic.SPTEncoreTypeStyle.alloc(interface: SPTEncoreTypeStyle.self) - ).bodyMediumBold() - + let attributes = Dynamic.SPTEncoreAttributes + .alloc(interface: SPTEncoreAttributes.self) + .`init`({ attributes in + attributes.setForegroundColor(.white.withAlphaComponent(0.5)) + }) + + let typeStyle = type( + of: Dynamic.SPTEncoreTypeStyle.alloc(interface: SPTEncoreTypeStyle.self) + ).bodyMediumBold() + + // + + if UserDefaults.fallbackReasons, let description = lastLyricsError?.description { text.append( Dynamic.SPTEncoreAttributedString.alloc(interface: SPTEncoreAttributedString.self) .initWithString( @@ -103,10 +101,21 @@ class LyricsOnlyViewControllerHook: ClassHook { attributes: attributes ) ) - - if #unavailable(iOS 15.0) { - encoreLabel.setNumberOfLines(2) - } + } + + if let languageLabel = lastLyricsLanguageLabel { + text.append( + Dynamic.SPTEncoreAttributedString.alloc(interface: SPTEncoreAttributedString.self) + .initWithString( + "\n\(languageLabel)", + typeStyle: typeStyle, + attributes: attributes + ) + ) + } + + if #unavailable(iOS 15.0) { + encoreLabel.setNumberOfLines(text.count) } encoreLabel.setText(text as NSArray) @@ -133,7 +142,7 @@ func getCurrentTrackLyricsData(originalLyrics: Lyrics? = nil) throws -> Data { var repository: LyricsRepository = switch source { case .genius: GeniusLyricsRepository() case .lrclib: LrcLibLyricsRepository() - case .musixmatch: MusixmatchLyricsRepository() + case .musixmatch: MusixmatchLyricsRepository.shared } let lyricsDto: LyricsDto @@ -192,6 +201,10 @@ func getCurrentTrackLyricsData(originalLyrics: Lyrics? = nil) throws -> Data { lyricsDto = try repository.getLyrics(searchQuery, options: options) } + + lastLyricsLanguageLabel = lyricsDto.romanized + ? "Romanized" + : Locale.current.localizedString(forLanguageCode: lyricsDto.translatedTo ?? "") let lyrics = Lyrics.with { $0.colors = getLyricsColors() diff --git a/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift b/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift index 1e7cd57..733e25a 100644 --- a/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift +++ b/Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift @@ -3,6 +3,8 @@ import Foundation struct LyricsDto { var lines: [LyricsLineDto] var timeSynced: Bool + var romanized: Bool = false + var translatedTo: String? = nil func toLyricsData(source: String) -> LyricsData { return LyricsData.with { diff --git a/Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsOptions.swift b/Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsOptions.swift index 5b2da4f..7399edf 100644 --- a/Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsOptions.swift +++ b/Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsOptions.swift @@ -1,6 +1,6 @@ import Foundation struct LyricsOptions: Codable, Equatable { - var geniusRomanizations: Bool - var musixmatchRomanizations: Bool + var romanization: Bool + var musixmatchLanguage: String } diff --git a/Sources/EeveeSpotify/Lyrics/Repositories/GeniusLyricsRepository.swift b/Sources/EeveeSpotify/Lyrics/Repositories/GeniusLyricsRepository.swift index 86127d7..bb252d7 100644 --- a/Sources/EeveeSpotify/Lyrics/Repositories/GeniusLyricsRepository.swift +++ b/Sources/EeveeSpotify/Lyrics/Repositories/GeniusLyricsRepository.swift @@ -87,7 +87,8 @@ struct GeniusLyricsRepository: LyricsRepository { private func mostRelevantHitResult( hits: [GeniusHit], strippedTitle: String, - romanized: Bool + romanized: Bool, + hasFoundRomanizedLyrics: inout Bool ) -> GeniusHitResult { let results = hits.map { $0.result } @@ -102,6 +103,7 @@ struct GeniusLyricsRepository: LyricsRepository { if romanized, let romanizedSong = matchingByTitle.first( where: { $0.artistNames == "Genius Romanizations" } ) { + hasFoundRomanizedLyrics = true return romanizedSong } @@ -131,10 +133,13 @@ struct GeniusLyricsRepository: LyricsRepository { throw LyricsError.NoSuchSong } + var hasFoundRomanizedLyrics = false + let song = mostRelevantHitResult( hits: hits, strippedTitle: strippedTitle, - romanized: options.geniusRomanizations + romanized: options.romanization, + hasFoundRomanizedLyrics: &hasFoundRomanizedLyrics ) let songInfo = try getSongInfo(song.id) @@ -142,7 +147,8 @@ struct GeniusLyricsRepository: LyricsRepository { return LyricsDto( lines: mapLyricsLines(plainLines).map { line in LyricsLineDto(content: line) }, - timeSynced: false + timeSynced: false, + romanized: hasFoundRomanizedLyrics ) } } diff --git a/Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift b/Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift index c2c4acc..f51f415 100644 --- a/Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift +++ b/Sources/EeveeSpotify/Lyrics/Repositories/MusixmatchLyricsRepository.swift @@ -1,12 +1,23 @@ import Foundation import UIKit -struct MusixmatchLyricsRepository: LyricsRepository { +class MusixmatchLyricsRepository: LyricsRepository { + private let apiUrl = "https://apic.musixmatch.com" + + var selectedLanguage: String + + static let shared = MusixmatchLyricsRepository( + language: UserDefaults.lyricsOptions.musixmatchLanguage + ) + + private init(language: String) { + selectedLanguage = language + } private func perform( _ path: String, - query: [String:Any] = [:] + query: [String: Any] = [:] ) throws -> Data { var stringUrl = "\(apiUrl)\(path)" @@ -46,19 +57,9 @@ struct MusixmatchLyricsRepository: LyricsRepository { return data! } - func getLyrics(_ query: LyricsSearchQuery, options: LyricsOptions) throws -> LyricsDto { - - let data = try perform( - "/ws/1.1/macro.subtitles.get", - query: [ - "track_spotify_id": query.spotifyTrackId, - "subtitle_format": "mxm", - "q_track": " " - ] - ) - - // 😭😭😭 - + // + + private func getMacroCalls(_ data: Data) throws -> [String: Any] { guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let message = json["message"] as? [String: Any], @@ -72,115 +73,166 @@ struct MusixmatchLyricsRepository: LyricsRepository { header["status_code"] as? Int == 401 { throw LyricsError.InvalidMusixmatchToken } - - if let trackSubtitlesGet = macroCalls["track.subtitles.get"] as? [String: Any], - let subtitlesMessage = trackSubtitlesGet["message"] as? [String: Any], - let subtitlesHeader = subtitlesMessage["header"] as? [String: Any], - let subtitlesStatusCode = subtitlesHeader["status_code"] as? Int { + + return macroCalls + } + + private func getFirstSubtitle(_ subtitlesMessage: [String: Any]) throws -> [String: Any] { + guard + let subtitlesHeader = subtitlesMessage["header"] as? [String: Any], + let subtitlesStatusCode = subtitlesHeader["status_code"] as? Int + else { + throw LyricsError.DecodingError + } + + guard + let subtitlesBody = subtitlesMessage["body"] as? [String: Any], + let subtitleList = subtitlesBody["subtitle_list"] as? [[String: Any]], + let firstSubtitle = subtitleList.first, + let subtitle = firstSubtitle["subtitle"] as? [String: Any] + else { + throw LyricsError.DecodingError + } - if subtitlesStatusCode == 404 { - throw LyricsError.NoSuchSong - } - - if let subtitlesBody = subtitlesMessage["body"] as? [String: Any], - let subtitleList = subtitlesBody["subtitle_list"] as? [[String: Any]], - let firstSubtitle = subtitleList.first, - let subtitle = firstSubtitle["subtitle"] as? [String: Any] { - - if let restricted = subtitle["restricted"] as? Bool, restricted { - throw LyricsError.MusixmatchRestricted - } - - if let subtitleBody = subtitle["subtitle_body"] as? String { - - guard let subtitles = try? JSONDecoder().decode( - [MusixmatchSubtitle].self, - from: subtitleBody.data(using: .utf8)! - ).dropLast() else { - throw LyricsError.DecodingError - } - - if !UserDefaults.lyricsOptions.musixmatchRomanizations { - return LyricsDto( - lines: subtitles.map { subtitle in - LyricsLineDto( - content: subtitle.text.lyricsNoteIfEmpty, - offsetMs: Int(subtitle.time.total * 1000) - ) - }, - timeSynced: true - ) - } else { - do { - let subtitleLang = subtitle["subtitle_language"] as? String ?? "" - let romajiLang = "r\(subtitleLang.prefix(1))" - - let romajiData = try perform( - "/ws/1.1/crowd.track.translations.get", - query: [ - "track_spotify_id": query.spotifyTrackId, - "selected_language": romajiLang - ] - ) - - guard - let romajiJson = try? JSONSerialization.jsonObject(with: romajiData, options: []) as? [String: Any], - let romajiMessage = romajiJson["message"] as? [String: Any], - let romajiBody = romajiMessage["body"] as? [String: Any], - let translationsList = romajiBody["translations_list"] as? [[String: Any]] - else { - throw LyricsError.DecodingError - } - - var translationDict: [String: String] = [:] - - for translation in translationsList { - if let translationInfo = translation["translation"] as? [String: Any], - let translationMatch = translationInfo["subtitle_matched_line"] as? String, - let translationString = translationInfo["description"] as? String { - if translationMatch != translationString { - translationDict[translationMatch] = translationString - } - - } - } - - let modifiedSubtitles = subtitles.map { subtitle in - var modifiedText = subtitle.text - for (translationMatch, translationString) in translationDict { - modifiedText = modifiedText.replacingOccurrences(of: translationMatch, with: translationString) - } - return MusixmatchSubtitle( - text: modifiedText, - time: subtitle.time - ) - } - - return LyricsDto( - lines: modifiedSubtitles.map { subtitle in - LyricsLineDto( - content: subtitle.text.lyricsNoteIfEmpty, - offsetMs: Int(subtitle.time.total * 1000) - ) - }, - timeSynced: true - ) - } catch { - return LyricsDto( - lines: subtitles.map { subtitle in - LyricsLineDto( - content: subtitle.text.lyricsNoteIfEmpty, - offsetMs: Int(subtitle.time.total * 1000) - ) - }, - timeSynced: true - ) - } - } - } - } + if let restricted = subtitle["restricted"] as? Bool, restricted { + throw LyricsError.MusixmatchRestricted + } + + return subtitle + } + + // + + private func getTranslations(_ spotifyTrackId: String, selectedLanguage: String) throws -> [String: String] { + let data = try perform( + "/ws/1.1/crowd.track.translations.get", + query: [ + "track_spotify_id": spotifyTrackId, + "selected_language": selectedLanguage + ] + ) + + guard + let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let message = json["message"] as? [String: Any], + let body = message["body"] as? [String: Any], + let translationsList = body["translations_list"] as? [[String: Any]] + else { + throw LyricsError.DecodingError } + let translations = translationsList.map { + $0["translation"] as! [String: Any] + } + + return Dictionary(uniqueKeysWithValues: translations.map { + ($0["subtitle_matched_line"] as! String, $0["description"] as! String) + }) + } + + // + + func getLyrics(_ query: LyricsSearchQuery, options: LyricsOptions) throws -> LyricsDto { + + var musixmatchQuery = [ + "track_spotify_id": query.spotifyTrackId, + "subtitle_format": "mxm", + "q_track": " " + ] + + if !selectedLanguage.isEmpty { + musixmatchQuery["selected_language"] = selectedLanguage + musixmatchQuery["part"] = "subtitle_translated" + } + + let data = try perform( + "/ws/1.1/macro.subtitles.get", + query: musixmatchQuery + ) + + // 😭😭😭 + + var romanized = false + var translatedTo: String? = nil + + let macroCalls = try getMacroCalls(data) + + if let trackSubtitlesGet = macroCalls["track.subtitles.get"] as? [String: Any], + let subtitlesMessage = trackSubtitlesGet["message"] as? [String: Any], + let subtitle = try? getFirstSubtitle(subtitlesMessage), + let subtitleLanguage = subtitle["subtitle_language"] as? String, + let subtitleBody = subtitle["subtitle_body"] as? String, + let subtitles = try? JSONDecoder().decode( + [MusixmatchSubtitle].self, from: subtitleBody.data(using: .utf8)! + ).dropLast() { + + var lyricsLines: [LyricsLineDto] + let romanizationLanguage = "r\(subtitleLanguage.prefix(1))" + + if let subtitleTranslated = subtitle["subtitle_translated"] as? [String: Any], + let subtitleTranslatedBody = subtitleTranslated["subtitle_body"] as? String, + let subtitlesTranslated = try? JSONDecoder().decode( + [MusixmatchSubtitle].self, from: subtitleTranslatedBody.data(using: .utf8)! + ).dropLast() { + + lyricsLines = subtitlesTranslated.enumerated().map { (index, subtitleTranslated) in + let content = subtitleTranslated.text.isEmpty + ? subtitles[index].text + : subtitleTranslated.text + + return LyricsLineDto( + content: content.lyricsNoteIfEmpty, + offsetMs: Int(subtitleTranslated.time.total * 1000) + ) + } + + if selectedLanguage == romanizationLanguage { + romanized = true + } + else { + translatedTo = selectedLanguage + } + } + else { + lyricsLines = subtitles.map { subtitle in + LyricsLineDto( + content: subtitle.text.lyricsNoteIfEmpty, + offsetMs: Int(subtitle.time.total * 1000) + ) + } + } + + if options.musixmatchLanguage.isEmpty + && options.romanization + && selectedLanguage != romanizationLanguage { + + selectedLanguage = romanizationLanguage + + if let translations = try? getTranslations( + query.spotifyTrackId, + selectedLanguage: romanizationLanguage + ) { + for (original, translation) in translations { + + for i in 0.. UIImage { + return UIImage( + contentsOfFile: self.bundle.path( + forResource: name, + ofType: "png" + )! + )! + } func premiumBlankData() throws -> Data { return try Data( diff --git a/Sources/EeveeSpotify/Settings/EeveeSettings.x.swift b/Sources/EeveeSpotify/Settings/EeveeSettings.x.swift index 3af659c..5c17c14 100644 --- a/Sources/EeveeSpotify/Settings/EeveeSettings.x.swift +++ b/Sources/EeveeSpotify/Settings/EeveeSettings.x.swift @@ -1,4 +1,5 @@ import Orion +import SwiftUI import UIKit class ProfileSettingsSectionHook: ClassHook { @@ -16,10 +17,40 @@ class ProfileSettingsSectionHook: ClassHook { let rootSettingsController = WindowHelper.shared.findFirstViewController( "RootSettingsViewController" )! - - let eeveeSettingsController = EeveeSettingsViewController(rootSettingsController.view.bounds) - rootSettingsController.navigationController!.pushViewController( + let navigationController = rootSettingsController.navigationController! + + let eeveeSettingsController = EeveeSettingsViewController( + rootSettingsController.view.bounds, + settingsView: AnyView(EeveeSettingsView(navigationController: navigationController)), + navigationTitle: "EeveeSpotify" + ) + + // + + let button = UIButton() + + button.setImage( + BundleHelper.shared.uiImage("github").withRenderingMode(.alwaysOriginal), + for: .normal + ) + + button.addTarget( + eeveeSettingsController, + action: #selector(eeveeSettingsController.openRepositoryUrl(_:)), + for: .touchUpInside + ) + + // + + let menuBarItem = UIBarButtonItem(customView: button) + + menuBarItem.customView?.heightAnchor.constraint(equalToConstant: 22).isActive = true + menuBarItem.customView?.widthAnchor.constraint(equalToConstant: 22).isActive = true + + eeveeSettingsController.navigationItem.rightBarButtonItem = menuBarItem + + navigationController.pushViewController( eeveeSettingsController, animated: true ) diff --git a/Sources/EeveeSpotify/Settings/ViewControllers/EeveeSettingsViewController.swift b/Sources/EeveeSpotify/Settings/ViewControllers/EeveeSettingsViewController.swift index ffc7dd5..7dde787 100644 --- a/Sources/EeveeSpotify/Settings/ViewControllers/EeveeSettingsViewController.swift +++ b/Sources/EeveeSpotify/Settings/ViewControllers/EeveeSettingsViewController.swift @@ -4,10 +4,14 @@ import UIKit class EeveeSettingsViewController: SPTPageViewController { let frame: CGRect + let settingsView: AnyView - init(_ frame: CGRect) { + init(_ frame: CGRect, settingsView: AnyView, navigationTitle: String) { self.frame = frame + self.settingsView = settingsView super.init(nibName: nil, bundle: nil) + + title = navigationTitle } required init?(coder: NSCoder) { @@ -17,13 +21,15 @@ class EeveeSettingsViewController: SPTPageViewController { override func viewDidLoad() { super.viewDidLoad() - self.title = "EeveeSpotify" - - let hostingController = UIHostingController(rootView: EeveeSettingsView()) + let hostingController = UIHostingController(rootView: settingsView) hostingController.view.frame = frame view.addSubview(hostingController.view) addChild(hostingController) hostingController.didMove(toParent: self) } + + @objc func openRepositoryUrl(_ sender: UIButton) { + UIApplication.shared.open(URL(string: "https://github.com/whoeevee/EeveeSpotify")!) + } } diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+LyricsColorsSection.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+LyricsColorsSection.swift deleted file mode 100644 index e679b49..0000000 --- a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+LyricsColorsSection.swift +++ /dev/null @@ -1,52 +0,0 @@ -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) { lyricsColors in - UserDefaults.lyricsColors = lyricsColors - } - } -} diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+PremiumSections.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+PremiumSections.swift deleted file mode 100644 index f54dcd5..0000000 --- a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView+PremiumSections.swift +++ /dev/null @@ -1,70 +0,0 @@ -import SwiftUI - -extension EeveeSettingsView { - - @ViewBuilder func PremiumSections() -> some View { - - Section(footer: patchType == .disabled ? nil : Text(""" -You can select the Premium patching method you prefer. App restart is required after changing. - -Static: The original method. On app start, the tweak composes cache data by inserting your username into a blank file with preset Premium parameters. When Spotify reloads user data, you'll be switched to the Free plan and see a popup with quick restart app and reset data actions. - -Dynamic: This method intercepts requests to load user data, deserializes it, and modifies the parameters in real-time. It's much more stable and is recommended. - -If you have an active Premium subscription, you can turn on Do Not Patch Premium. The tweak won't patch the data or restrict the use of Premium server-sided features. -""")) { - Toggle( - "Do Not Patch Premium", - isOn: Binding( - get: { patchType == .disabled }, - set: { patchType = $0 ? .disabled : .requests } - ) - ) - - if patchType != .disabled { - Picker( - "Patching Method", - selection: $patchType - ) { - Text("Static").tag(PatchType.offlineBnk) - Text("Dynamic").tag(PatchType.requests) - } - } - } - - .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.") - ) { - Toggle( - "Overwrite Configuration", - isOn: $overwriteConfiguration - ) - } - } - } -} diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift index b5f3750..6cd9046 100644 --- a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift +++ b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift @@ -2,40 +2,69 @@ import SwiftUI import UIKit struct EeveeSettingsView: View { - - @State var musixmatchToken = UserDefaults.musixmatchToken - @State var patchType = UserDefaults.patchType - @State var overwriteConfiguration = UserDefaults.overwriteConfiguration - @State var lyricsSource = UserDefaults.lyricsSource - @State var geniusFallback = UserDefaults.geniusFallback - @State var lyricsColors = UserDefaults.lyricsColors - @State var lyricsOptions = UserDefaults.lyricsOptions + let navigationController: UINavigationController @State var latestVersion = "" + @State var hasShownCommonIssuesTip = UserDefaults.hasShownCommonIssuesTip + + private func pushSettingsController(with view: any View, title: String) { + let viewController = EeveeSettingsViewController( + navigationController.view.frame, + settingsView: AnyView(view), + navigationTitle: title + ) + navigationController.pushViewController(viewController, animated: true) + } var body: some View { - List { + VersionSection() - PremiumSections() - - LyricsSourceSection() - LyricsOptionsSection() - - LyricsColorsSection() - - Section { - Toggle( - "Dark PopUps", - isOn: Binding( - get: { UserDefaults.darkPopUps }, - set: { UserDefaults.darkPopUps = $0 } - ) + if !hasShownCommonIssuesTip { + CommonIssuesTipView( + onDismiss: { + hasShownCommonIssuesTip = true + UserDefaults.hasShownCommonIssuesTip = true + } ) } + // + + Button { + pushSettingsController(with: EeveePatchingSettingsView(), title: "Patching") + } label: { + NavigationSectionView( + color: .orange, + title: "Patching", + imageSystemName: "hammer.fill" + ) + } + + Button { + pushSettingsController(with: EeveeLyricsSettingsView(), title: "Lyrics") + } label: { + NavigationSectionView( + color: .blue, + title: "Lyrics", + imageSystemName: "quote.bubble.fill" + ) + } + + Button { + pushSettingsController(with: EeveeUISettingsView(), title: "Customization") + } label: { + NavigationSectionView( + color: Color(hex: "#64D2FF"), + title: "Customization", + imageSystemName: "paintpalette.fill" + ) + } + + // + Section(footer: Text("Clear cached data and restart the app.")) { Button { try! OfflineHelper.resetPersistentCache() @@ -44,28 +73,14 @@ struct EeveeSettingsView: View { Text("Reset Data") } } - - if !UIDevice.current.isIpad { - Spacer() - .frame(height: 40) - .listRowBackground(Color.clear) - .modifier(ListRowSeparatorHidden()) - } } .listStyle(GroupedListStyle()) - - .animation(.default, value: lyricsSource) - .animation(.default, value: geniusFallback) - .animation(.default, value: patchType) - .animation(.default, value: lyricsColors) + .animation(.default, value: latestVersion) + .animation(.default, value: hasShownCommonIssuesTip) .onAppear { - UIView.appearance( - whenContainedInInstancesOf: [UIAlertController.self] - ).tintColor = UIColor(Color(hex: "#1ed760")) - WindowHelper.shared.overrideUserInterfaceStyle(.dark) Task { diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsViewController+LyricsOptionsSection.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsViewController+LyricsOptionsSection.swift deleted file mode 100644 index 3a1377d..0000000 --- a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsViewController+LyricsOptionsSection.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI - -extension EeveeSettingsView { - @ViewBuilder func LyricsOptionsSection() -> some View { - if lyricsSource == .genius || geniusFallback { - Section { - Toggle( - "Romanized Genius Lyrics", - isOn: $lyricsOptions.geniusRomanizations - ) - } - .onChange(of: lyricsOptions) { lyricsOptions in - UserDefaults.lyricsOptions = lyricsOptions - } - } - if lyricsSource == .musixmatch { - Section { - Toggle( - "Romanized MusixMatch Lyrics", - isOn: $lyricsOptions.musixmatchRomanizations - ) - } - .onChange(of: lyricsOptions) { lyricsOptions in - UserDefaults.lyricsOptions = lyricsOptions - } - } - } -} diff --git a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsViewController+LyricsSourceSection.swift b/Sources/EeveeSpotify/Settings/Views/EeveeSettingsViewController+LyricsSourceSection.swift deleted file mode 100644 index 5be110d..0000000 --- a/Sources/EeveeSpotify/Settings/Views/EeveeSettingsViewController+LyricsSourceSection.swift +++ /dev/null @@ -1,132 +0,0 @@ -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 LyricsSourceSection() -> some View { - - Section(footer: Text(""" -You can select the lyrics source you prefer. - -Genius: Offers the best quality lyrics, provides the most songs, and updates lyrics the fastest. Does not and will never be time-synced. - -LRCLIB: The most open service, offering time-synced lyrics. However, it lacks lyrics for many songs. - -Musixmatch: The service Spotify uses. Provides time-synced lyrics for many songs, but you'll need a user token to use this source. - -If the tweak is unable to find a song or process the lyrics, you'll see a "Couldn't load the lyrics for this song" message. The lyrics might be wrong for some songs when using Genius due to how the tweak searches songs. I've made it work in most cases. -""")) { - Picker( - "Lyrics Source", - selection: $lyricsSource - ) { - Text("Genius").tag(LyricsSource.genius) - Text("LRCLIB").tag(LyricsSource.lrclib) - Text("Musixmatch").tag(LyricsSource.musixmatch) - } - - if lyricsSource == .musixmatch { - - VStack(alignment: .leading, spacing: 5) { - - Text("Musixmatch User Token") - - TextField("Enter User Token or Paste Debug Info", text: $musixmatchToken) - .foregroundColor(.gray) - } - .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 - } - - .onChange(of: geniusFallback) { geniusFallback in - UserDefaults.geniusFallback = geniusFallback - } - - if lyricsSource != .genius { - Section( - footer: Text("Load lyrics from Genius if there is a problem with \(lyricsSource).") - ) { - Toggle( - "Genius Fallback", - isOn: $geniusFallback - ) - - Toggle( - "Show Fallback Reasons", - isOn: Binding( - get: { UserDefaults.fallbackReasons }, - set: { UserDefaults.fallbackReasons = $0 } - ) - ) - } - } - } -} - diff --git a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+Extension.swift b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+Extension.swift new file mode 100644 index 0000000..e61680f --- /dev/null +++ b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+Extension.swift @@ -0,0 +1,51 @@ +import SwiftUI + +extension EeveeLyricsSettingsView { + + 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 + } + + 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.view.tintColor = UIColor(Color(hex: "#1ed760")) + + 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) + } +} + diff --git a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+LyricsSourceSection.swift b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+LyricsSourceSection.swift new file mode 100644 index 0000000..652242f --- /dev/null +++ b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+LyricsSourceSection.swift @@ -0,0 +1,63 @@ +import SwiftUI + +extension EeveeLyricsSettingsView { + + @ViewBuilder func LyricsSourceSection() -> some View { + Section(footer: Text(""" +You can select the lyrics source you prefer. + +Genius: Offers the best quality lyrics, provides the most songs, and updates lyrics the fastest. Does not and will never be time-synced. + +LRCLIB: The most open service, offering time-synced lyrics. However, it lacks lyrics for many songs. + +Musixmatch: The service Spotify uses. Provides time-synced lyrics for many songs, but you'll need a user token to use this source. + +If the tweak is unable to find a song or process the lyrics, you'll see a "Couldn't load the lyrics for this song" message. The lyrics might be wrong for some songs when using Genius due to how the tweak searches songs. I've made it work in most cases. +""")) { + Picker( + "Lyrics Source", + selection: $lyricsSource + ) { + Text("Genius").tag(LyricsSource.genius) + Text("LRCLIB").tag(LyricsSource.lrclib) + Text("Musixmatch").tag(LyricsSource.musixmatch) + } + + if lyricsSource == .musixmatch { + + VStack(alignment: .leading, spacing: 5) { + + Text("Musixmatch User Token") + + TextField("Enter User Token or Paste Debug Info", text: $musixmatchToken) + .foregroundColor(.gray) + } + .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 + } + } +} + diff --git a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView.swift b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView.swift new file mode 100644 index 0000000..a8389a4 --- /dev/null +++ b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView.swift @@ -0,0 +1,105 @@ +import SwiftUI + +struct EeveeLyricsSettingsView: View { + + @State var musixmatchToken = UserDefaults.musixmatchToken + @State var lyricsSource = UserDefaults.lyricsSource + @State var geniusFallback = UserDefaults.geniusFallback + @State var lyricsOptions = UserDefaults.lyricsOptions + + @State var showLanguageWarning = false + + var body: some View { + List { + + LyricsSourceSection() + + if lyricsSource != .genius { + Section( + footer: Text("Load lyrics from Genius if there is a problem with \(lyricsSource).") + ) { + Toggle( + "Genius Fallback", + isOn: $geniusFallback + ) + + if geniusFallback { + Toggle( + "Show Fallback Reasons", + isOn: Binding( + get: { UserDefaults.fallbackReasons }, + set: { UserDefaults.fallbackReasons = $0 } + ) + ) + } + } + } + + // + + if [.genius, .musixmatch].contains(lyricsSource) || geniusFallback { + Section(footer: Text("Load romanized lyrics from Musixmatch or Genius.")) { + Toggle( + "Romanized Lyrics", + isOn: $lyricsOptions.romanization + ) + } + } + + if lyricsSource == .musixmatch { + Section { + HStack { + if showLanguageWarning { + Image(systemName: "exclamationmark.triangle") + .font(.title3) + .foregroundColor(.yellow) + } + + Text("Musixmatch Lyrics Language") + + Spacer() + + TextField("en", text: $lyricsOptions.musixmatchLanguage) + .frame(maxWidth: 20) + .foregroundColor(.gray) + } + } footer: { + Text("You can enter a 2-letter ISO language code and see translated lyrics on Musixmatch if they are available. It overrides Romanized Lyrics.") + } + } + + if !UIDevice.current.isIpad { + Spacer() + .frame(height: 40) + .listRowBackground(Color.clear) + .modifier(ListRowSeparatorHidden()) + } + } + + .listStyle(GroupedListStyle()) + + .animation(.default, value: lyricsSource) + .animation(.default, value: showLanguageWarning) + .animation(.default, value: geniusFallback) + + .onChange(of: geniusFallback) { geniusFallback in + UserDefaults.geniusFallback = geniusFallback + } + + .onChange(of: lyricsOptions) { lyricsOptions in + + let selectedLanguage = lyricsOptions.musixmatchLanguage + + if selectedLanguage.isEmpty || Locale.isoLanguageCodes.contains(selectedLanguage) { + showLanguageWarning = false + + MusixmatchLyricsRepository.shared.selectedLanguage = selectedLanguage + UserDefaults.lyricsOptions = lyricsOptions + + return + } + + showLanguageWarning = true + } + } +} diff --git a/Sources/EeveeSpotify/Settings/Views/Sections/EeveePatchingSettingsView.swift b/Sources/EeveeSpotify/Settings/Views/Sections/EeveePatchingSettingsView.swift new file mode 100644 index 0000000..69be367 --- /dev/null +++ b/Sources/EeveeSpotify/Settings/Views/Sections/EeveePatchingSettingsView.swift @@ -0,0 +1,83 @@ +import SwiftUI +import UIKit + +struct EeveePatchingSettingsView: View { + @State var patchType = UserDefaults.patchType + @State var overwriteConfiguration = UserDefaults.overwriteConfiguration + + var body: some View { + List { + Section(footer: patchType == .disabled ? nil : Text(""" + You can select the Premium patching method you prefer. App restart is required after changing. + + Static: The original method. On app start, the tweak composes cache data by inserting your username into a blank file with preset Premium parameters. When Spotify reloads user data, you'll be switched to the Free plan and see a popup with quick restart app and reset data actions. + + Dynamic: This method intercepts requests to load user data, deserializes it, and modifies the parameters in real-time. It's much more stable and is recommended. + + If you have an active Premium subscription, you can turn on Do Not Patch Premium. The tweak won't patch the data or restrict the use of Premium server-sided features. + """)) { + Toggle( + "Do Not Patch Premium", + isOn: Binding( + get: { patchType == .disabled }, + set: { patchType = $0 ? .disabled : .requests } + ) + ) + + if patchType != .disabled { + Picker( + "Patching Method", + selection: $patchType + ) { + Text("Static").tag(PatchType.offlineBnk) + Text("Dynamic").tag(PatchType.requests) + } + } + } + + .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.") + ) { + Toggle( + "Overwrite Configuration", + isOn: $overwriteConfiguration + ) + } + } + + if !UIDevice.current.isIpad { + Spacer() + .frame(height: 40) + .listRowBackground(Color.clear) + .modifier(ListRowSeparatorHidden()) + } + } + .listStyle(GroupedListStyle()) + .animation(.default, value: patchType) + } +} diff --git a/Sources/EeveeSpotify/Settings/Views/Sections/EeveeUISettingsView.swift b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeUISettingsView.swift new file mode 100644 index 0000000..d598cdb --- /dev/null +++ b/Sources/EeveeSpotify/Settings/Views/Sections/EeveeUISettingsView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import UIKit + +struct EeveeUISettingsView: View { + + @State var lyricsColors = UserDefaults.lyricsColors + + var body: some View { + List { + 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) { lyricsColors in + UserDefaults.lyricsColors = lyricsColors + } + + Section { + Toggle( + "Dark PopUps", + isOn: Binding( + get: { UserDefaults.darkPopUps }, + set: { UserDefaults.darkPopUps = $0 } + ) + ) + } + + if !UIDevice.current.isIpad { + Spacer() + .frame(height: 40) + .listRowBackground(Color.clear) + .modifier(ListRowSeparatorHidden()) + } + } + .listStyle(GroupedListStyle()) + + .animation(.default, value: lyricsColors) + } +} diff --git a/Sources/EeveeSpotify/Settings/Views/Shared/CommonIssuesTipView.swift b/Sources/EeveeSpotify/Settings/Views/Shared/CommonIssuesTipView.swift new file mode 100644 index 0000000..9cbc22f --- /dev/null +++ b/Sources/EeveeSpotify/Settings/Views/Shared/CommonIssuesTipView.swift @@ -0,0 +1,55 @@ +import SwiftUI + +struct CommonIssuesTipView: View { + + var onDismiss: () -> Void + + var body: some View { + + Section { + HStack(spacing: 15) { + + Image(systemName: "exclamationmark.bubble") + .font(.title) + .foregroundColor(.gray) + + VStack(alignment: .leading, spacing: 3) { + + VStack(alignment: .leading) { + Text("Having Trouble?") + .font(.headline) + } + + Link( + destination: URL(string: "https://github.com/whoeevee/EeveeSpotify/blob/swift/common_issues.md")!, + label: { + VStack { + Text("If you are facing an issue, such as being unable to play any songs, check out ") + .foregroundColor(.white) + + + Text("Common Issues") + .foregroundColor(.blue) + + + Text(".") + .foregroundColor(.white) + } + .font(.subheadline) + } + ) + } + + Spacer() + + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.headline) + .foregroundColor(Color(UIColor.systemGray2)) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.vertical, 5) + } + } +} diff --git a/Sources/EeveeSpotify/Settings/Views/Shared/NavigationSectionView.swift b/Sources/EeveeSpotify/Settings/Views/Shared/NavigationSectionView.swift new file mode 100644 index 0000000..d679cd3 --- /dev/null +++ b/Sources/EeveeSpotify/Settings/Views/Shared/NavigationSectionView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct NavigationSectionView: View { + + var color: Color + var title: String + var imageSystemName: String + + var body: some View { + HStack(spacing: 15) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .foregroundColor(color) + + Image(systemName: imageSystemName) + .foregroundColor(.white) + .font(.system(size: 16, weight: .medium)) + } + .frame(width: 30, height: 30) + + Text(title) + .foregroundColor(.white) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(Color(UIColor.systemGray2)) + .font(.subheadline.bold()) + } + } +} diff --git a/layout/Library/Application Support/EeveeSpotify.bundle/github.png b/layout/Library/Application Support/EeveeSpotify.bundle/github.png new file mode 100644 index 0000000..05f02d4 Binary files /dev/null and b/layout/Library/Application Support/EeveeSpotify.bundle/github.png differ