mirror of
https://github.com/whoeevee/EeveeSpotifyReborn.git
synced 2026-01-08 23:23:20 +00:00
redesigned settings, musixmatch lyrics romanization and translation
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct LyricsOptions: Codable, Equatable {
|
||||
var geniusRomanizations: Bool
|
||||
var musixmatchRomanizations: Bool
|
||||
var romanization: Bool
|
||||
var musixmatchLanguage: String
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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")!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user