redesigned settings, musixmatch lyrics romanization and translation

This commit is contained in:
eevee
2024-07-10 08:51:14 +03:00
parent 742909efb9
commit 9dde89f07d
22 changed files with 813 additions and 546 deletions

View File

@@ -30,6 +30,7 @@ class EncoreButtonHook: ClassHook<UIButton> {
//
private var lastLyricsLanguageLabel: String? = nil
private var lastLyricsError: LyricsError? = nil
private var hasShownRestrictedPopUp = false
@@ -51,10 +52,6 @@ class LyricsOnlyViewControllerHook: ClassHook<UIViewController> {
orig.viewDidLoad()
if !UserDefaults.fallbackReasons {
return
}
guard
let lyricsHeaderViewController = target.parent?.children.first
else {
@@ -83,18 +80,19 @@ class LyricsOnlyViewControllerHook: ClassHook<UIViewController> {
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<UIViewController> {
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()

View File

@@ -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 {

View File

@@ -1,6 +1,6 @@
import Foundation
struct LyricsOptions: Codable, Equatable {
var geniusRomanizations: Bool
var musixmatchRomanizations: Bool
var romanization: Bool
var musixmatchLanguage: String
}

View File

@@ -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
)
}
}

View File

@@ -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..<lyricsLines.count {
if lyricsLines[i].content == original {
lyricsLines[i].content = translation
}
}
}
romanized = true
}
}
return LyricsDto(
lines: lyricsLines,
timeSynced: true,
romanized: romanized,
translatedTo: translatedTo
)
}
if let trackLyricsGet = macroCalls["track.lyrics.get"] as? [String: Any],
let lyricsMessage = trackLyricsGet["message"] as? [String: Any],
let lyricsHeader = lyricsMessage["header"] as? [String: Any],
@@ -198,74 +250,13 @@ struct MusixmatchLyricsRepository: LyricsRepository {
throw LyricsError.MusixmatchRestricted
}
if (!UserDefaults.lyricsOptions.musixmatchRomanizations) {
return LyricsDto(
lines: plainLyrics
.components(separatedBy: "\n")
.dropLast()
.map { LyricsLineDto(content: $0.lyricsNoteIfEmpty) },
timeSynced: false
)
} else {
do {
let subtitleLang = lyrics["lyrics_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["matched_line"] as? String,
let translationString = translationInfo["description"] as? String {
if translationMatch != translationString {
translationDict[translationMatch] = translationString
}
}
}
let modifiedLyrics = plainLyrics
.components(separatedBy: "\n")
.dropLast()
.map { line in
var modifiedLine = line
for (translationMatch, translationString) in translationDict {
modifiedLine = modifiedLine.replacingOccurrences(of: translationMatch, with: translationString)
}
return modifiedLine
}
return LyricsDto(
lines: modifiedLyrics.map { LyricsLineDto(content: $0.lyricsNoteIfEmpty) },
timeSynced: false
)
} catch {
return LyricsDto(
lines: plainLyrics
.components(separatedBy: "\n")
.dropLast()
.map { LyricsLineDto(content: $0.lyricsNoteIfEmpty) },
timeSynced: false
)
}
}
return LyricsDto(
lines: plainLyrics
.components(separatedBy: "\n")
.dropLast()
.map { LyricsLineDto(content: $0.lyricsNoteIfEmpty) },
timeSynced: false
)
}
}

View File

@@ -13,6 +13,7 @@ extension UserDefaults {
private static let overwriteConfigurationKey = "overwriteConfiguration"
private static let lyricsColorsKey = "lyricsColors"
private static let lyricsOptionsKey = "lyricsOptions"
private static let hasShownCommonIssuesTipKey = "hasShownCommonIssuesTip"
static var lyricsSource: LyricsSource {
get {
@@ -47,11 +48,12 @@ extension UserDefaults {
static var lyricsOptions: LyricsOptions {
get {
if let data = defaults.object(forKey: lyricsOptionsKey) as? Data {
return try! JSONDecoder().decode(LyricsOptions.self, from: data)
if let data = defaults.object(forKey: lyricsOptionsKey) as? Data,
let lyricsOptions = try? JSONDecoder().decode(LyricsOptions.self, from: data) {
return lyricsOptions
}
return LyricsOptions(geniusRomanizations: false, musixmatchRomanizations: false)
return LyricsOptions(romanization: false, musixmatchLanguage: "")
}
set (lyricsOptions) {
defaults.set(try! JSONEncoder().encode(lyricsOptions), forKey: lyricsOptionsKey)
@@ -98,6 +100,15 @@ extension UserDefaults {
}
}
static var hasShownCommonIssuesTip: Bool {
get {
defaults.bool(forKey: hasShownCommonIssuesTipKey)
}
set (hasShownCommonIssuesTip) {
defaults.set(hasShownCommonIssuesTip, forKey: hasShownCommonIssuesTipKey)
}
}
static var lyricsColors: LyricsColorsSettings {
get {
if let data = defaults.object(forKey: lyricsColorsKey) as? Data {

View File

@@ -1,4 +1,5 @@
import Foundation
import SwiftUI
import libroot
class BundleHelper {
@@ -17,6 +18,15 @@ class BundleHelper {
?? jbRootPath("/Library/Application Support/\(bundleName).bundle")
)!
}
func uiImage(_ name: String) -> UIImage {
return UIImage(
contentsOfFile: self.bundle.path(
forResource: name,
ofType: "png"
)!
)!
}
func premiumBlankData() throws -> Data {
return try Data(

View File

@@ -1,4 +1,5 @@
import Orion
import SwiftUI
import UIKit
class ProfileSettingsSectionHook: ClassHook<NSObject> {
@@ -16,10 +17,40 @@ class ProfileSettingsSectionHook: ClassHook<NSObject> {
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
)

View File

@@ -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")!)
}
}

View File

@@ -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<Color>(
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
}
}
}

View File

@@ -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<Bool>(
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
)
}
}
}
}

View File

@@ -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<Bool>(
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 {

View File

@@ -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
}
}
}
}

View File

@@ -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<Bool>(
get: { UserDefaults.fallbackReasons },
set: { UserDefaults.fallbackReasons = $0 }
)
)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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<Bool>(
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
}
}
}

View File

@@ -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<Bool>(
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)
}
}

View File

@@ -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<Color>(
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<Bool>(
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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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())
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB