mirror of
https://github.com/whoeevee/EeveeSpotifyReborn.git
synced 2026-01-09 00:23:20 +01:00
v3.0: LRCLIB and Musixmatch lyrics sources, settings menu, normalized colors, fixed Genius translation issue
This commit is contained in:
+64
-37
@@ -27,56 +27,83 @@ class EncoreButtonHook: ClassHook<UIButton> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ProfileSettingsSectionHook: ClassHook<NSObject> {
|
||||
|
||||
static let targetName = "ProfileSettingsSection"
|
||||
|
||||
func numberOfRows() -> Int {
|
||||
return 2
|
||||
}
|
||||
|
||||
func didSelectRow(_ row: Int) {
|
||||
|
||||
if row == 1 {
|
||||
|
||||
let rootSettingsController = WindowHelper.shared.findFirstViewController(
|
||||
"RootSettingsViewController"
|
||||
)!
|
||||
|
||||
let eeveeSettingsController = EeveeSettingsViewController()
|
||||
eeveeSettingsController.title = "EeveeSpotify"
|
||||
|
||||
rootSettingsController.navigationController!.pushViewController(
|
||||
eeveeSettingsController,
|
||||
animated: true
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
orig.didSelectRow(row)
|
||||
}
|
||||
|
||||
func cellForRow(_ row: Int) -> UITableViewCell {
|
||||
|
||||
if row == 1 {
|
||||
|
||||
let settingsTableCell = Dynamic.SPTSettingsTableViewCell
|
||||
.alloc(interface: SPTSettingsTableViewCell.self)
|
||||
.initWithStyle(3, reuseIdentifier: "EeveeSpotify")
|
||||
|
||||
let tableViewCell = Dynamic.convert(settingsTableCell, to: UITableViewCell.self)
|
||||
|
||||
tableViewCell.accessoryView = type(
|
||||
of: Dynamic.SPTDisclosureAccessoryView
|
||||
.alloc(interface: SPTDisclosureAccessoryView.self)
|
||||
)
|
||||
.disclosureAccessoryView()
|
||||
|
||||
tableViewCell.textLabel?.text = "EeveeSpotify"
|
||||
return tableViewCell
|
||||
}
|
||||
|
||||
return orig.cellForRow(row)
|
||||
}
|
||||
}
|
||||
|
||||
func getCurrentTrackLyricsData() throws -> Data {
|
||||
|
||||
guard let track = HookedInstances.currentTrack else {
|
||||
throw GeniusLyricsError.NoCurrentTrack
|
||||
throw LyricsError.NoCurrentTrack
|
||||
}
|
||||
|
||||
let title = track.trackTitle()
|
||||
.removeMatches("\\(.*\\)")
|
||||
.prefix(30)
|
||||
let source = UserDefaults.lyricsSource
|
||||
|
||||
let artist = track.artistTitle()
|
||||
|
||||
let geniusHits = try GeniusApi.search("\(title) \(artist)")
|
||||
|
||||
guard let geniusSong = (
|
||||
geniusHits.first(
|
||||
where: { $0.result.title.containsInsensitive(title) }
|
||||
) ?? geniusHits.first
|
||||
)?.result else {
|
||||
throw GeniusLyricsError.NoSuchSong
|
||||
}
|
||||
|
||||
let geniusSongInfo = try GeniusApi.getSongInfo(geniusSong.id)
|
||||
|
||||
var geniusLyrics = geniusSongInfo.lyrics.plain
|
||||
.components(separatedBy: "\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
|
||||
geniusLyrics.removeAll { $0.matches("\\[.*\\]") }
|
||||
|
||||
geniusLyrics = Array(
|
||||
geniusLyrics
|
||||
.drop(while: { $0.isEmpty })
|
||||
.dropLast(while: { $0.isEmpty })
|
||||
let plainLyrics = try LyricsRepository.getLyrics(
|
||||
title: track.trackTitle(),
|
||||
artist: track.artistTitle(),
|
||||
spotifyTrackId: track.URI().spt_trackIdentifier(),
|
||||
source: source
|
||||
)
|
||||
|
||||
let lyrics = Lyrics.with {
|
||||
$0.colors = LyricsColors.with {
|
||||
$0.backgroundColor = Color(hex: track.extractedColorHex()).uInt32
|
||||
$0.backgroundColor = Color(hex: track.extractedColorHex()).normalized.uInt32
|
||||
$0.lineColor = Color.black.uInt32
|
||||
$0.activeLineColor = Color.white.uInt32
|
||||
}
|
||||
$0.data = LyricsData.with {
|
||||
$0.timeSynchronized = false
|
||||
$0.restriction = .unrestricted
|
||||
$0.providedBy = "Genius (EeveeSpotify)"
|
||||
$0.lines = geniusLyrics.map { line in
|
||||
LyricsLine.with { $0.content = line }
|
||||
}
|
||||
}
|
||||
$0.data = try! LyricsHelper.composeLyricsData(plainLyrics, source: source)
|
||||
}
|
||||
|
||||
return try lyrics.serializedData()
|
||||
@@ -0,0 +1,112 @@
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
class LyricsHelper {
|
||||
|
||||
static func composeLyricsData(_ plain: PlainLyrics, source: LyricsSource) throws -> LyricsData {
|
||||
|
||||
var lyricLines: [LyricsLine] = []
|
||||
|
||||
switch source {
|
||||
|
||||
case .genius:
|
||||
|
||||
var lines = plain
|
||||
.content
|
||||
.components(separatedBy: "\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
|
||||
lines.removeAll { $0 ~= "\\[.*\\]" }
|
||||
|
||||
lines = Array(
|
||||
lines
|
||||
.drop(while: { $0.isEmpty })
|
||||
.dropLast(while: { $0.isEmpty })
|
||||
)
|
||||
|
||||
lyricLines = lines.map { line in
|
||||
LyricsLine.with { $0.content = line }
|
||||
}
|
||||
|
||||
case .lrclib:
|
||||
|
||||
let lines = plain
|
||||
.content
|
||||
.components(separatedBy: "\n")
|
||||
.dropLast()
|
||||
|
||||
if plain.timeSynced {
|
||||
|
||||
lyricLines = lines.map { line in
|
||||
|
||||
let match = line.firstMatch(
|
||||
"\\[(?<minute>\\d{2}):(?<seconds>\\d{2}\\.\\d{2})\\] ?(?<content>.*)"
|
||||
)!
|
||||
|
||||
var captures: [String: String] = [:]
|
||||
|
||||
for name in ["minute", "seconds", "content"] {
|
||||
|
||||
let matchRange = match.range(withName: name)
|
||||
|
||||
if let substringRange = Range(matchRange, in: line) {
|
||||
let capture = String(line[substringRange])
|
||||
captures[name] = capture
|
||||
}
|
||||
}
|
||||
|
||||
let minute = Int(captures["minute"]!)!
|
||||
let seconds = Float(captures["seconds"]!)!
|
||||
let content = captures["content"]!
|
||||
|
||||
return LyricsLine.with {
|
||||
$0.offsetMs = Int32(minute * 60 * 1000 + Int(seconds * 1000))
|
||||
$0.content = content.lyricsNoteIfEmpty
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
lyricLines = lines.map { line in
|
||||
LyricsLine.with { $0.content = line }
|
||||
}
|
||||
|
||||
case .musixmatch:
|
||||
|
||||
if plain.timeSynced {
|
||||
|
||||
let lines = try JSONDecoder().decode(
|
||||
[MusixmatchSubtitle].self,
|
||||
from: plain.content.data(using: .utf8)!
|
||||
)
|
||||
.dropLast()
|
||||
|
||||
lyricLines = lines.map { line in
|
||||
LyricsLine.with {
|
||||
$0.offsetMs = Int32(Int(line.time.total * 1000))
|
||||
$0.content = line.text.lyricsNoteIfEmpty
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
let lines = plain
|
||||
.content
|
||||
.components(separatedBy: "\n")
|
||||
.dropLast()
|
||||
|
||||
lyricLines = lines.map { line in
|
||||
LyricsLine.with { $0.content = line }
|
||||
}
|
||||
}
|
||||
|
||||
return LyricsData.with {
|
||||
$0.timeSynchronized = plain.timeSynced
|
||||
$0.restriction = .unrestricted
|
||||
$0.providedBy = "\(source) (EeveeSpotify)"
|
||||
$0.lines = lyricLines
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,36 @@ class WindowHelper {
|
||||
self.rootViewController = window.rootViewController!
|
||||
}
|
||||
|
||||
func present(_ viewController: UIViewController) {
|
||||
rootViewController.present(viewController, animated: true)
|
||||
}
|
||||
|
||||
func findFirstViewController(_ regex: String) -> UIViewController? {
|
||||
|
||||
let rootView = self.rootViewController.view!
|
||||
var result: UIViewController?
|
||||
|
||||
func searchViews(_ view: UIView) {
|
||||
if let viewController = self.viewController(for: view) {
|
||||
if String(describing: type(of: viewController)) ~= regex {
|
||||
result = viewController
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for subview in view.subviews {
|
||||
searchViews(subview)
|
||||
}
|
||||
}
|
||||
|
||||
searchViews(rootView)
|
||||
return result
|
||||
}
|
||||
|
||||
func overrideUserInterfaceStyle(_ style: UIUserInterfaceStyle) {
|
||||
window.overrideUserInterfaceStyle = style
|
||||
}
|
||||
|
||||
func viewController(for view: UIView) -> UIViewController? {
|
||||
var responder: UIResponder? = view
|
||||
while let nextResponder = responder?.next {
|
||||
|
||||
+23
-12
@@ -1,10 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct GeniusApi {
|
||||
struct GeniusLyricsDataSource {
|
||||
|
||||
private static let apiUrl = "https://api.genius.com"
|
||||
private let apiUrl = "https://api.genius.com"
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.httpAdditionalHeaders = ["X-Genius-iOS-Version": "6.19.1"]
|
||||
|
||||
session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
private static func perform(
|
||||
private func perform(
|
||||
_ path: String,
|
||||
query: [String:Any] = [:]
|
||||
) throws -> GeniusDataResponse? {
|
||||
@@ -12,7 +20,6 @@ struct GeniusApi {
|
||||
var stringUrl = "\(apiUrl)\(path)"
|
||||
|
||||
if !query.isEmpty {
|
||||
|
||||
let queryString = query.queryString.addingPercentEncoding(
|
||||
withAllowedCharacters: .urlHostAllowed
|
||||
)!
|
||||
@@ -20,13 +27,14 @@ struct GeniusApi {
|
||||
stringUrl += "?\(queryString)"
|
||||
}
|
||||
|
||||
var request = URLRequest(url: URL(string: stringUrl)!)
|
||||
request.addValue("6.19.1", forHTTPHeaderField: "X-Genius-iOS-Version")
|
||||
let request = URLRequest(url: URL(string: stringUrl)!)
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var data: Data?
|
||||
var error: Error?
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { response, _, _ in
|
||||
let task = session.dataTask(with: request) { response, _, err in
|
||||
error = err
|
||||
data = response
|
||||
semaphore.signal()
|
||||
}
|
||||
@@ -34,30 +42,33 @@ struct GeniusApi {
|
||||
task.resume()
|
||||
semaphore.wait()
|
||||
|
||||
if let error = error {
|
||||
throw error
|
||||
}
|
||||
|
||||
let rootResponse = try JSONDecoder().decode(GeniusRootResponse.self, from: data!)
|
||||
return rootResponse.response
|
||||
}
|
||||
|
||||
static func search(_ query: String) throws -> [GeniusHit] {
|
||||
func search(_ query: String) throws -> [GeniusHit] {
|
||||
|
||||
let data = try perform("/search", query: ["q": query])
|
||||
|
||||
guard case .hits(let hitsResponse) = data else {
|
||||
throw GeniusLyricsError.DecodingError
|
||||
throw LyricsError.DecodingError
|
||||
}
|
||||
|
||||
return hitsResponse.hits
|
||||
}
|
||||
|
||||
static func getSongInfo(_ songId: Int) throws -> GeniusSong {
|
||||
func getSongInfo(_ songId: Int) throws -> GeniusSong {
|
||||
|
||||
let data = try perform("/songs/\(songId)", query: ["text_format": "plain"])
|
||||
|
||||
guard case .song(let songResponse) = data else {
|
||||
throw GeniusLyricsError.DecodingError
|
||||
throw LyricsError.DecodingError
|
||||
}
|
||||
|
||||
return songResponse.song
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
struct LrcLibLyricsDataSource {
|
||||
|
||||
private let apiUrl = "https://lrclib.net/api"
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.httpAdditionalHeaders = [
|
||||
"User-Agent": "EeveeSpotify v3.0 https://github.com/whoeevee/EeveeSpotify"
|
||||
]
|
||||
|
||||
session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
private func perform(
|
||||
_ path: String,
|
||||
query: [String:Any] = [:]
|
||||
) throws -> Data {
|
||||
|
||||
var stringUrl = "\(apiUrl)\(path)"
|
||||
|
||||
if !query.isEmpty {
|
||||
let queryString = query.queryString.addingPercentEncoding(
|
||||
withAllowedCharacters: .urlHostAllowed
|
||||
)!
|
||||
|
||||
stringUrl += "?\(queryString)"
|
||||
}
|
||||
|
||||
let request = URLRequest(url: URL(string: stringUrl)!)
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var data: Data?
|
||||
var error: Error?
|
||||
|
||||
let task = session.dataTask(with: request) { response, _, err in
|
||||
error = err
|
||||
data = response
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
task.resume()
|
||||
semaphore.wait()
|
||||
|
||||
if let error = error {
|
||||
throw error
|
||||
}
|
||||
|
||||
return data!
|
||||
}
|
||||
|
||||
func search(_ query: String) throws -> [LrclibSong] {
|
||||
|
||||
let data = try perform("/search", query: ["q": query])
|
||||
return try JSONDecoder().decode([LrclibSong].self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
struct MusixmatchLyricsDataSource {
|
||||
|
||||
private let apiUrl = "https://apic.musixmatch.com"
|
||||
|
||||
private func perform(
|
||||
_ path: String,
|
||||
query: [String:Any] = [:]
|
||||
) throws -> Data {
|
||||
|
||||
var stringUrl = "\(apiUrl)\(path)"
|
||||
|
||||
var finalQuery = query
|
||||
|
||||
finalQuery["usertoken"] = UserDefaults.musixmatchToken
|
||||
finalQuery["app_id"] = "mac-ios-v2.0"
|
||||
|
||||
let queryString = finalQuery.queryString.addingPercentEncoding(
|
||||
withAllowedCharacters: .urlHostAllowed
|
||||
)!
|
||||
|
||||
stringUrl += "?\(queryString)"
|
||||
|
||||
let request = URLRequest(url: URL(string: stringUrl)!)
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var data: Data?
|
||||
var error: Error?
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { response, _, err in
|
||||
error = err
|
||||
data = response
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
task.resume()
|
||||
semaphore.wait()
|
||||
|
||||
if let error = error {
|
||||
throw error
|
||||
}
|
||||
|
||||
return data!
|
||||
}
|
||||
|
||||
func getLyrics(_ spotifyTrackId: String) throws -> PlainLyrics {
|
||||
|
||||
let data = try perform(
|
||||
"/ws/1.1/macro.subtitles.get",
|
||||
query: [
|
||||
"track_spotify_id": spotifyTrackId,
|
||||
"subtitle_format": "mxm",
|
||||
"q_track": " "
|
||||
]
|
||||
)
|
||||
|
||||
// 😭😭😭
|
||||
|
||||
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 macroCalls = body["macro_calls"] as? [String: Any]
|
||||
else {
|
||||
throw LyricsError.DecodingError
|
||||
}
|
||||
|
||||
if let trackSubtitlesGet = macroCalls["track.subtitles.get"] as? [String: Any],
|
||||
let subtitlesMessage = trackSubtitlesGet["message"] as? [String: Any],
|
||||
let subtitlesBody = subtitlesMessage["body"] as? [String: Any],
|
||||
let subtitlesList = subtitlesBody["subtitle_list"] as? [Any],
|
||||
let firstSubtitle = subtitlesList.first as? [String: Any],
|
||||
let subtitle = firstSubtitle["subtitle"] as? [String: Any],
|
||||
let subtitleBody = subtitle["subtitle_body"] as? String {
|
||||
return PlainLyrics(content: subtitleBody, timeSynced: true)
|
||||
}
|
||||
|
||||
guard
|
||||
let trackLyricsGet = macroCalls["track.lyrics.get"] as? [String: Any],
|
||||
let lyricsMessage = trackLyricsGet["message"] as? [String: Any],
|
||||
let lyricsBody = lyricsMessage["body"] as? [String: Any],
|
||||
let lyrics = lyricsBody["lyrics"] as? [String: Any],
|
||||
let plainLyrics = lyrics["lyrics_body"] as? String
|
||||
else {
|
||||
throw LyricsError.DecodingError
|
||||
}
|
||||
|
||||
return PlainLyrics(content: plainLyrics, timeSynced: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
struct LyricsRepository {
|
||||
|
||||
private static let geniusDataSource = GeniusLyricsDataSource()
|
||||
private static let lrclibDataSource = LrcLibLyricsDataSource()
|
||||
private static let musixmatchDataSource = MusixmatchLyricsDataSource()
|
||||
|
||||
static func getLyrics(
|
||||
title: String,
|
||||
artist: String,
|
||||
spotifyTrackId: String,
|
||||
source: LyricsSource
|
||||
) throws -> PlainLyrics {
|
||||
|
||||
let query = "\(title.strippedTrackTitle) \(artist)"
|
||||
|
||||
switch source {
|
||||
|
||||
case .genius:
|
||||
|
||||
let hits = try geniusDataSource.search(query)
|
||||
|
||||
guard let song = (
|
||||
hits.first(
|
||||
where: { $0.result.title.containsInsensitive(title) }
|
||||
) ?? hits.first
|
||||
)?.result else {
|
||||
throw LyricsError.NoSuchSong
|
||||
}
|
||||
|
||||
let songInfo = try geniusDataSource.getSongInfo(song.id)
|
||||
return PlainLyrics(content: songInfo.lyrics.plain, timeSynced: false)
|
||||
|
||||
case .lrclib:
|
||||
|
||||
let hits = try lrclibDataSource.search(query)
|
||||
|
||||
guard let song = (
|
||||
hits.first(
|
||||
where: { $0.name.containsInsensitive(title) }
|
||||
) ?? hits.first
|
||||
) else {
|
||||
throw LyricsError.NoSuchSong
|
||||
}
|
||||
|
||||
return PlainLyrics(
|
||||
content: song.syncedLyrics ?? song.plainLyrics,
|
||||
timeSynced: song.syncedLyrics != nil
|
||||
)
|
||||
|
||||
case .musixmatch:
|
||||
return try musixmatchDataSource.getLyrics(spotifyTrackId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct LrclibSong: Decodable {
|
||||
var name: String
|
||||
var plainLyrics: String
|
||||
var syncedLyrics: String?
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
enum GeniusLyricsError: Swift.Error {
|
||||
enum LyricsError: Swift.Error {
|
||||
case NoCurrentTrack
|
||||
case DecodingError
|
||||
case NoSuchSong
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
enum LyricsSource : Int, CustomStringConvertible {
|
||||
case genius
|
||||
case lrclib
|
||||
case musixmatch
|
||||
|
||||
var description : String {
|
||||
switch self {
|
||||
case .genius: "Genius"
|
||||
case .lrclib: "LRCLIB"
|
||||
case .musixmatch: "Musixmatch"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct MusixmatchSubtitle: Decodable {
|
||||
var text: String
|
||||
var time: MusixmatchTime
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
struct MusixmatchTime: Decodable {
|
||||
var total: Float
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct PlainLyrics {
|
||||
var content: String
|
||||
var timeSynced: Bool
|
||||
}
|
||||
@@ -30,20 +30,34 @@ extension Color {
|
||||
}
|
||||
|
||||
var components: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
|
||||
|
||||
typealias NativeColor = UIColor
|
||||
|
||||
var r: CGFloat = 0
|
||||
var g: CGFloat = 0
|
||||
var b: CGFloat = 0
|
||||
var a: CGFloat = 0
|
||||
|
||||
guard NativeColor(self).getRed(&r, green: &g, blue: &b, alpha: &a) else {
|
||||
guard UIColor(self).getRed(&r, green: &g, blue: &b, alpha: &a) else {
|
||||
return (0, 0, 0, 0)
|
||||
}
|
||||
|
||||
return (r, g, b, a)
|
||||
}
|
||||
|
||||
func lighter(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).lighter(by: amount)) }
|
||||
func darker(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).darker(by: amount)) }
|
||||
|
||||
var brightness: CGFloat {
|
||||
(
|
||||
components.red * 299
|
||||
+ components.green * 587
|
||||
+ components.blue * 114
|
||||
) / 1000
|
||||
}
|
||||
|
||||
var normalized: Color {
|
||||
brightness < 0.5
|
||||
? self.lighter(by: 0.5)
|
||||
: self.darker(by: brightness - 0.5)
|
||||
}
|
||||
|
||||
var uInt32: UInt32 {
|
||||
UInt32(components.alpha * 255) << 24
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
|
||||
static func random(_ length: Int) -> Data {
|
||||
return Data((0 ..< length).map { _ in UInt8.random(in: UInt8.min ... UInt8.max) })
|
||||
}
|
||||
|
||||
var hexEncodedString: String {
|
||||
map { String(format: "%02hhx", $0) }.joined()
|
||||
}
|
||||
|
||||
static var musixmatchTokenPlaceholder: String {
|
||||
"2" + self.random(53).hexEncodedString
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,38 @@ import Foundation
|
||||
extension String {
|
||||
|
||||
static func ~= (lhs: String, rhs: String) -> Bool {
|
||||
guard let regex = try? NSRegularExpression(pattern: rhs) else { return false }
|
||||
let range = NSRange(location: 0, length: lhs.utf16.count)
|
||||
return regex.firstMatch(in: lhs, options: [], range: range) != nil
|
||||
lhs.firstMatch(rhs) != nil
|
||||
}
|
||||
|
||||
var range: NSRange {
|
||||
NSRange(self.startIndex..., in: self)
|
||||
}
|
||||
|
||||
var strippedTrackTitle: String {
|
||||
String(
|
||||
self
|
||||
.removeMatches("\\(.*\\)")
|
||||
//.removeMatches("- .*")
|
||||
.prefix(30)
|
||||
//.trimmingCharacters(in: .whitespaces)
|
||||
)
|
||||
}
|
||||
|
||||
var isHex: Bool {
|
||||
self ~= "^[a-f0-9]+$"
|
||||
}
|
||||
|
||||
var lyricsNoteIfEmpty: String {
|
||||
self.isEmpty ? "♪" : self
|
||||
}
|
||||
|
||||
func containsInsensitive<S: StringProtocol>(_ s: S) -> Bool {
|
||||
self.range(of: s, options: .caseInsensitive) != nil
|
||||
}
|
||||
|
||||
func matches(_ pattern: String) -> Bool {
|
||||
func firstMatch(_ pattern: String) -> NSTextCheckingResult? {
|
||||
try! NSRegularExpression(pattern: pattern)
|
||||
.firstMatch(in: self, range: self.range) != nil
|
||||
.firstMatch(in: self, range: self.range)
|
||||
}
|
||||
|
||||
func removeMatches(_ pattern: String) -> String {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
extension UIColor {
|
||||
|
||||
func mix(with target: UIColor, amount: CGFloat) -> Self {
|
||||
var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0
|
||||
var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0
|
||||
|
||||
self.getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
|
||||
target.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
|
||||
|
||||
return Self(
|
||||
red: r1 * (1.0 - amount) + r2 * amount,
|
||||
green: g1 * (1.0 - amount) + g2 * amount,
|
||||
blue: b1 * (1.0 - amount) + b2 * amount,
|
||||
alpha: a1
|
||||
)
|
||||
}
|
||||
|
||||
func lighter(by amount: CGFloat = 0.2) -> Self { mix(with: .white, amount: amount) }
|
||||
func darker(by amount: CGFloat = 0.2) -> Self { mix(with: .black, amount: amount) }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
extension UserDefaults {
|
||||
|
||||
private static let defaults = UserDefaults.standard
|
||||
|
||||
private static let lyricsSourceKey = "lyricsSource"
|
||||
private static let musixmatchTokenKey = "musixmatchToken"
|
||||
|
||||
static var lyricsSource: LyricsSource {
|
||||
|
||||
get {
|
||||
if let rawValue = defaults.object(forKey: lyricsSourceKey) as? Int {
|
||||
return LyricsSource(rawValue: rawValue)!
|
||||
}
|
||||
|
||||
return .lrclib
|
||||
}
|
||||
set (newSource) {
|
||||
defaults.set(newSource.rawValue, forKey: lyricsSourceKey)
|
||||
}
|
||||
}
|
||||
|
||||
static var musixmatchToken: String {
|
||||
|
||||
get {
|
||||
defaults.string(forKey: musixmatchTokenKey) ?? ""
|
||||
}
|
||||
set (token) {
|
||||
defaults.set(token, forKey: musixmatchTokenKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@objc protocol SPTDisclosureAccessoryView {
|
||||
static func disclosureAccessoryView() -> UIView
|
||||
}
|
||||
@@ -5,4 +5,5 @@ import Foundation
|
||||
func extractedColorHex() -> String
|
||||
func trackTitle() -> String
|
||||
func artistTitle() -> String
|
||||
func URI() -> SPTURL
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
@objc protocol SPTSettingsTableViewCell {
|
||||
func initWithStyle(_ style: Int, reuseIdentifier: String) -> SPTSettingsTableViewCell
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
// it's NSURL actually, just with Spotify extensions
|
||||
@objc protocol SPTURL {
|
||||
func spt_trackIdentifier() -> String
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct EeveeSettingsView: View {
|
||||
|
||||
@State private var musixmatchToken = UserDefaults.musixmatchToken
|
||||
@State private var lyricsSource = UserDefaults.lyricsSource
|
||||
|
||||
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 Musixmatch app. Please enter it here.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
|
||||
alert.addTextField() { textField in
|
||||
textField.placeholder = Data.musixmatchTokenPlaceholder
|
||||
}
|
||||
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
|
||||
lyricsSource = oldSource
|
||||
})
|
||||
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
|
||||
let token = alert.textFields!.first!.text!
|
||||
|
||||
if !(token ~= "^[a-f0-9]+$") {
|
||||
lyricsSource = oldSource
|
||||
return
|
||||
}
|
||||
|
||||
musixmatchToken = token
|
||||
UserDefaults.lyricsSource = .musixmatch
|
||||
})
|
||||
|
||||
WindowHelper.shared.present(alert)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
List {
|
||||
|
||||
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 the original Spotify one or a "Couldn't load the lyrics for this song" message. The lyrics might be wrong for some songs (e.g. another song, song article) 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", text: $musixmatchToken)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.padding(.bottom, 50)
|
||||
|
||||
.animation(.default, value: lyricsSource)
|
||||
|
||||
.onChange(of: musixmatchToken) { token in
|
||||
UserDefaults.musixmatchToken = token
|
||||
}
|
||||
|
||||
.onChange(of: lyricsSource) { [lyricsSource] newSource in
|
||||
|
||||
if newSource == .musixmatch && musixmatchToken.isEmpty {
|
||||
showMusixmatchTokenAlert(lyricsSource)
|
||||
return
|
||||
}
|
||||
|
||||
UserDefaults.lyricsSource = newSource
|
||||
}
|
||||
|
||||
.listStyle(GroupedListStyle())
|
||||
|
||||
.onAppear {
|
||||
UIView.appearance(
|
||||
whenContainedInInstancesOf: [UIAlertController.self]
|
||||
).tintColor = UIColor(Color(hex: "#1ed760"))
|
||||
|
||||
WindowHelper.shared.overrideUserInterfaceStyle(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class EeveeSettingsViewController: UIViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
let hostingController = UIHostingController(rootView: EeveeSettingsView())
|
||||
hostingController.view.frame = view.bounds
|
||||
|
||||
view.addSubview(hostingController.view)
|
||||
addChild(hostingController)
|
||||
hostingController.didMove(toParent: self)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user