From 47c35d2531582e857fe6c8f7ef2d38562069efe5 Mon Sep 17 00:00:00 2001 From: eevee Date: Fri, 10 May 2024 01:30:12 +0300 Subject: [PATCH] v3.0: LRCLIB and Musixmatch lyrics sources, settings menu, normalized colors, fixed Genius translation issue --- ...iusLyrics.x.swift => CustomLyrics.x.swift} | 101 ++++++++++------ .../EeveeSpotify/Helpers/LyricsHelper.swift | 112 ++++++++++++++++++ .../EeveeSpotify/Helpers/WindowHelper.swift | 30 +++++ .../DataSources/GeniusLyricsDataSource.swift} | 35 ++++-- .../DataSources/LrclibLyricsDataSource.swift | 59 +++++++++ .../MusixmatchLyricsDataSource.swift | 91 ++++++++++++++ .../Lyrics/LyricsRepository.swift | 56 +++++++++ .../Models/Genius/GeniusDataResponse.swift | 0 .../Models/Genius/GeniusHit.swift | 0 .../Models/Genius/GeniusHitResult.swift | 0 .../Models/Genius/GeniusHitsResponse.swift | 0 .../Models/Genius/GeniusLyrics.swift | 0 .../Models/Genius/GeniusRootResponse.swift | 0 .../Models/Genius/GeniusSong.swift | 0 .../Models/Genius/GeniusSongResponse.swift | 0 .../Lyrics/Models/Lrclib/LrclibSong.swift | 7 ++ .../{ => Lyrics}/Models/Lyrics.pb.swift | 0 .../Models/LyricsError.swift} | 2 +- .../Lyrics/Models/LyricsSource.swift | 15 +++ .../Musixmatch/MusixmatchSubtitle.swift | 6 + .../Models/Musixmatch/MusixmatchTime.swift | 5 + .../Lyrics/Models/PlainLyrics.swift | 6 + .../Models/Extensions/Color+Extension.swift | 22 +++- .../Models/Extensions/Data+Extension.swift | 16 +++ .../Models/Extensions/String+Extension.swift | 26 +++- .../Models/Extensions/UIColor+Extension.swift | 23 ++++ .../Extensions/UserDefaults+Extension.swift | 33 ++++++ .../Headers/SPTDisclosureAccessoryView.swift | 6 + .../Models/Headers/SPTPlayerTrack.swift | 1 + .../Headers/SPTSettingsTableViewCell.swift | 5 + .../EeveeSpotify/Models/Headers/SPTURL.swift | 6 + .../Views/EeveeSettingsView.swift | 106 +++++++++++++++++ .../Views/EeveeSettingsViewController.swift | 15 +++ control | 2 +- 34 files changed, 726 insertions(+), 60 deletions(-) rename Sources/EeveeSpotify/{GeniusLyrics.x.swift => CustomLyrics.x.swift} (62%) create mode 100644 Sources/EeveeSpotify/Helpers/LyricsHelper.swift rename Sources/EeveeSpotify/{GeniusApi.swift => Lyrics/DataSources/GeniusLyricsDataSource.swift} (58%) create mode 100644 Sources/EeveeSpotify/Lyrics/DataSources/LrclibLyricsDataSource.swift create mode 100644 Sources/EeveeSpotify/Lyrics/DataSources/MusixmatchLyricsDataSource.swift create mode 100644 Sources/EeveeSpotify/Lyrics/LyricsRepository.swift rename Sources/EeveeSpotify/{ => Lyrics}/Models/Genius/GeniusDataResponse.swift (100%) rename Sources/EeveeSpotify/{ => Lyrics}/Models/Genius/GeniusHit.swift (100%) rename Sources/EeveeSpotify/{ => Lyrics}/Models/Genius/GeniusHitResult.swift (100%) rename Sources/EeveeSpotify/{ => Lyrics}/Models/Genius/GeniusHitsResponse.swift (100%) rename Sources/EeveeSpotify/{ => Lyrics}/Models/Genius/GeniusLyrics.swift (100%) rename Sources/EeveeSpotify/{ => Lyrics}/Models/Genius/GeniusRootResponse.swift (100%) rename Sources/EeveeSpotify/{ => Lyrics}/Models/Genius/GeniusSong.swift (100%) rename Sources/EeveeSpotify/{ => Lyrics}/Models/Genius/GeniusSongResponse.swift (100%) create mode 100644 Sources/EeveeSpotify/Lyrics/Models/Lrclib/LrclibSong.swift rename Sources/EeveeSpotify/{ => Lyrics}/Models/Lyrics.pb.swift (100%) rename Sources/EeveeSpotify/{Models/GeniusLyricsError.swift => Lyrics/Models/LyricsError.swift} (69%) create mode 100644 Sources/EeveeSpotify/Lyrics/Models/LyricsSource.swift create mode 100644 Sources/EeveeSpotify/Lyrics/Models/Musixmatch/MusixmatchSubtitle.swift create mode 100644 Sources/EeveeSpotify/Lyrics/Models/Musixmatch/MusixmatchTime.swift create mode 100644 Sources/EeveeSpotify/Lyrics/Models/PlainLyrics.swift create mode 100644 Sources/EeveeSpotify/Models/Extensions/Data+Extension.swift create mode 100644 Sources/EeveeSpotify/Models/Extensions/UIColor+Extension.swift create mode 100644 Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift create mode 100644 Sources/EeveeSpotify/Models/Headers/SPTDisclosureAccessoryView.swift create mode 100644 Sources/EeveeSpotify/Models/Headers/SPTSettingsTableViewCell.swift create mode 100644 Sources/EeveeSpotify/Models/Headers/SPTURL.swift create mode 100644 Sources/EeveeSpotify/Views/EeveeSettingsView.swift create mode 100644 Sources/EeveeSpotify/Views/EeveeSettingsViewController.swift diff --git a/Sources/EeveeSpotify/GeniusLyrics.x.swift b/Sources/EeveeSpotify/CustomLyrics.x.swift similarity index 62% rename from Sources/EeveeSpotify/GeniusLyrics.x.swift rename to Sources/EeveeSpotify/CustomLyrics.x.swift index 2156c31..7f0051b 100644 --- a/Sources/EeveeSpotify/GeniusLyrics.x.swift +++ b/Sources/EeveeSpotify/CustomLyrics.x.swift @@ -27,56 +27,83 @@ class EncoreButtonHook: ClassHook { } } + +class ProfileSettingsSectionHook: ClassHook { + + 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() diff --git a/Sources/EeveeSpotify/Helpers/LyricsHelper.swift b/Sources/EeveeSpotify/Helpers/LyricsHelper.swift new file mode 100644 index 0000000..a19c063 --- /dev/null +++ b/Sources/EeveeSpotify/Helpers/LyricsHelper.swift @@ -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( + "\\[(?\\d{2}):(?\\d{2}\\.\\d{2})\\] ?(?.*)" + )! + + 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 + } + } +} diff --git a/Sources/EeveeSpotify/Helpers/WindowHelper.swift b/Sources/EeveeSpotify/Helpers/WindowHelper.swift index 97b4800..a7ebbad 100644 --- a/Sources/EeveeSpotify/Helpers/WindowHelper.swift +++ b/Sources/EeveeSpotify/Helpers/WindowHelper.swift @@ -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 { diff --git a/Sources/EeveeSpotify/GeniusApi.swift b/Sources/EeveeSpotify/Lyrics/DataSources/GeniusLyricsDataSource.swift similarity index 58% rename from Sources/EeveeSpotify/GeniusApi.swift rename to Sources/EeveeSpotify/Lyrics/DataSources/GeniusLyricsDataSource.swift index 08a04a8..df36a2d 100644 --- a/Sources/EeveeSpotify/GeniusApi.swift +++ b/Sources/EeveeSpotify/Lyrics/DataSources/GeniusLyricsDataSource.swift @@ -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 } - } diff --git a/Sources/EeveeSpotify/Lyrics/DataSources/LrclibLyricsDataSource.swift b/Sources/EeveeSpotify/Lyrics/DataSources/LrclibLyricsDataSource.swift new file mode 100644 index 0000000..cff7134 --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/DataSources/LrclibLyricsDataSource.swift @@ -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) + } +} diff --git a/Sources/EeveeSpotify/Lyrics/DataSources/MusixmatchLyricsDataSource.swift b/Sources/EeveeSpotify/Lyrics/DataSources/MusixmatchLyricsDataSource.swift new file mode 100644 index 0000000..b134b30 --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/DataSources/MusixmatchLyricsDataSource.swift @@ -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) + } +} diff --git a/Sources/EeveeSpotify/Lyrics/LyricsRepository.swift b/Sources/EeveeSpotify/Lyrics/LyricsRepository.swift new file mode 100644 index 0000000..653fb3b --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/LyricsRepository.swift @@ -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) + } + } +} diff --git a/Sources/EeveeSpotify/Models/Genius/GeniusDataResponse.swift b/Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusDataResponse.swift similarity index 100% rename from Sources/EeveeSpotify/Models/Genius/GeniusDataResponse.swift rename to Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusDataResponse.swift diff --git a/Sources/EeveeSpotify/Models/Genius/GeniusHit.swift b/Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusHit.swift similarity index 100% rename from Sources/EeveeSpotify/Models/Genius/GeniusHit.swift rename to Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusHit.swift diff --git a/Sources/EeveeSpotify/Models/Genius/GeniusHitResult.swift b/Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusHitResult.swift similarity index 100% rename from Sources/EeveeSpotify/Models/Genius/GeniusHitResult.swift rename to Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusHitResult.swift diff --git a/Sources/EeveeSpotify/Models/Genius/GeniusHitsResponse.swift b/Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusHitsResponse.swift similarity index 100% rename from Sources/EeveeSpotify/Models/Genius/GeniusHitsResponse.swift rename to Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusHitsResponse.swift diff --git a/Sources/EeveeSpotify/Models/Genius/GeniusLyrics.swift b/Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusLyrics.swift similarity index 100% rename from Sources/EeveeSpotify/Models/Genius/GeniusLyrics.swift rename to Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusLyrics.swift diff --git a/Sources/EeveeSpotify/Models/Genius/GeniusRootResponse.swift b/Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusRootResponse.swift similarity index 100% rename from Sources/EeveeSpotify/Models/Genius/GeniusRootResponse.swift rename to Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusRootResponse.swift diff --git a/Sources/EeveeSpotify/Models/Genius/GeniusSong.swift b/Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusSong.swift similarity index 100% rename from Sources/EeveeSpotify/Models/Genius/GeniusSong.swift rename to Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusSong.swift diff --git a/Sources/EeveeSpotify/Models/Genius/GeniusSongResponse.swift b/Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusSongResponse.swift similarity index 100% rename from Sources/EeveeSpotify/Models/Genius/GeniusSongResponse.swift rename to Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusSongResponse.swift diff --git a/Sources/EeveeSpotify/Lyrics/Models/Lrclib/LrclibSong.swift b/Sources/EeveeSpotify/Lyrics/Models/Lrclib/LrclibSong.swift new file mode 100644 index 0000000..74dc483 --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Models/Lrclib/LrclibSong.swift @@ -0,0 +1,7 @@ +import Foundation + +struct LrclibSong: Decodable { + var name: String + var plainLyrics: String + var syncedLyrics: String? +} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Models/Lyrics.pb.swift b/Sources/EeveeSpotify/Lyrics/Models/Lyrics.pb.swift similarity index 100% rename from Sources/EeveeSpotify/Models/Lyrics.pb.swift rename to Sources/EeveeSpotify/Lyrics/Models/Lyrics.pb.swift diff --git a/Sources/EeveeSpotify/Models/GeniusLyricsError.swift b/Sources/EeveeSpotify/Lyrics/Models/LyricsError.swift similarity index 69% rename from Sources/EeveeSpotify/Models/GeniusLyricsError.swift rename to Sources/EeveeSpotify/Lyrics/Models/LyricsError.swift index 5df7e64..ef1f6ce 100644 --- a/Sources/EeveeSpotify/Models/GeniusLyricsError.swift +++ b/Sources/EeveeSpotify/Lyrics/Models/LyricsError.swift @@ -1,6 +1,6 @@ import Foundation -enum GeniusLyricsError: Swift.Error { +enum LyricsError: Swift.Error { case NoCurrentTrack case DecodingError case NoSuchSong diff --git a/Sources/EeveeSpotify/Lyrics/Models/LyricsSource.swift b/Sources/EeveeSpotify/Lyrics/Models/LyricsSource.swift new file mode 100644 index 0000000..3b3c515 --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Models/LyricsSource.swift @@ -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" + } + } +} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Lyrics/Models/Musixmatch/MusixmatchSubtitle.swift b/Sources/EeveeSpotify/Lyrics/Models/Musixmatch/MusixmatchSubtitle.swift new file mode 100644 index 0000000..f3a7fdc --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Models/Musixmatch/MusixmatchSubtitle.swift @@ -0,0 +1,6 @@ +import Foundation + +struct MusixmatchSubtitle: Decodable { + var text: String + var time: MusixmatchTime +} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Lyrics/Models/Musixmatch/MusixmatchTime.swift b/Sources/EeveeSpotify/Lyrics/Models/Musixmatch/MusixmatchTime.swift new file mode 100644 index 0000000..1289256 --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Models/Musixmatch/MusixmatchTime.swift @@ -0,0 +1,5 @@ +import Foundation + +struct MusixmatchTime: Decodable { + var total: Float +} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Lyrics/Models/PlainLyrics.swift b/Sources/EeveeSpotify/Lyrics/Models/PlainLyrics.swift new file mode 100644 index 0000000..eca5bd1 --- /dev/null +++ b/Sources/EeveeSpotify/Lyrics/Models/PlainLyrics.swift @@ -0,0 +1,6 @@ +import Foundation + +struct PlainLyrics { + var content: String + var timeSynced: Bool +} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Models/Extensions/Color+Extension.swift b/Sources/EeveeSpotify/Models/Extensions/Color+Extension.swift index 4ef8d8d..309990a 100644 --- a/Sources/EeveeSpotify/Models/Extensions/Color+Extension.swift +++ b/Sources/EeveeSpotify/Models/Extensions/Color+Extension.swift @@ -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 diff --git a/Sources/EeveeSpotify/Models/Extensions/Data+Extension.swift b/Sources/EeveeSpotify/Models/Extensions/Data+Extension.swift new file mode 100644 index 0000000..ae1c86a --- /dev/null +++ b/Sources/EeveeSpotify/Models/Extensions/Data+Extension.swift @@ -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 + } +} diff --git a/Sources/EeveeSpotify/Models/Extensions/String+Extension.swift b/Sources/EeveeSpotify/Models/Extensions/String+Extension.swift index 535a109..6c65683 100644 --- a/Sources/EeveeSpotify/Models/Extensions/String+Extension.swift +++ b/Sources/EeveeSpotify/Models/Extensions/String+Extension.swift @@ -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: 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 { diff --git a/Sources/EeveeSpotify/Models/Extensions/UIColor+Extension.swift b/Sources/EeveeSpotify/Models/Extensions/UIColor+Extension.swift new file mode 100644 index 0000000..a9de376 --- /dev/null +++ b/Sources/EeveeSpotify/Models/Extensions/UIColor+Extension.swift @@ -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) } +} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift b/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift new file mode 100644 index 0000000..75f737d --- /dev/null +++ b/Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift @@ -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) + } + } +} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Models/Headers/SPTDisclosureAccessoryView.swift b/Sources/EeveeSpotify/Models/Headers/SPTDisclosureAccessoryView.swift new file mode 100644 index 0000000..f3abff6 --- /dev/null +++ b/Sources/EeveeSpotify/Models/Headers/SPTDisclosureAccessoryView.swift @@ -0,0 +1,6 @@ +import Foundation +import UIKit + +@objc protocol SPTDisclosureAccessoryView { + static func disclosureAccessoryView() -> UIView +} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Models/Headers/SPTPlayerTrack.swift b/Sources/EeveeSpotify/Models/Headers/SPTPlayerTrack.swift index 10d9422..70d7364 100644 --- a/Sources/EeveeSpotify/Models/Headers/SPTPlayerTrack.swift +++ b/Sources/EeveeSpotify/Models/Headers/SPTPlayerTrack.swift @@ -5,4 +5,5 @@ import Foundation func extractedColorHex() -> String func trackTitle() -> String func artistTitle() -> String + func URI() -> SPTURL } diff --git a/Sources/EeveeSpotify/Models/Headers/SPTSettingsTableViewCell.swift b/Sources/EeveeSpotify/Models/Headers/SPTSettingsTableViewCell.swift new file mode 100644 index 0000000..b5e05c7 --- /dev/null +++ b/Sources/EeveeSpotify/Models/Headers/SPTSettingsTableViewCell.swift @@ -0,0 +1,5 @@ +import Foundation + +@objc protocol SPTSettingsTableViewCell { + func initWithStyle(_ style: Int, reuseIdentifier: String) -> SPTSettingsTableViewCell +} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Models/Headers/SPTURL.swift b/Sources/EeveeSpotify/Models/Headers/SPTURL.swift new file mode 100644 index 0000000..bda67ab --- /dev/null +++ b/Sources/EeveeSpotify/Models/Headers/SPTURL.swift @@ -0,0 +1,6 @@ +import Foundation + +// it's NSURL actually, just with Spotify extensions +@objc protocol SPTURL { + func spt_trackIdentifier() -> String +} diff --git a/Sources/EeveeSpotify/Views/EeveeSettingsView.swift b/Sources/EeveeSpotify/Views/EeveeSettingsView.swift new file mode 100644 index 0000000..041f8a5 --- /dev/null +++ b/Sources/EeveeSpotify/Views/EeveeSettingsView.swift @@ -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) + } + } +} \ No newline at end of file diff --git a/Sources/EeveeSpotify/Views/EeveeSettingsViewController.swift b/Sources/EeveeSpotify/Views/EeveeSettingsViewController.swift new file mode 100644 index 0000000..a7846d2 --- /dev/null +++ b/Sources/EeveeSpotify/Views/EeveeSettingsViewController.swift @@ -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) + } +} diff --git a/control b/control index 50f15f2..d86d703 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.eevee.spotify Name: EeveeSpotify -Version: 2.1 +Version: 3.0 Architecture: iphoneos-arm Description: A tweak to get Spotify Premium for free, just like Spotilife Maintainer: Eevee