mirror of
https://github.com/whoeevee/EeveeSpotifyReborn.git
synced 2026-01-09 00:23:20 +01:00
lrclib api option, many improvements and refactoring
This commit is contained in:
@@ -96,8 +96,10 @@ class LyricsOnlyViewControllerHook: ClassHook<UIViewController> {
|
||||
|
||||
//
|
||||
|
||||
if UserDefaults.fallbackReasons, let description = lastLyricsState.fallbackError?.description {
|
||||
var attributedString = Dynamic.SPTEncoreAttributedString.alloc(
|
||||
if UserDefaults.lyricsOptions.showFallbackReasons,
|
||||
let description = lastLyricsState.fallbackError?.description
|
||||
{
|
||||
let attributedString = Dynamic.SPTEncoreAttributedString.alloc(
|
||||
interface: SPTEncoreAttributedString.self
|
||||
)
|
||||
|
||||
@@ -117,7 +119,7 @@ class LyricsOnlyViewControllerHook: ClassHook<UIViewController> {
|
||||
}
|
||||
|
||||
if lastLyricsState.wasRomanized {
|
||||
var attributedString = Dynamic.SPTEncoreAttributedString.alloc(
|
||||
let attributedString = Dynamic.SPTEncoreAttributedString.alloc(
|
||||
interface: SPTEncoreAttributedString.self
|
||||
)
|
||||
|
||||
@@ -146,6 +148,11 @@ class LyricsOnlyViewControllerHook: ClassHook<UIViewController> {
|
||||
|
||||
//
|
||||
|
||||
private let geniusLyricsRepository = GeniusLyricsRepository()
|
||||
private let petitLyricsRepository = PetitLyricsRepository()
|
||||
|
||||
//
|
||||
|
||||
private func loadLyricsForCurrentTrack() throws {
|
||||
guard let track = HookedInstances.currentTrack else {
|
||||
throw LyricsError.noCurrentTrack
|
||||
@@ -163,10 +170,10 @@ private func loadLyricsForCurrentTrack() throws {
|
||||
var source = UserDefaults.lyricsSource
|
||||
|
||||
var repository: LyricsRepository = switch source {
|
||||
case .genius: GeniusLyricsRepository()
|
||||
case .lrclib: LrcLibLyricsRepository()
|
||||
case .genius: geniusLyricsRepository
|
||||
case .lrclib: LrclibLyricsRepository.shared
|
||||
case .musixmatch: MusixmatchLyricsRepository.shared
|
||||
case .petit: PetitLyricsRepository()
|
||||
case .petit: petitLyricsRepository
|
||||
case .notReplaced: throw LyricsError.invalidSource
|
||||
}
|
||||
|
||||
@@ -215,7 +222,7 @@ private func loadLyricsForCurrentTrack() throws {
|
||||
lastLyricsState.fallbackError = .unknownError
|
||||
}
|
||||
|
||||
if source == .genius || !UserDefaults.geniusFallback {
|
||||
if source == .genius || !UserDefaults.lyricsOptions.geniusFallback {
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
struct LyricsOptions: Codable, Equatable {
|
||||
struct LyricsOptions: Codable, Hashable {
|
||||
var romanization: Bool
|
||||
var musixmatchLanguage: String
|
||||
var lrclibUrl: String
|
||||
var geniusFallback: Bool
|
||||
var showFallbackReasons: Bool
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ enum LyricsSource: Int, CaseIterable, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
var isReplacing: Bool { self != .notReplaced }
|
||||
var isReplacingLyrics: Bool { self != .notReplaced }
|
||||
|
||||
static var defaultSource: LyricsSource {
|
||||
Locale.isInRegion("JP", orHasLanguage: "ja")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct GeniusLyricsRepository: LyricsRepository {
|
||||
class GeniusLyricsRepository: LyricsRepository {
|
||||
private let jsonDecoder: JSONDecoder
|
||||
private let apiUrl = "https://api.genius.com"
|
||||
private let session: URLSession
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct LrcLibLyricsRepository: LyricsRepository {
|
||||
private let apiUrl = "https://lrclib.net/api"
|
||||
class LrclibLyricsRepository: LyricsRepository {
|
||||
var apiUrl: String
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
private init(apiUrl: String) {
|
||||
self.apiUrl = apiUrl
|
||||
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.httpAdditionalHeaders = [
|
||||
"User-Agent": "EeveeSpotify v\(EeveeSpotify.version) https://github.com/whoeevee/EeveeSpotify"
|
||||
@@ -13,6 +15,12 @@ struct LrcLibLyricsRepository: LyricsRepository {
|
||||
session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
static let originalApiUrl = "https://lrclib.net/api"
|
||||
|
||||
static let shared = LrclibLyricsRepository(
|
||||
apiUrl: UserDefaults.lyricsOptions.lrclibUrl
|
||||
)
|
||||
|
||||
private func perform(
|
||||
_ path: String,
|
||||
query: [String:Any] = [:]
|
||||
@@ -65,7 +73,6 @@ struct LrcLibLyricsRepository: LyricsRepository {
|
||||
var captures: [String: String] = [:]
|
||||
|
||||
for name in ["minute", "seconds", "content"] {
|
||||
|
||||
let matchRange = match.range(withName: name)
|
||||
|
||||
if let substringRange = Range(matchRange, in: line) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct PetitLyricsRepository: LyricsRepository {
|
||||
class PetitLyricsRepository: LyricsRepository {
|
||||
private let url = "https://p1.petitlyrics.com/api/GetPetitLyricsData.php"
|
||||
private let session: URLSession
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ extension String {
|
||||
return bundle.localizedString(forKey: self, value: nil, table: nil)
|
||||
}
|
||||
|
||||
func localizeWithFormat(_ arguments: CVarArg...) -> String{
|
||||
func localizeWithFormat(_ arguments: CVarArg...) -> String {
|
||||
String(format: self.localized, arguments: arguments)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ extension UserDefaults {
|
||||
|
||||
private static let lyricsSourceKey = "lyricsSource"
|
||||
private static let musixmatchTokenKey = "musixmatchToken"
|
||||
private static let geniusFallbackKey = "geniusFallback"
|
||||
private static let fallbackReasonsKey = "fallbackReasons"
|
||||
private static let darkPopUpsKey = "darkPopUps"
|
||||
private static let patchTypeKey = "patchType"
|
||||
private static let overwriteConfigurationKey = "overwriteConfiguration"
|
||||
@@ -36,15 +34,6 @@ extension UserDefaults {
|
||||
}
|
||||
}
|
||||
|
||||
static var geniusFallback: Bool {
|
||||
get {
|
||||
defaults.object(forKey: geniusFallbackKey) as? Bool ?? true
|
||||
}
|
||||
set (fallback) {
|
||||
defaults.set(fallback, forKey: geniusFallbackKey)
|
||||
}
|
||||
}
|
||||
|
||||
static var lyricsOptions: LyricsOptions {
|
||||
get {
|
||||
if let data = defaults.object(forKey: lyricsOptionsKey) as? Data,
|
||||
@@ -54,22 +43,16 @@ extension UserDefaults {
|
||||
|
||||
return LyricsOptions(
|
||||
romanization: false,
|
||||
musixmatchLanguage: Locale.current.languageCode ?? ""
|
||||
musixmatchLanguage: Locale.current.languageCode ?? "",
|
||||
lrclibUrl: LrclibLyricsRepository.originalApiUrl,
|
||||
geniusFallback: true,
|
||||
showFallbackReasons: true
|
||||
)
|
||||
}
|
||||
set (lyricsOptions) {
|
||||
defaults.set(try! JSONEncoder().encode(lyricsOptions), forKey: lyricsOptionsKey)
|
||||
}
|
||||
}
|
||||
|
||||
static var fallbackReasons: Bool {
|
||||
get {
|
||||
defaults.object(forKey: fallbackReasonsKey) as? Bool ?? true
|
||||
}
|
||||
set (reasons) {
|
||||
defaults.set(reasons, forKey: fallbackReasonsKey)
|
||||
}
|
||||
}
|
||||
|
||||
static var darkPopUps: Bool {
|
||||
get {
|
||||
|
||||
@@ -3,4 +3,5 @@ import Foundation
|
||||
// it's NSURL actually, just with Spotify extensions
|
||||
@objc protocol SPTURL {
|
||||
func spt_trackIdentifier() -> String
|
||||
func isPlaylistURL() -> Bool
|
||||
}
|
||||
|
||||
@@ -26,11 +26,9 @@ class ContentOffliningUIHelperImplementationHook: ClassHook<NSObject> {
|
||||
pageIdentifier: NSString,
|
||||
pageURI: NSURL
|
||||
) {
|
||||
let isPlaylist = [
|
||||
"free-tier-playlist",
|
||||
"playlist/ondemand"
|
||||
].contains(pageIdentifier)
|
||||
|
||||
let isPlaylist = Dynamic.convert(pageURI, to: SPTURL.self)
|
||||
.isPlaylistURL()
|
||||
|
||||
PopUpHelper.showPopUp(
|
||||
message: "playlist_downloading_popup".localized,
|
||||
buttonText: "OK".uiKitLocalized,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct AnonymousTokenHelper {
|
||||
private static let apiUrl = "https://apic.musixmatch.com"
|
||||
|
||||
static func requestAnonymousMusixmatchToken() async throws -> String {
|
||||
let url = URL(string: "\(apiUrl)/ws/1.1/token.get?app_id=\(await UIDevice.current.musixmatchAppId)")!
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
|
||||
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 userToken = body["user_token"] as? String
|
||||
else {
|
||||
throw AnonymousTokenError.invalidResponse
|
||||
}
|
||||
|
||||
return userToken
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
struct AnonymousTokenHelper {
|
||||
private static let apiUrl = "https://apic.musixmatch.com"
|
||||
|
||||
static func requestAnonymousMusixmatchToken() -> AnyPublisher<String, Error> {
|
||||
let url = URL(string: "\(apiUrl)/ws/1.1/token.get?app_id=\(UIDevice.current.musixmatchAppId)")!
|
||||
|
||||
return URLSession.shared.dataTaskPublisher(for: url)
|
||||
.map(\.data)
|
||||
.tryMap { data in
|
||||
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 userToken = body["user_token"] as? String
|
||||
else {
|
||||
throw AnonymousTokenError.invalidResponse
|
||||
}
|
||||
|
||||
return userToken
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
enum LrclibURLState {
|
||||
case `default`
|
||||
case invalidURL
|
||||
case unreachableURL
|
||||
case originalURL
|
||||
case ok
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
extension EeveeLyricsSettingsViewModel {
|
||||
func setupBindings() {
|
||||
$lyricsOptions
|
||||
.map(\.musixmatchLanguage)
|
||||
.sink { [weak self] language in
|
||||
guard let self = self else { return }
|
||||
|
||||
let isValidLanguage = language.isEmpty || language ~= "^[\\w\\d]{2}$"
|
||||
|
||||
if isValidLanguage {
|
||||
self.showMusixmatchInvalidLanguageWarning = false
|
||||
MusixmatchLyricsRepository.shared.selectedLanguage = language
|
||||
return
|
||||
}
|
||||
|
||||
self.showMusixmatchInvalidLanguageWarning = true
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
$lyricsOptions
|
||||
.map(\.lrclibUrl)
|
||||
.map { urlString -> AnyPublisher<LrclibURLState, Never> in
|
||||
guard let url = URL(string: urlString) else {
|
||||
return Just(.invalidURL).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
if url.host == "lrclib.net" {
|
||||
return Just(.originalURL).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return URLSession.shared.dataTaskPublisher(for: url)
|
||||
.map { _ in
|
||||
LrclibLyricsRepository.shared.apiUrl = urlString
|
||||
return LrclibURLState.ok
|
||||
}
|
||||
.catch { _ in Just(LrclibURLState.unreachableURL) }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
.switchToLatest()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$lrclibURLState)
|
||||
|
||||
$musixmatchToken
|
||||
.dropFirst()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] tokenString in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let token = self.getMusixmatchTokenFromDebugInfo(tokenString) {
|
||||
self.musixmatchToken = token
|
||||
return
|
||||
}
|
||||
|
||||
if let token = self.getMusixmatchToken(tokenString) {
|
||||
UserDefaults.musixmatchToken = token
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
$lyricsSource
|
||||
.dropFirst()
|
||||
.sink { [weak self] newSource in
|
||||
guard let self = self else { return }
|
||||
|
||||
if newSource == .musixmatch && self.musixmatchToken.isEmpty {
|
||||
self.musixmatchTokenInputAlertPublisher.send(true)
|
||||
return
|
||||
}
|
||||
|
||||
if newSource == .lrclib {
|
||||
self.lyricsOptions.lrclibUrl = LrclibLyricsRepository.originalApiUrl
|
||||
}
|
||||
|
||||
UserDefaults.lyricsSource = newSource
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
class EeveeLyricsSettingsViewModel: ObservableObject {
|
||||
@Published var lyricsSource = UserDefaults.lyricsSource
|
||||
|
||||
@Published var lyricsOptions = UserDefaults.lyricsOptions {
|
||||
didSet { UserDefaults.lyricsOptions = lyricsOptions }
|
||||
}
|
||||
|
||||
@Published var musixmatchToken = UserDefaults.musixmatchToken
|
||||
@Published var isRequestingMusixmatchToken = false
|
||||
@Published var musixmatchTokenInputAlertPublisher = PassthroughSubject<Bool, Never>()
|
||||
var isMusixmatchTokenValid: Bool { getMusixmatchToken(musixmatchToken) != nil }
|
||||
|
||||
@Published var showMusixmatchInvalidLanguageWarning = false
|
||||
@Published var lrclibURLState = LrclibURLState.default
|
||||
|
||||
var animationValues: [AnyHashable] {
|
||||
[
|
||||
lyricsSource,
|
||||
lyricsOptions,
|
||||
isMusixmatchTokenValid,
|
||||
isRequestingMusixmatchToken,
|
||||
lrclibURLState,
|
||||
showMusixmatchInvalidLanguageWarning
|
||||
]
|
||||
}
|
||||
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
setupBindings()
|
||||
}
|
||||
|
||||
func getMusixmatchTokenFromDebugInfo(_ debugInfo: String) -> String? {
|
||||
if let match = debugInfo.firstMatch("\\[UserToken\\]: ([a-f0-9]+)"),
|
||||
let tokenRange = Range(match.range(at: 1), in: debugInfo) {
|
||||
return String(debugInfo[tokenRange])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMusixmatchToken(_ input: String) -> String? {
|
||||
if input ~= "^[a-f0-9]{54}$" {
|
||||
return input
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestAnonymousMusixmatchToken() {
|
||||
isRequestingMusixmatchToken = true
|
||||
|
||||
AnonymousTokenHelper.requestAnonymousMusixmatchToken()
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.isRequestingMusixmatchToken = false
|
||||
|
||||
switch completion {
|
||||
case .failure(_):
|
||||
self?.musixmatchTokenInputAlertPublisher.send(false)
|
||||
case .finished:
|
||||
UserDefaults.lyricsSource = .musixmatch
|
||||
break
|
||||
}
|
||||
}, receiveValue: { [weak self] token in
|
||||
self?.musixmatchToken = token
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DynamicIconModifier: ViewModifier {
|
||||
var systemName: String
|
||||
var color: Color
|
||||
@Binding var condition: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
HStack {
|
||||
if condition {
|
||||
Image(systemName: systemName)
|
||||
.font(.title2)
|
||||
.foregroundColor(color)
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func icon(_ systemName: String, color: Color, when condition: Binding<Bool>) -> some View {
|
||||
modifier(DynamicIconModifier(systemName: systemName, color: color, condition: condition))
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import SwiftUI
|
||||
|
||||
extension EeveeLyricsSettingsView {
|
||||
private func lyricsSourceFooter() -> some View {
|
||||
var text = "lyrics_source_description".localized
|
||||
|
||||
text.append("\n\n")
|
||||
text.append("petitlyrics_description".localized)
|
||||
|
||||
text.append("\n\n")
|
||||
text.append("lyrics_additional_info".localized)
|
||||
|
||||
return Text(text)
|
||||
}
|
||||
|
||||
@ViewBuilder func lyricsSourceSection() -> some View {
|
||||
Section {
|
||||
Toggle(
|
||||
"do_not_replace_lyrics".localized,
|
||||
isOn: Binding<Bool>(
|
||||
get: { viewModel.lyricsSource == .notReplaced },
|
||||
set: {
|
||||
viewModel.lyricsSource = $0
|
||||
? .notReplaced
|
||||
: LyricsSource.defaultSource
|
||||
}
|
||||
)
|
||||
)
|
||||
} footer: {
|
||||
Text("restart_is_required_description".localized)
|
||||
}
|
||||
|
||||
if viewModel.lyricsSource.isReplacingLyrics {
|
||||
Section(footer: lyricsSourceFooter()) {
|
||||
Picker(
|
||||
"lyrics_source".localized,
|
||||
selection: $viewModel.lyricsSource
|
||||
) {
|
||||
ForEach(LyricsSource.allCases, id: \.self) { lyricsSource in
|
||||
Text(lyricsSource.description).tag(lyricsSource)
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.lyricsSource == .musixmatch {
|
||||
musixmatchTokenField()
|
||||
}
|
||||
|
||||
if viewModel.lyricsSource == .lrclib {
|
||||
lrclibURLField()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func musixmatchTokenField() -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("musixmatch_user_token".localized)
|
||||
|
||||
TextField("user_token_placeholder".localized, text: $viewModel.musixmatchToken)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.icon(
|
||||
"exclamationmark.circle",
|
||||
color: .red,
|
||||
when: Binding<Bool>(
|
||||
get: { !viewModel.isMusixmatchTokenValid },
|
||||
set: { _ in }
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder private func lrclibURLField() -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("lrclib_api".localized)
|
||||
|
||||
TextField(LrclibLyricsRepository.originalApiUrl, text: $viewModel.lyricsOptions.lrclibUrl)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.icon(
|
||||
"exclamationmark.circle",
|
||||
color: .red,
|
||||
when: Binding<Bool>(
|
||||
get: {
|
||||
viewModel.lrclibURLState == .invalidURL
|
||||
|| viewModel.lrclibURLState == .unreachableURL
|
||||
},
|
||||
set: { _ in }
|
||||
)
|
||||
)
|
||||
.icon(
|
||||
"checkmark.seal",
|
||||
color: .green,
|
||||
when: Binding<Bool>(
|
||||
get: { viewModel.lrclibURLState == .originalURL },
|
||||
set: { _ in }
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
extension EeveeLyricsSettingsView {
|
||||
func showMusixmatchTokenAlert(_ oldSource: LyricsSource, _ showAnonymousTokenOption: Bool) {
|
||||
var message = "enter_user_token_message".localized
|
||||
|
||||
if showAnonymousTokenOption {
|
||||
message.append("\n\n")
|
||||
message.append("request_anonymous_token_description".localized)
|
||||
}
|
||||
|
||||
let alert = UIAlertController(
|
||||
title: "enter_user_token".localized,
|
||||
message: message,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
|
||||
alert.addTextField() { textField in
|
||||
textField.placeholder = "---- Debug Info ---- [Device]: \(UIDevice.current.isIpad ? "iPad" : "iPhone")"
|
||||
}
|
||||
|
||||
alert.addAction(UIAlertAction(title: "Cancel".uiKitLocalized, style: .cancel) { _ in
|
||||
viewModel.lyricsSource = oldSource
|
||||
})
|
||||
|
||||
if showAnonymousTokenOption {
|
||||
alert.addAction(UIAlertAction(title: "request_anonymous_token".localized, style: .default) { _ in
|
||||
viewModel.requestAnonymousMusixmatchToken()
|
||||
})
|
||||
}
|
||||
|
||||
alert.addAction(UIAlertAction(title: "OK".uiKitLocalized, style: .default) { _ in
|
||||
let text = alert.textFields!.first!.text!
|
||||
|
||||
guard let token =
|
||||
viewModel.getMusixmatchTokenFromDebugInfo(text)
|
||||
?? viewModel.getMusixmatchToken(text)
|
||||
else {
|
||||
viewModel.lyricsSource = oldSource
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.musixmatchToken = token
|
||||
UserDefaults.lyricsSource = .musixmatch
|
||||
})
|
||||
|
||||
WindowHelper.shared.present(alert)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EeveeLyricsSettingsView: View {
|
||||
@StateObject var viewModel = EeveeLyricsSettingsViewModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
lyricsSourceSection()
|
||||
|
||||
if viewModel.lyricsSource != .notReplaced {
|
||||
if viewModel.lyricsSource != .genius {
|
||||
geniusFallbackSection()
|
||||
}
|
||||
|
||||
romanizedLyricsSection()
|
||||
|
||||
if viewModel.lyricsSource == .musixmatch {
|
||||
musixmatchLanguageSection()
|
||||
}
|
||||
}
|
||||
|
||||
NonIPadSpacerView()
|
||||
}
|
||||
.onReceive(viewModel.musixmatchTokenInputAlertPublisher) { showAnonymousTokenOption in
|
||||
showMusixmatchTokenAlert(UserDefaults.lyricsSource, showAnonymousTokenOption)
|
||||
}
|
||||
.listStyle(GroupedListStyle())
|
||||
.disabled(viewModel.isRequestingMusixmatchToken)
|
||||
.animation(.default, value: viewModel.animationValues)
|
||||
}
|
||||
|
||||
@ViewBuilder private func geniusFallbackSection() -> some View {
|
||||
Section {
|
||||
Toggle(
|
||||
"genius_fallback".localized,
|
||||
isOn: $viewModel.lyricsOptions.geniusFallback
|
||||
)
|
||||
|
||||
if viewModel.lyricsOptions.geniusFallback {
|
||||
Toggle(
|
||||
"show_fallback_reasons".localized,
|
||||
isOn: $viewModel.lyricsOptions.showFallbackReasons
|
||||
)
|
||||
}
|
||||
} footer: {
|
||||
Text("genius_fallback_description"
|
||||
.localizeWithFormat(viewModel.lyricsSource.description))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func romanizedLyricsSection() -> some View {
|
||||
Section {
|
||||
Toggle(
|
||||
"romanized_lyrics".localized,
|
||||
isOn: $viewModel.lyricsOptions.romanization
|
||||
)
|
||||
} footer: {
|
||||
Text("romanized_lyrics_description".localized)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func musixmatchLanguageSection() -> some View {
|
||||
Section {
|
||||
HStack {
|
||||
Text("musixmatch_language".localized)
|
||||
|
||||
Spacer()
|
||||
|
||||
TextField("en", text: $viewModel.lyricsOptions.musixmatchLanguage)
|
||||
.frame(maxWidth: 20)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.icon(
|
||||
"exclamationmark.triangle.fill",
|
||||
color: .yellow,
|
||||
when: $viewModel.showMusixmatchInvalidLanguageWarning
|
||||
)
|
||||
} footer: {
|
||||
Text("musixmatch_language_description".localized)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-6
@@ -45,12 +45,7 @@ struct EeveePatchingSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !UIDevice.current.isIpad {
|
||||
Spacer()
|
||||
.frame(height: 40)
|
||||
.listRowBackground(Color.clear)
|
||||
.modifier(ListRowSeparatorHidden())
|
||||
}
|
||||
NonIPadSpacerView()
|
||||
}
|
||||
.listStyle(GroupedListStyle())
|
||||
.animation(.default, value: patchType)
|
||||
+2
-2
@@ -3,7 +3,7 @@ import SwiftUI
|
||||
struct ChevronRightView: View {
|
||||
var body: some View {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(Color(UIColor.systemGray2))
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(Color(UIColor.tertiaryLabel))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NonIPadSpacerView: View {
|
||||
var body: some View {
|
||||
if !UIDevice.current.isIpad {
|
||||
Spacer()
|
||||
.frame(height: 40)
|
||||
.listRowBackground(Color.clear)
|
||||
.modifier(ListRowSeparatorHidden())
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-7
@@ -6,7 +6,7 @@ struct EeveeUISettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if UserDefaults.lyricsSource.isReplacing {
|
||||
if UserDefaults.lyricsSource.isReplacingLyrics {
|
||||
Section(
|
||||
header: Text("lyrics_background_color_section".localized),
|
||||
footer: Text("lyrics_background_color_section_description".localized)
|
||||
@@ -58,12 +58,7 @@ struct EeveeUISettingsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
if !UIDevice.current.isIpad {
|
||||
Spacer()
|
||||
.frame(height: 40)
|
||||
.listRowBackground(Color.clear)
|
||||
.modifier(ListRowSeparatorHidden())
|
||||
}
|
||||
NonIPadSpacerView()
|
||||
}
|
||||
|
||||
.listStyle(GroupedListStyle())
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class EeveeSettingsViewController: SPTPageViewController {
|
||||
|
||||
let frame: CGRect
|
||||
let settingsView: AnyView
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import UIKit
|
||||
|
||||
class SPTPageViewController: UIViewController {
|
||||
|
||||
override func conforms(to aProtocol: Protocol) -> Bool {
|
||||
|
||||
if NSStringFromProtocol(aProtocol) ~= "SPTPageController" {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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, showAnonymousTokenOption: Bool) {
|
||||
var message = "enter_user_token_message".localized
|
||||
|
||||
if showAnonymousTokenOption {
|
||||
message.append("\n\n")
|
||||
message.append("request_anonymous_token_description".localized)
|
||||
}
|
||||
|
||||
let alert = UIAlertController(
|
||||
title: "enter_user_token".localized,
|
||||
message: message,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
|
||||
alert.addTextField() { textField in
|
||||
textField.placeholder = "---- Debug Info ---- [Device]: iPhone"
|
||||
}
|
||||
|
||||
alert.addAction(UIAlertAction(title: "Cancel".uiKitLocalized, style: .cancel) { _ in
|
||||
lyricsSource = oldSource
|
||||
})
|
||||
|
||||
if showAnonymousTokenOption {
|
||||
alert.addAction(UIAlertAction(title: "request_anonymous_token".localized, style: .default) { _ in
|
||||
Task {
|
||||
defer {
|
||||
isRequestingMusixmatchToken.toggle()
|
||||
}
|
||||
do {
|
||||
isRequestingMusixmatchToken.toggle()
|
||||
|
||||
musixmatchToken = try await AnonymousTokenHelper.requestAnonymousMusixmatchToken()
|
||||
UserDefaults.lyricsSource = .musixmatch
|
||||
}
|
||||
catch {
|
||||
showMusixmatchTokenAlert(oldSource, showAnonymousTokenOption: false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
alert.addAction(UIAlertAction(title: "OK".uiKitLocalized, 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)
|
||||
}
|
||||
}
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension EeveeLyricsSettingsView {
|
||||
func lyricsSourceFooter() -> some View {
|
||||
var text = "lyrics_source_description".localized
|
||||
|
||||
text.append("\n\n")
|
||||
text.append("petitlyrics_description".localized)
|
||||
|
||||
text.append("\n\n")
|
||||
text.append("lyrics_additional_info".localized)
|
||||
|
||||
return Text(text)
|
||||
}
|
||||
|
||||
@ViewBuilder func LyricsSourceSection() -> some View {
|
||||
Section(footer: Text("restart_is_required_description".localized)) {
|
||||
Toggle(
|
||||
"do_not_replace_lyrics".localized,
|
||||
isOn: Binding<Bool>(
|
||||
get: { lyricsSource == .notReplaced },
|
||||
set: { lyricsSource = $0 ? .notReplaced : LyricsSource.defaultSource }
|
||||
)
|
||||
)
|
||||
}
|
||||
.onChange(of: lyricsSource) { [lyricsSource] newSource in
|
||||
if newSource == .musixmatch && musixmatchToken.isEmpty {
|
||||
showMusixmatchTokenAlert(lyricsSource, showAnonymousTokenOption: true)
|
||||
return
|
||||
}
|
||||
|
||||
UserDefaults.lyricsSource = newSource
|
||||
}
|
||||
|
||||
if lyricsSource.isReplacing {
|
||||
Section(footer: lyricsSourceFooter()) {
|
||||
Picker(
|
||||
"lyrics_source".localized,
|
||||
selection: $lyricsSource
|
||||
) {
|
||||
ForEach(LyricsSource.allCases, id: \.self) { lyricsSource in
|
||||
Text(lyricsSource.description).tag(lyricsSource)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsSource == .musixmatch {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("musixmatch_user_token".localized)
|
||||
|
||||
TextField("user_token_placeholder".localized, 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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EeveeLyricsSettingsView: View {
|
||||
@State var musixmatchToken = UserDefaults.musixmatchToken
|
||||
@State var lyricsSource = UserDefaults.lyricsSource
|
||||
@State var geniusFallback = UserDefaults.geniusFallback
|
||||
|
||||
@State private var lyricsOptions = UserDefaults.lyricsOptions
|
||||
|
||||
@State var isRequestingMusixmatchToken = false
|
||||
@State private var isShowingLanguageWarning = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
LyricsSourceSection()
|
||||
|
||||
if lyricsSource != .notReplaced {
|
||||
if lyricsSource != .genius {
|
||||
Section(
|
||||
footer: Text("genius_fallback_description".localizeWithFormat(lyricsSource.description))
|
||||
) {
|
||||
Toggle(
|
||||
"genius_fallback".localized,
|
||||
isOn: $geniusFallback
|
||||
)
|
||||
|
||||
if geniusFallback {
|
||||
Toggle(
|
||||
"show_fallback_reasons".localized,
|
||||
isOn: Binding<Bool>(
|
||||
get: { UserDefaults.fallbackReasons },
|
||||
set: { UserDefaults.fallbackReasons = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
Section(footer: Text("romanized_lyrics_description".localized)) {
|
||||
Toggle(
|
||||
"romanized_lyrics".localized,
|
||||
isOn: $lyricsOptions.romanization
|
||||
)
|
||||
}
|
||||
|
||||
if lyricsSource == .musixmatch {
|
||||
Section {
|
||||
HStack {
|
||||
if isShowingLanguageWarning {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title3)
|
||||
.foregroundColor(.yellow)
|
||||
}
|
||||
|
||||
Text("musixmatch_language".localized)
|
||||
|
||||
Spacer()
|
||||
|
||||
TextField("en", text: $lyricsOptions.musixmatchLanguage)
|
||||
.frame(maxWidth: 20)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
} footer: {
|
||||
Text("musixmatch_language_description".localized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !UIDevice.current.isIpad {
|
||||
Spacer()
|
||||
.frame(height: 40)
|
||||
.listRowBackground(Color.clear)
|
||||
.modifier(ListRowSeparatorHidden())
|
||||
}
|
||||
}
|
||||
.listStyle(GroupedListStyle())
|
||||
|
||||
.disabled(isRequestingMusixmatchToken)
|
||||
|
||||
.animation(.default, value: lyricsSource)
|
||||
.animation(.default, value: isRequestingMusixmatchToken)
|
||||
.animation(.default, value: isShowingLanguageWarning)
|
||||
.animation(.default, value: geniusFallback)
|
||||
|
||||
.onChange(of: geniusFallback) { geniusFallback in
|
||||
UserDefaults.geniusFallback = geniusFallback
|
||||
}
|
||||
|
||||
.onChange(of: lyricsOptions) { lyricsOptions in
|
||||
let selectedLanguage = lyricsOptions.musixmatchLanguage
|
||||
|
||||
if selectedLanguage.isEmpty || selectedLanguage ~= "^[\\w\\d]{2}$" {
|
||||
isShowingLanguageWarning = false
|
||||
|
||||
MusixmatchLyricsRepository.shared.selectedLanguage = selectedLanguage
|
||||
UserDefaults.lyricsOptions = lyricsOptions
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
isShowingLanguageWarning = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ struct EeveeSpotify: Tweak {
|
||||
PremiumPatchingGroup().activate()
|
||||
}
|
||||
|
||||
if UserDefaults.lyricsSource.isReplacing {
|
||||
if UserDefaults.lyricsSource.isReplacingLyrics {
|
||||
LyricsGroup().activate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,3 +113,5 @@ contributors = "Contributors";
|
||||
|
||||
request_anonymous_token = "Request Anonymous Token";
|
||||
request_anonymous_token_description = "Tap “Request Anonymous Token” to request a token from Musixmatch without authorization.";
|
||||
|
||||
lrclib_api = "Server Address";
|
||||
|
||||
@@ -111,3 +111,5 @@ contributors = "Участники";
|
||||
|
||||
request_anonymous_token = "Запросить анонимный токен";
|
||||
request_anonymous_token_description = "Нажмите Запросить анонимный токен, чтобы запросить токен у Musixmatch без авторизации.";
|
||||
|
||||
lrclib_api = "Адрес сервера";
|
||||
|
||||
Reference in New Issue
Block a user