├── .github ├── ISSUE_TEMPLATE │ └── bug.yaml └── workflows │ ├── build-swift.yml │ └── update-repo.yml ├── .gitignore ├── EeveeSpotify.plist ├── Images ├── banner.png └── hex.png ├── LICENSE ├── Makefile ├── Package.swift ├── README.md ├── Sources ├── EeveeSpotify │ ├── DarkPopUps.x.swift │ ├── DataLoaderServiceHooks.x.swift │ ├── Dependencies │ │ └── XMLCoder │ │ │ ├── Auxiliaries │ │ │ ├── Attribute.swift │ │ │ ├── Box │ │ │ │ ├── BoolBox.swift │ │ │ │ ├── Box.swift │ │ │ │ ├── ChoiceBox.swift │ │ │ │ ├── DataBox.swift │ │ │ │ ├── DateBox.swift │ │ │ │ ├── DecimalBox.swift │ │ │ │ ├── DoubleBox.swift │ │ │ │ ├── FloatBox.swift │ │ │ │ ├── IntBox.swift │ │ │ │ ├── KeyedBox.swift │ │ │ │ ├── NullBox.swift │ │ │ │ ├── SharedBox.swift │ │ │ │ ├── SingleKeyedBox.swift │ │ │ │ ├── StringBox.swift │ │ │ │ ├── UIntBox.swift │ │ │ │ ├── URLBox.swift │ │ │ │ ├── UnkeyedBox.swift │ │ │ │ └── ValueBox.swift │ │ │ ├── Element.swift │ │ │ ├── ElementAndAttribute.swift │ │ │ ├── ISO8601DateFormatter.swift │ │ │ ├── KeyedStorage.swift │ │ │ ├── Metatypes.swift │ │ │ ├── String+Extensions.swift │ │ │ ├── Utils.swift │ │ │ ├── XMLChoiceCodingKey.swift │ │ │ ├── XMLCoderElement.swift │ │ │ ├── XMLDocumentType.swift │ │ │ ├── XMLHeader.swift │ │ │ ├── XMLKey.swift │ │ │ └── XMLStackParser.swift │ │ │ ├── Decoder │ │ │ ├── DecodingErrorExtension.swift │ │ │ ├── DynamicNodeDecoding.swift │ │ │ ├── SingleValueDecodingContainer.swift │ │ │ ├── XMLChoiceDecodingContainer.swift │ │ │ ├── XMLDecoder.swift │ │ │ ├── XMLDecoderImplementation.swift │ │ │ ├── XMLDecodingStorage.swift │ │ │ ├── XMLKeyedDecodingContainer.swift │ │ │ └── XMLUnkeyedDecodingContainer.swift │ │ │ └── Encoder │ │ │ ├── DynamicNodeEncoding.swift │ │ │ ├── EncodingErrorExtension.swift │ │ │ ├── SingleValueEncodingContainer.swift │ │ │ ├── XMLChoiceEncodingContainer.swift │ │ │ ├── XMLEncoder.swift │ │ │ ├── XMLEncoderImplementation.swift │ │ │ ├── XMLEncodingStorage.swift │ │ │ ├── XMLKeyedEncodingContainer.swift │ │ │ ├── XMLReferencingEncoder.swift │ │ │ └── XMLUnkeyedEncodingContainer.swift │ ├── Helpers │ │ ├── PopUpHelper.swift │ │ ├── URLSessionHelper.swift │ │ └── WindowHelper.swift │ ├── HookedInstances.x.swift │ ├── Lyrics │ │ ├── CustomLyrics+AllTracksLyrics.x.swift │ │ ├── CustomLyrics.x.swift │ │ ├── Models │ │ │ ├── Genius │ │ │ │ ├── GeniusDataResponse.swift │ │ │ │ ├── GeniusHit.swift │ │ │ │ ├── GeniusHitResult.swift │ │ │ │ ├── GeniusLyrics.swift │ │ │ │ ├── GeniusRootResponse.swift │ │ │ │ ├── GeniusSection.swift │ │ │ │ ├── GeniusSectionsResponse.swift │ │ │ │ ├── GeniusSong.swift │ │ │ │ └── GeniusSongResponse.swift │ │ │ ├── Lrclib │ │ │ │ └── LrclibSong.swift │ │ │ ├── Lyrics.pb.swift │ │ │ ├── LyricsDto.swift │ │ │ ├── LyricsError.swift │ │ │ ├── LyricsLineDto.swift │ │ │ ├── LyricsLoadingState.swift │ │ │ ├── LyricsRomanizationStatus.swift │ │ │ ├── LyricsSearchQuery.swift │ │ │ ├── LyricsTranslationDto.swift │ │ │ ├── Musixmatch │ │ │ │ ├── MusixmatchSubtitle.swift │ │ │ │ └── MusixmatchTime.swift │ │ │ ├── Petit │ │ │ │ ├── PetitLyricsData.swift │ │ │ │ ├── PetitLyricsLine.swift │ │ │ │ ├── PetitLyricsType.swift │ │ │ │ ├── PetitLyricsWord.swift │ │ │ │ ├── PetitResponse.swift │ │ │ │ └── PetitSong.swift │ │ │ └── Settings │ │ │ │ ├── LyricsColorsSettings.swift │ │ │ │ ├── LyricsOptions.swift │ │ │ │ └── LyricsSource.swift │ │ ├── Protocols │ │ │ └── LyricsRepository.swift │ │ └── Repositories │ │ │ ├── GeniusLyricsRepository.swift │ │ │ ├── LrclibLyricsRepository.swift │ │ │ ├── MusixmatchLyricsRepository.swift │ │ │ └── PetitLyricsRepository.swift │ ├── Models │ │ ├── Extensions │ │ │ ├── Collection+Extension.swift │ │ │ ├── Color+Extension.swift │ │ │ ├── Data+Extension.swift │ │ │ ├── Dictionary+Extension.swift │ │ │ ├── Locale+Extension.swift │ │ │ ├── StirngArray+Extension.swift │ │ │ ├── String+Extension.swift │ │ │ ├── UIColor+Extension.swift │ │ │ ├── UIDevice+Extension.swift │ │ │ ├── UIView+Extension.swift │ │ │ ├── URL+Extension.swift │ │ │ └── UserDefaults+Extension.swift │ │ └── Headers │ │ │ ├── SPTCoreProductState.swift │ │ │ ├── SPTDisclosureAccessoryView.swift │ │ │ ├── SPTEncoreAttributedString.swift │ │ │ ├── SPTEncoreAttributes.swift │ │ │ ├── SPTEncoreLabel.swift │ │ │ ├── SPTEncorePopUpDialog.swift │ │ │ ├── SPTEncorePopUpDialogModel.swift │ │ │ ├── SPTEncorePopUpPresenter.swift │ │ │ ├── SPTEncoreTypeStyle.swift │ │ │ ├── SPTNowPlayingMetadataBackgroundViewModel.swift │ │ │ ├── SPTPlayerTrack.swift │ │ │ ├── SPTSettingsTableViewCell.swift │ │ │ └── SPTURL.swift │ ├── OpenSpotify.x.swift │ ├── Premium │ │ ├── DynamicPremium+ModifyBootstrap.x.swift │ │ ├── DynamicPremium+ModifyingFunctions.swift │ │ ├── Helpers │ │ │ ├── BundleHelper.swift │ │ │ └── OfflineHelper.swift │ │ ├── LikedSongsEnabler.x.swift │ │ ├── Models │ │ │ ├── Account.pb.swift │ │ │ ├── Extensions │ │ │ │ ├── BootstrapMessage+Extension.swift │ │ │ │ └── UcsResponse+Extension.swift │ │ │ ├── PatchType.swift │ │ │ └── SpotifySessionDelegate.swift │ │ ├── ServerSidedReminder.x.swift │ │ └── TrackRowsEnabler.x.swift │ ├── Settings │ │ ├── EeveeSettings.x.swift │ │ ├── Helpers │ │ │ ├── AnonymousTokenHelper.swift │ │ │ └── GitHubHelper.swift │ │ ├── Models │ │ │ ├── AnonymousTokenError.swift │ │ │ ├── EeveeContributor.swift │ │ │ ├── EeveeContributorSection.swift │ │ │ ├── GitHubRelease.swift │ │ │ └── GitHubUser.swift │ │ ├── ViewControllers │ │ │ ├── EeveeSettingsViewController.swift │ │ │ └── SPTPageViewController.swift │ │ ├── ViewModels │ │ │ └── ImageViewModel.swift │ │ ├── ViewModifiers │ │ │ └── ListRowSeparatorHidden.swift │ │ └── Views │ │ │ ├── Contributors │ │ │ ├── EeveeContributorView.swift │ │ │ └── EeveeContributorsSheetView.swift │ │ │ ├── EeveeSettingsVersionView.swift │ │ │ ├── EeveeSettingsView.swift │ │ │ ├── Sections │ │ │ ├── EeveeLyricsSettingsView+Extension.swift │ │ │ ├── EeveeLyricsSettingsView+LyricsSourceSection.swift │ │ │ ├── EeveeLyricsSettingsView.swift │ │ │ ├── EeveePatchingSettingsView.swift │ │ │ └── EeveeUISettingsView.swift │ │ │ └── Shared │ │ │ ├── ChevronRightView.swift │ │ │ ├── CommonIssuesTipView.swift │ │ │ ├── ImageView.swift │ │ │ └── NavigationSectionView.swift │ └── Tweak.x.swift └── EeveeSpotifyC │ ├── Tweak.m │ └── include │ ├── Tweak.h │ └── module.modulemap ├── Tools ├── altSourceConverter └── updateRepo ├── common_issues.md ├── contributors.json ├── control ├── layout └── Library │ └── Application Support │ └── EeveeSpotify.bundle │ ├── Info.plist │ ├── ar-EG.lproj │ └── Localizable.strings │ ├── az.lproj │ └── Localizable.strings │ ├── bg.lproj │ └── Localizable.strings │ ├── ca.lproj │ └── Localizable.strings │ ├── da.lproj │ └── Localizable.strings │ ├── de-CH.lproj │ └── Localizable.strings │ ├── de.lproj │ └── Localizable.strings │ ├── en.lproj │ └── Localizable.strings │ ├── es.lproj │ └── Localizable.strings │ ├── fa.lproj │ └── Localizable.strings │ ├── fr.lproj │ └── Localizable.strings │ ├── github.png │ ├── hu.lproj │ └── Localizable.strings │ ├── it.lproj │ └── Localizable.strings │ ├── ja.lproj │ └── Localizable.strings │ ├── ko.lproj │ └── Localizable.strings │ ├── np.lproj │ └── Localizable.strings │ ├── pl.lproj │ └── Localizable.strings │ ├── pt-BR.lproj │ └── Localizable.strings │ ├── pt.lproj │ └── Localizable.strings │ ├── resolveconfiguration.bnk │ ├── ro.lproj │ └── Localizable.strings │ ├── ru.lproj │ └── Localizable.strings │ ├── tr.lproj │ └── Localizable.strings │ ├── uk.lproj │ └── Localizable.strings │ ├── vi.lproj │ └── Localizable.strings │ ├── zh-CN.lproj │ └── Localizable.strings │ └── zh-TW.lproj │ └── Localizable.strings ├── repo.altsource.json └── repo.json /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | # thanks to uYouEnhanced for the issue template: 2 | # https://github.com/arichornlover/uYouEnhanced/blob/main/.github/ISSUE_TEMPLATE/bug.yaml?plain=1 3 | 4 | name: Bug 5 | description: Report a bug in EeveeSpotify 6 | title: "[Bug] " 7 | labels: bug 8 | body: 9 | - type: checkboxes 10 | attributes: 11 | label: Is this issue appropriate? 12 | description: | 13 | Please read the list of [common issues](https://github.com/whoeevee/EeveeSpotify/blob/swift/common_issues.md). 14 | options: 15 | - label: I have read the document in its entirety 16 | required: true 17 | 18 | - type: checkboxes 19 | attributes: 20 | label: Is this issue unique to EeveeSpotify? 21 | options: 22 | - label: I was unable to reproduce my issue using the stock Spotify app 23 | required: true 24 | 25 | - type: checkboxes 26 | attributes: 27 | label: Have you searched the existing issues? 28 | options: 29 | - label: This is a unique issue 30 | required: true 31 | 32 | - type: textarea 33 | attributes: 34 | label: Describe the bug. 35 | description: Attach videos or screenshots if possible 36 | validations: 37 | required: true 38 | -------------------------------------------------------------------------------- /.github/workflows/build-swift.yml: -------------------------------------------------------------------------------- 1 | name: Build Swift 2 | 3 | on: 4 | push: 5 | branches: [ "swift" ] 6 | pull_request: 7 | branches: [ "swift" ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: macos-14 13 | env: 14 | THEOS: ${{ github.workspace }}/theos 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: maxim-lobanov/setup-xcode@v1 18 | with: 19 | xcode-version: '16.0' 20 | 21 | - name: Setup Theos 22 | uses: actions/checkout@v4 23 | with: 24 | repository: theos/theos 25 | path: ${{ github.workspace }}/theos 26 | submodules: recursive 27 | 28 | - name: Install Dependencies 29 | run: | 30 | brew install make dpkg ldid 31 | echo "$(brew --prefix make)/libexec/gnubin" >> $GITHUB_PATH 32 | 33 | - name: Copy SwiftProtobuf (rootful) 34 | run: | 35 | mkdir swiftprotobuf && cd "$_" 36 | wget https://github.com/whoeevee/swift-protobuf/releases/download/1.28.1/org.swift.protobuf.swiftprotobuf_1.28.1_iphoneos-arm.deb 37 | ar -x org.swift.protobuf.swiftprotobuf_1.28.1_iphoneos-arm.deb 38 | tar -xvf data.tar.lzma 39 | cp -r Library/Frameworks/SwiftProtobuf.framework $THEOS/lib 40 | 41 | - name: Copy SwiftProtobuf (rootless) 42 | run: | 43 | mkdir swiftprotobuf-rootless && cd "$_" 44 | wget https://github.com/whoeevee/swift-protobuf/releases/download/1.28.1/org.swift.protobuf.swiftprotobuf_1.28.1_iphoneos-arm64.deb 45 | ar -x org.swift.protobuf.swiftprotobuf_1.28.1_iphoneos-arm64.deb 46 | tar -xvf data.tar.lzma 47 | cp -r var/jb/Library/Frameworks/SwiftProtobuf.framework $THEOS/lib/iphone/rootless 48 | 49 | - name: Build EeveeSpotify (debug) 50 | run: | 51 | make package 52 | echo "filename=$(find . -name '*debug*' -path './packages/*' | cut -d/ -f3)" >> $GITHUB_ENV 53 | - uses: actions/upload-artifact@v4 54 | with: 55 | name: ${{env.filename}} 56 | path: packages/${{env.filename}} 57 | if-no-files-found: error 58 | 59 | - name: Build EeveeSpotify (rootful) 60 | run: | 61 | make package FINALPACKAGE=1 62 | echo "filename=$(find . -not -name '*debug*' -path './packages/*' | cut -d/ -f3)" >> $GITHUB_ENV 63 | - uses: actions/upload-artifact@v4 64 | with: 65 | name: ${{env.filename}} 66 | path: packages/${{env.filename}} 67 | if-no-files-found: error 68 | 69 | - name: Build EeveeSpotify (rootless) 70 | run: | 71 | make package FINALPACKAGE=1 THEOS_PACKAGE_SCHEME=rootless 72 | echo "filename=$(find . -name '*arm64*' -path './packages/*' | cut -d/ -f3)" >> $GITHUB_ENV 73 | - uses: actions/upload-artifact@v4 74 | with: 75 | name: ${{env.filename}} 76 | path: packages/${{env.filename}} 77 | if-no-files-found: error 78 | -------------------------------------------------------------------------------- /.github/workflows/update-repo.yml: -------------------------------------------------------------------------------- 1 | name: Update Repo 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | 10 | permissions: 11 | contents: write 12 | 13 | steps: 14 | - name: Checkout Repo 15 | uses: actions/checkout@v4.1.6 16 | 17 | - name: Run Update Script 18 | run: ./Tools/updateRepo 19 | 20 | - name: Make AltStore Source 21 | run: ./Tools/altSourceConverter repo.json 22 | 23 | - name: Push Changes 24 | uses: stefanzweifel/git-auto-commit-action@v5.0.1 25 | with: 26 | commit_message: "update: repo" 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | 7 | ._* 8 | .theos 9 | .swiftpm 10 | /packages 11 | .theos/ 12 | packages/ 13 | .DS_Store 14 | -------------------------------------------------------------------------------- /EeveeSpotify.plist: -------------------------------------------------------------------------------- 1 | { 2 | Filter = { 3 | Bundles = ( 4 | "com.spotify.client", 5 | ); 6 | }; 7 | } -------------------------------------------------------------------------------- /Images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asdfzxcvbn/EeveeSpotify/1db9dbbfb63748fee4325667dd5ea60934e93881/Images/banner.png -------------------------------------------------------------------------------- /Images/hex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asdfzxcvbn/EeveeSpotify/1db9dbbfb63748fee4325667dd5ea60934e93881/Images/hex.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TARGET := iphone:clang:latest:14.0 2 | INSTALL_TARGET_PROCESSES = Spotify 3 | ARCHS = arm64 4 | 5 | include $(THEOS)/makefiles/common.mk 6 | 7 | TWEAK_NAME = EeveeSpotify 8 | 9 | EeveeSpotify_FILES = $(shell find Sources/EeveeSpotify -name '*.swift') $(shell find Sources/EeveeSpotifyC -name '*.m' -o -name '*.c' -o -name '*.mm' -o -name '*.cpp') 10 | EeveeSpotify_SWIFTFLAGS = -ISources/EeveeSpotifyC/include 11 | EeveeSpotify_EXTRA_FRAMEWORKS = SwiftProtobuf 12 | EeveeSpotify_CFLAGS = -fobjc-arc -ISources/EeveeSpotifyC/include 13 | 14 | include $(THEOS_MAKE_PATH)/tweak.mk 15 | 16 | copy-swiftprotobuf: 17 | mkdir -p swiftprotobuf && cd swiftprotobuf ;\ 18 | curl -OL https://github.com/whoeevee/EeveeSpotify/releases/download/swift2.0/org.swift.protobuf.swiftprotobuf_1.26.0_iphoneos-arm.deb ;\ 19 | ar -x org.swift.protobuf.swiftprotobuf_1.26.0_iphoneos-arm.deb ;\ 20 | tar -xvf data.tar.lzma ;\ 21 | cp -r Library/Frameworks/SwiftProtobuf.framework "${THEOS}/lib" ;\ 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | 3 | import PackageDescription 4 | import Foundation 5 | 6 | let projectDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() 7 | 8 | @dynamicMemberLookup struct TheosConfiguration { 9 | private let dict: [String: String] 10 | init(at path: String) { 11 | let configURL = URL(fileURLWithPath: path, relativeTo: projectDir) 12 | guard let infoString = try? String(contentsOf: configURL) else { 13 | fatalError(""" 14 | Could not find Theos SPM config. Have you run `make spm` yet? 15 | """) 16 | } 17 | let pairs = infoString.split(separator: "\n").map { 18 | $0.split( 19 | separator: "=", maxSplits: 1, 20 | omittingEmptySubsequences: false 21 | ).map(String.init) 22 | }.map { ($0[0], $0[1]) } 23 | dict = Dictionary(uniqueKeysWithValues: pairs) 24 | } 25 | subscript( 26 | key: String, 27 | or defaultValue: @autoclosure () -> String? = nil 28 | ) -> String { 29 | if let value = dict[key] { 30 | return value 31 | } else if let def = defaultValue() { 32 | return def 33 | } else { 34 | fatalError(""" 35 | Could not get value of key '\(key)' from Theos SPM config. \ 36 | Try running `make spm` again. 37 | """) 38 | } 39 | } 40 | subscript(dynamicMember key: String) -> String { self[key] } 41 | } 42 | let conf = TheosConfiguration(at: ".theos/spm_config") 43 | 44 | let theosPath = conf.theos 45 | let sdk = conf.sdk 46 | let resourceDir = conf.swiftResourceDir 47 | let deploymentTarget = conf.deploymentTarget 48 | let triple = "arm64-apple-ios\(deploymentTarget)" 49 | 50 | let libFlags: [String] = [ 51 | "-F\(theosPath)/vendor/lib", "-F\(theosPath)/lib", 52 | "-I\(theosPath)/vendor/include", "-I\(theosPath)/include" 53 | ] 54 | 55 | let cFlags: [String] = libFlags + [ 56 | "-target", triple, "-isysroot", sdk, 57 | "-Wno-unused-command-line-argument", "-Qunused-arguments", 58 | ] 59 | 60 | let cxxFlags: [String] = [ 61 | ] 62 | 63 | let swiftFlags: [String] = libFlags + [ 64 | "-target", triple, "-sdk", sdk, "-resource-dir", resourceDir, 65 | ] 66 | 67 | let package = Package( 68 | name: "EeveeSpotify", 69 | platforms: [.iOS(deploymentTarget)], 70 | products: [ 71 | .library( 72 | name: "EeveeSpotify", 73 | targets: ["EeveeSpotify"] 74 | ), 75 | ], 76 | targets: [ 77 | .target( 78 | name: "EeveeSpotifyC", 79 | cSettings: [.unsafeFlags(cFlags)], 80 | cxxSettings: [.unsafeFlags(cxxFlags)] 81 | ), 82 | .target( 83 | name: "EeveeSpotify", 84 | dependencies: ["EeveeSpotifyC"], 85 | swiftSettings: [.unsafeFlags(swiftFlags)] 86 | ), 87 | ] 88 | ) 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Banner](Images/banner.png) 2 | 3 | # EeveeSpotify 4 | 5 | This tweak makes Spotify think you have a Premium subscription, granting free listening, just like Spotilife, and provides some additional features like custom lyrics. 6 | 7 | ## The History 8 | 9 | Several months ago, Spotilife, the only tweak to get Spotify Premium, stopped working on new Spotify versions. I decompiled Spotilife, reverse-engineered Spotify, intercepted requests, etc., and created this tweak. 10 | 11 | ## Restrictions 12 | 13 | Please refrain from opening issues about the following features, as they are server-sided and will **NEVER** work: 14 | 15 | - Very High audio quality 16 | - Native playlist downloading (you can download podcast episodes though) 17 | - Jam (hosting a Spotify Jam and joining it remotely requires Premium; only joining in-person works) 18 | - AI DJ/Playlist 19 | 20 | It's possible to implement downloading locally, but it will never be included in EeveeSpotify (unless someone opens a pull request). 21 | 22 | ## Lyrics Support 23 | 24 | EeveeSpotify replaces Spotify monthly limited lyrics with one of the following three lyrics providers: 25 | 26 | - Genius: Offers the best quality lyrics, provides the most songs, and updates lyrics the fastest. Does not and will never be time-synced. 27 | 28 | - LRCLIB: The most open service, offering time-synced lyrics. However, it lacks lyrics for many songs. 29 | 30 | - Musixmatch: The service Spotify uses. Provides time-synced lyrics for many songs, but you'll need a user token to use this source. To obtain the token, download Musixmatch from the App Store, sign up, then go to Settings > Get help > Copy debug info, and paste it into EeveeSpotify alert. You can also extract the token using MITM. 31 | 32 | - PetitLyrics: Offers plenty of time-synced Japanese and some international lyrics. 33 | 34 | If the tweak is unable to find a song or process the lyrics, you'll see a "Couldn't load the lyrics for this song" message. The lyrics might be wrong for some songs when using Genius due to how the tweak searches songs. While I've made it work in most cases, kindly refrain from opening issues about it. 35 | 36 | ## How It Works 37 | 38 | **Starting with version 4.0, EeveeSpotify intercepts Spotify requests to load user data, deserializes it, and modifies the parameters in real-time. This method is the best so far and works incredibly stable.** 39 | 40 | Upon login, Spotify fetches user data and caches it in the `offline.bnk` file in the `/Library/Application Support/PersistentCache` directory. It uses its proprietary binary format to store data, incorporating a length byte before each value, among other conventions. Certain keys, such as `player-license`, `financial-product`, `streaming-rules`, and others, determine the user abilities. 41 | 42 | The tweak patches this file while initializing; Spotify loads it and assumes you have Premium. To be honest, it doesn't really patch due to challenges with dynamic length and varied bytes. The tweak extracts the username from the current `offline.bnk` file and inserts it into `premiumblank.bnk` (a file containing all premium values preset), replacing `offline.bnk`. Spotify may reload user data, and you'll be switched to the Free plan. When this happens, you'll see a popup with quick restart app and reset data actions. 43 | 44 | ![Hex](Images/hex.png) 45 | 46 | Tweak also sets `trackRowsEnabled` in `SPTFreeTierArtistHubRemoteURLResolver` to `true`, so Spotify loads not just track names on the artist page. It can stop working just like Spotilife, but so far, it works on the latest Spotify 8.9.## (Spotilife also patches `offline.bnk`, however, it changes obscure bytes that do nothing on new versions). 47 | 48 | ## Support 49 | 50 | EeveeSpotify has always been free and open-source project. However, I started accepting crypto donations if you'd like to support me. This will help me pay for a good monthly VPS and continue creating cool things. I really appreciate it: 51 | 52 | USDT (TRC-20): `TJppx7dvTa2ndoVcQ1jxWkvGN1vEuFHssJ` 53 | 54 | USDC/ETH/USDT: `0x98bbd1541cb9a8ebb1229741218886efba963677` 55 | 56 | BTC: `bc1q230f0jaryxhrr03v8knxew30p7l4kwefd6d4nl` 57 | 58 | LTC: `ltc1qhj3ts8ek0lklqfydxu90ku5d5efq5cw5ww7u9g` 59 | 60 | XMR: `86QVbA9XLJ9WznTDRgA7dbf8UV9rsR5KB1UxJCPtdwQqd9rv9YZNRkTJvesGzM13khL9Do1BRb5biUTuDZ5YqnuQF8JrJYk` 61 | 62 | *** 63 | 64 | To open Spotify links in sideloaded app, use [OpenSpotifySafariExtension](https://github.com/BillyCurtis/OpenSpotifySafariExtension). Remember to activate it and allow access in Settings > Safari > Extensions. 65 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/DarkPopUps.x.swift: -------------------------------------------------------------------------------- 1 | import Orion 2 | import UIKit 3 | import SwiftUI 4 | 5 | struct DarkPopUps: HookGroup { } 6 | 7 | class EncoreLabelHook: ClassHook { 8 | typealias Group = DarkPopUps 9 | static let targetName = "SPTEncoreLabel" 10 | 11 | func intrinsicContentSize() -> CGSize { 12 | if let viewController = WindowHelper.shared.viewController(for: target), 13 | NSStringFromClass(type(of: viewController)) == "SPTEncorePopUpContainer" 14 | { 15 | let label = Dynamic.convert(target.subviews.first!, to: UILabel.self) 16 | 17 | if !label.hasParent(matching: "Primary") { 18 | label.textColor = .white 19 | } 20 | } 21 | 22 | return orig.intrinsicContentSize() 23 | } 24 | } 25 | 26 | class SPTEncorePopUpContainerHook: ClassHook { 27 | typealias Group = DarkPopUps 28 | static let targetName = "SPTEncorePopUpContainer" 29 | 30 | func containedView() -> SPTEncorePopUpDialog { 31 | return orig.containedView() 32 | } 33 | 34 | func viewDidAppear(_ animated: Bool) { 35 | orig.viewDidAppear(animated) 36 | containedView().uiView().backgroundColor = UIColor(Color(hex: "#242424")) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Attribute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XMLAttribute.swift 3 | // XMLCoder 4 | // 5 | // Created by Benjamin Wetherfield on 6/3/20. 6 | // 7 | 8 | protocol XMLAttributeProtocol {} 9 | 10 | /** Property wrapper specifying that a given property should be encoded and decoded as an XML attribute. 11 | 12 | For example, this type 13 | ```swift 14 | struct Book: Codable { 15 | @Attribute var id: Int 16 | } 17 | ``` 18 | 19 | will encode value `Book(id: 42)` as ``. And vice versa, 20 | it will decode the former into the latter. 21 | */ 22 | @propertyWrapper 23 | public struct Attribute: XMLAttributeProtocol { 24 | public var wrappedValue: Value 25 | 26 | public init(_ wrappedValue: Value) { 27 | self.wrappedValue = wrappedValue 28 | } 29 | } 30 | 31 | extension Attribute: Codable where Value: Codable { 32 | public func encode(to encoder: Encoder) throws { 33 | try wrappedValue.encode(to: encoder) 34 | } 35 | 36 | public init(from decoder: Decoder) throws { 37 | try wrappedValue = .init(from: decoder) 38 | } 39 | } 40 | 41 | extension Attribute: Equatable where Value: Equatable {} 42 | extension Attribute: Hashable where Value: Hashable {} 43 | 44 | extension Attribute: ExpressibleByIntegerLiteral where Value: ExpressibleByIntegerLiteral { 45 | public typealias IntegerLiteralType = Value.IntegerLiteralType 46 | 47 | public init(integerLiteral value: Value.IntegerLiteralType) { 48 | wrappedValue = Value(integerLiteral: value) 49 | } 50 | } 51 | 52 | extension Attribute: ExpressibleByUnicodeScalarLiteral where Value: ExpressibleByUnicodeScalarLiteral { 53 | public init(unicodeScalarLiteral value: Value.UnicodeScalarLiteralType) { 54 | wrappedValue = Value(unicodeScalarLiteral: value) 55 | } 56 | 57 | public typealias UnicodeScalarLiteralType = Value.UnicodeScalarLiteralType 58 | } 59 | 60 | extension Attribute: ExpressibleByExtendedGraphemeClusterLiteral where Value: ExpressibleByExtendedGraphemeClusterLiteral { 61 | public typealias ExtendedGraphemeClusterLiteralType = Value.ExtendedGraphemeClusterLiteralType 62 | 63 | public init(extendedGraphemeClusterLiteral value: Value.ExtendedGraphemeClusterLiteralType) { 64 | wrappedValue = Value(extendedGraphemeClusterLiteral: value) 65 | } 66 | } 67 | 68 | extension Attribute: ExpressibleByStringLiteral where Value: ExpressibleByStringLiteral { 69 | public typealias StringLiteralType = Value.StringLiteralType 70 | 71 | public init(stringLiteral value: Value.StringLiteralType) { 72 | wrappedValue = Value(stringLiteral: value) 73 | } 74 | } 75 | 76 | extension Attribute: ExpressibleByBooleanLiteral where Value: ExpressibleByBooleanLiteral { 77 | public typealias BooleanLiteralType = Value.BooleanLiteralType 78 | 79 | public init(booleanLiteral value: Value.BooleanLiteralType) { 80 | wrappedValue = Value(booleanLiteral: value) 81 | } 82 | } 83 | 84 | extension Attribute: ExpressibleByNilLiteral where Value: ExpressibleByNilLiteral { 85 | public init(nilLiteral: ()) { 86 | wrappedValue = Value(nilLiteral: ()) 87 | } 88 | } 89 | 90 | protocol XMLOptionalAttributeProtocol: XMLAttributeProtocol { 91 | init() 92 | } 93 | 94 | extension Attribute: XMLOptionalAttributeProtocol where Value: AnyOptional { 95 | init() { 96 | wrappedValue = Value() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/BoolBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/17/18. 7 | // 8 | 9 | struct BoolBox: Equatable { 10 | typealias Unboxed = Bool 11 | 12 | let unboxed: Unboxed 13 | 14 | init(_ unboxed: Unboxed) { 15 | self.unboxed = unboxed 16 | } 17 | 18 | init?(xmlString: String) { 19 | switch xmlString.lowercased() { 20 | case "false", "0", "n", "no": self.init(false) 21 | case "true", "1", "y", "yes": self.init(true) 22 | case _: return nil 23 | } 24 | } 25 | } 26 | 27 | extension BoolBox: Box { 28 | var isNull: Bool { 29 | return false 30 | } 31 | 32 | /// # Lexical representation 33 | /// Boolean has a lexical representation consisting of the following 34 | /// legal literals {`true`, `false`, `1`, `0`}. 35 | /// 36 | /// # Canonical representation 37 | /// The canonical representation for boolean is the set of literals {`true`, `false`}. 38 | /// 39 | /// --- 40 | /// 41 | /// [Schema definition](https://www.w3.org/TR/xmlschema-2/#boolean) 42 | var xmlString: String? { 43 | return (unboxed) ? "true" : "false" 44 | } 45 | } 46 | 47 | extension BoolBox: SimpleBox {} 48 | 49 | extension BoolBox: CustomStringConvertible { 50 | var description: String { 51 | return unboxed.description 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/Box.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/17/18. 7 | // 8 | 9 | protocol Box { 10 | var isNull: Bool { get } 11 | var xmlString: String? { get } 12 | } 13 | 14 | /// A box that only describes a single atomic value. 15 | protocol SimpleBox: Box { 16 | // A simple tagging protocol, for now. 17 | } 18 | 19 | protocol TypeErasedSharedBoxProtocol { 20 | func typeErasedUnbox() -> Box 21 | } 22 | 23 | protocol SharedBoxProtocol: TypeErasedSharedBoxProtocol { 24 | associatedtype B: Box 25 | func unbox() -> B 26 | } 27 | 28 | extension SharedBoxProtocol { 29 | func typeErasedUnbox() -> Box { 30 | return unbox() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/ChoiceBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by James Bean on 7/18/19. 7 | // 8 | 9 | /// A `Box` which represents an element which is known to contain an XML choice element. 10 | struct ChoiceBox { 11 | var key: String = "" 12 | var element: Box = NullBox() 13 | } 14 | 15 | extension ChoiceBox: Box { 16 | var isNull: Bool { 17 | return false 18 | } 19 | 20 | var xmlString: String? { 21 | return nil 22 | } 23 | } 24 | 25 | extension ChoiceBox: SimpleBox {} 26 | 27 | extension ChoiceBox { 28 | init?(_ keyedBox: KeyedBox) { 29 | guard 30 | let firstKey = keyedBox.elements.keys.first, 31 | let firstElement = keyedBox.elements[firstKey].first 32 | else { 33 | return nil 34 | } 35 | self.init(key: firstKey, element: firstElement) 36 | } 37 | 38 | init(_ singleKeyedBox: SingleKeyedBox) { 39 | self.init(key: singleKeyedBox.key, element: singleKeyedBox.element) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/DataBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/19/18. 7 | // 8 | 9 | import Foundation 10 | 11 | struct DataBox: Equatable { 12 | enum Format: Equatable { 13 | case base64 14 | } 15 | 16 | typealias Unboxed = Data 17 | 18 | let unboxed: Unboxed 19 | let format: Format 20 | 21 | init(_ unboxed: Unboxed, format: Format) { 22 | self.unboxed = unboxed 23 | self.format = format 24 | } 25 | 26 | init?(base64 string: String) { 27 | guard let data = Data(base64Encoded: string) else { 28 | return nil 29 | } 30 | self.init(data, format: .base64) 31 | } 32 | 33 | func xmlString(format: Format) -> String { 34 | switch format { 35 | case .base64: 36 | return unboxed.base64EncodedString() 37 | } 38 | } 39 | } 40 | 41 | extension DataBox: Box { 42 | var isNull: Bool { 43 | return false 44 | } 45 | 46 | var xmlString: String? { 47 | return xmlString(format: format) 48 | } 49 | } 50 | 51 | extension DataBox: SimpleBox {} 52 | 53 | extension DataBox: CustomStringConvertible { 54 | var description: String { 55 | return unboxed.description 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/DateBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/18/18. 7 | // 8 | 9 | import Foundation 10 | 11 | struct DateBox: Equatable { 12 | enum Format: Equatable { 13 | case secondsSince1970 14 | case millisecondsSince1970 15 | case iso8601 16 | case formatter(DateFormatter) 17 | } 18 | 19 | typealias Unboxed = Date 20 | 21 | let unboxed: Unboxed 22 | let format: Format 23 | 24 | init(_ unboxed: Unboxed, format: Format) { 25 | self.unboxed = unboxed 26 | self.format = format 27 | } 28 | 29 | init?(secondsSince1970 string: String) { 30 | guard let seconds = TimeInterval(string) else { 31 | return nil 32 | } 33 | let unboxed = Date(timeIntervalSince1970: seconds) 34 | self.init(unboxed, format: .secondsSince1970) 35 | } 36 | 37 | init?(millisecondsSince1970 string: String) { 38 | guard let milliseconds = TimeInterval(string) else { 39 | return nil 40 | } 41 | let unboxed = Date(timeIntervalSince1970: milliseconds / 1000.0) 42 | self.init(unboxed, format: .millisecondsSince1970) 43 | } 44 | 45 | init?(iso8601 string: String) { 46 | if #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) { 47 | guard let unboxed = _iso8601Formatter.date(from: string) else { 48 | return nil 49 | } 50 | self.init(unboxed, format: .iso8601) 51 | } else { 52 | fatalError("ISO8601DateFormatter is unavailable on this platform.") 53 | } 54 | } 55 | 56 | init?(xmlString: String, formatter: DateFormatter) { 57 | guard let date = formatter.date(from: xmlString) else { 58 | return nil 59 | } 60 | self.init(date, format: .formatter(formatter)) 61 | } 62 | 63 | func xmlString(format: Format) -> String { 64 | switch format { 65 | case .secondsSince1970: 66 | let seconds = unboxed.timeIntervalSince1970 67 | return seconds.description 68 | case .millisecondsSince1970: 69 | let milliseconds = unboxed.timeIntervalSince1970 * 1000.0 70 | return milliseconds.description 71 | case .iso8601: 72 | if #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) { 73 | return _iso8601Formatter.string(from: self.unboxed) 74 | } else { 75 | fatalError("ISO8601DateFormatter is unavailable on this platform.") 76 | } 77 | case let .formatter(formatter): 78 | return formatter.string(from: unboxed) 79 | } 80 | } 81 | } 82 | 83 | extension DateBox: Box { 84 | var isNull: Bool { 85 | return false 86 | } 87 | 88 | var xmlString: String? { 89 | return xmlString(format: format) 90 | } 91 | } 92 | 93 | extension DateBox: SimpleBox {} 94 | 95 | extension DateBox: CustomStringConvertible { 96 | var description: String { 97 | return unboxed.description 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/DecimalBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/17/18. 7 | // 8 | 9 | import Foundation 10 | 11 | struct DecimalBox: Equatable { 12 | typealias Unboxed = Decimal 13 | 14 | let unboxed: Unboxed 15 | 16 | init(_ unboxed: Unboxed) { 17 | self.unboxed = unboxed 18 | } 19 | 20 | init?(xmlString: String) { 21 | guard let unboxed = Unboxed(string: xmlString) else { 22 | return nil 23 | } 24 | self.init(unboxed) 25 | } 26 | } 27 | 28 | extension DecimalBox: Box { 29 | var isNull: Bool { 30 | return false 31 | } 32 | 33 | /// # Lexical representation 34 | /// Decimal has a lexical representation consisting of a finite-length sequence of 35 | /// decimal digits separated by a period as a decimal indicator. 36 | /// An optional leading sign is allowed. If the sign is omitted, `"+"` is assumed. 37 | /// Leading and trailing zeroes are optional. If the fractional part is zero, 38 | /// the period and following zero(es) can be omitted. 39 | /// For example: `-1.23`, `12678967.543233`, `+100000.00`, `210`. 40 | /// 41 | /// # Canonical representation 42 | /// The canonical representation for decimal is defined by prohibiting certain 43 | /// options from the Lexical representation. Specifically, the preceding optional 44 | /// `"+"` sign is prohibited. The decimal point is required. Leading and trailing 45 | /// zeroes are prohibited subject to the following: there must be at least one 46 | /// digit to the right and to the left of the decimal point which may be a zero. 47 | /// 48 | /// --- 49 | /// 50 | /// [Schema definition](https://www.w3.org/TR/xmlschema-2/#decimal) 51 | var xmlString: String? { 52 | return "\(unboxed)" 53 | } 54 | } 55 | 56 | extension DecimalBox: SimpleBox {} 57 | 58 | extension DecimalBox: CustomStringConvertible { 59 | var description: String { 60 | return unboxed.description 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/DoubleBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Max Desiatov on 05/10/2019. 7 | // 8 | 9 | struct DoubleBox: Equatable, ValueBox { 10 | typealias Unboxed = Double 11 | 12 | let unboxed: Unboxed 13 | 14 | init(_ value: Unboxed) { 15 | unboxed = value 16 | } 17 | 18 | init?(xmlString: String) { 19 | guard let unboxed = Double(xmlString) else { return nil } 20 | 21 | self.init(unboxed) 22 | } 23 | } 24 | 25 | extension DoubleBox: Box { 26 | var isNull: Bool { 27 | return false 28 | } 29 | 30 | var xmlString: String? { 31 | guard !unboxed.isNaN else { 32 | return "NaN" 33 | } 34 | 35 | guard !unboxed.isInfinite else { 36 | return (unboxed > 0.0) ? "INF" : "-INF" 37 | } 38 | 39 | return unboxed.description 40 | } 41 | } 42 | 43 | extension DoubleBox: SimpleBox {} 44 | 45 | extension DoubleBox: CustomStringConvertible { 46 | var description: String { 47 | return unboxed.description 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/FloatBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/17/18. 7 | // 8 | 9 | struct FloatBox: Equatable, ValueBox { 10 | typealias Unboxed = Float 11 | 12 | let unboxed: Unboxed 13 | 14 | init(_ unboxed: Float) { 15 | self.unboxed = Unboxed(unboxed) 16 | } 17 | 18 | init?(xmlString: String) { 19 | guard let unboxed = Unboxed(xmlString) else { 20 | return nil 21 | } 22 | self.init(unboxed) 23 | } 24 | } 25 | 26 | extension FloatBox: Box { 27 | var isNull: Bool { 28 | return false 29 | } 30 | 31 | /// # Lexical representation 32 | /// float values have a lexical representation consisting of a mantissa followed, optionally, 33 | /// by the character `"E"` or `"e"`, followed by an exponent. The exponent **must** be an integer. 34 | /// The mantissa **must** be a decimal number. The representations for exponent and mantissa **must** 35 | /// follow the lexical rules for integer and decimal. If the `"E"` or `"e"` and the following 36 | /// exponent are omitted, an exponent value of `0` is assumed. 37 | /// 38 | /// The special values positive and negative infinity and not-a-number have lexical 39 | /// representations `INF`, `-INF` and `NaN`, respectively. Lexical representations for zero 40 | /// may take a positive or negative sign. 41 | /// 42 | /// For example, `-1E4`, `1267.43233E12`, `12.78e-2`, `12` , `-0`, `0` and `INF` are all 43 | /// legal literals for float. 44 | /// 45 | /// # Canonical representation 46 | /// The canonical representation for float is defined by prohibiting certain options from the 47 | /// Lexical representation. Specifically, the exponent must be indicated by `"E"`. 48 | /// Leading zeroes and the preceding optional `"+"` sign are prohibited in the exponent. 49 | /// If the exponent is zero, it must be indicated by `"E0"`. For the mantissa, the preceding 50 | /// optional `"+"` sign is prohibited and the decimal point is required. Leading and trailing 51 | /// zeroes are prohibited subject to the following: number representations must be normalized 52 | /// such that there is a single digit which is non-zero to the left of the decimal point and 53 | /// at least a single digit to the right of the decimal point unless the value being represented 54 | /// is zero. The canonical representation for zero is `0.0E0`. 55 | /// 56 | /// --- 57 | /// 58 | /// [Schema definition](https://www.w3.org/TR/xmlschema-2/#float) 59 | var xmlString: String? { 60 | guard !unboxed.isNaN else { 61 | return "NaN" 62 | } 63 | 64 | guard !unboxed.isInfinite else { 65 | return (unboxed > 0.0) ? "INF" : "-INF" 66 | } 67 | 68 | return unboxed.description 69 | } 70 | } 71 | 72 | extension FloatBox: SimpleBox {} 73 | 74 | extension FloatBox: CustomStringConvertible { 75 | var description: String { 76 | return unboxed.description 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/IntBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/17/18. 7 | // 8 | 9 | struct IntBox: Equatable { 10 | typealias Unboxed = Int64 11 | 12 | let unboxed: Unboxed 13 | 14 | init(_ unboxed: Integer) { 15 | self.unboxed = Unboxed(unboxed) 16 | } 17 | 18 | init?(xmlString: String) { 19 | guard let unboxed = Unboxed(xmlString) else { 20 | return nil 21 | } 22 | self.init(unboxed) 23 | } 24 | 25 | func unbox() -> Integer? { 26 | return Integer(exactly: unboxed) 27 | } 28 | } 29 | 30 | extension IntBox: Box { 31 | var isNull: Bool { 32 | return false 33 | } 34 | 35 | /// # Lexical representation 36 | /// Integer has a lexical representation consisting of a finite-length sequence of 37 | /// decimal digits with an optional leading sign. If the sign is omitted, `"+"` is assumed. 38 | /// For example: `-1`, `0`, `12678967543233`, `+100000`. 39 | /// 40 | /// # Canonical representation 41 | /// The canonical representation for integer is defined by prohibiting certain 42 | /// options from the Lexical representation. Specifically, the preceding optional 43 | /// `"+"` sign is prohibited and leading zeroes are prohibited. 44 | /// 45 | /// --- 46 | /// 47 | /// [Schema definition](https://www.w3.org/TR/xmlschema-2/#integer) 48 | var xmlString: String? { 49 | return unboxed.description 50 | } 51 | } 52 | 53 | extension IntBox: SimpleBox {} 54 | 55 | extension IntBox: CustomStringConvertible { 56 | var description: String { 57 | return unboxed.description 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/KeyedBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 11/19/18. 7 | // 8 | 9 | struct KeyedBox { 10 | typealias Key = String 11 | typealias Attribute = SimpleBox 12 | typealias Element = Box 13 | 14 | typealias Attributes = KeyedStorage 15 | typealias Elements = KeyedStorage 16 | 17 | var elements = Elements() 18 | var attributes = Attributes() 19 | 20 | var unboxed: (elements: Elements, attributes: Attributes) { 21 | return ( 22 | elements: elements, 23 | attributes: attributes 24 | ) 25 | } 26 | 27 | var value: SimpleBox? { 28 | return elements.values.first as? SimpleBox 29 | } 30 | } 31 | 32 | extension KeyedBox { 33 | init(elements: E, attributes: A) 34 | where E: Sequence, E.Element == (Key, Element), 35 | A: Sequence, A.Element == (Key, Attribute) 36 | { 37 | let elements = Elements(elements) 38 | let attributes = Attributes(attributes) 39 | self.init(elements: elements, attributes: attributes) 40 | } 41 | } 42 | 43 | extension KeyedBox: Box { 44 | var isNull: Bool { 45 | return false 46 | } 47 | 48 | var xmlString: String? { 49 | return nil 50 | } 51 | } 52 | 53 | extension KeyedBox: CustomStringConvertible { 54 | var description: String { 55 | return "{attributes: \(attributes), elements: \(elements)}" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/NullBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/17/18. 7 | // 8 | 9 | struct NullBox {} 10 | 11 | extension NullBox: Box { 12 | var isNull: Bool { 13 | return true 14 | } 15 | 16 | var xmlString: String? { 17 | return nil 18 | } 19 | } 20 | 21 | extension NullBox: SimpleBox {} 22 | 23 | extension NullBox: Equatable { 24 | static func ==(_: NullBox, _: NullBox) -> Bool { 25 | return true 26 | } 27 | } 28 | 29 | extension NullBox: CustomStringConvertible { 30 | var description: String { 31 | return "null" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/SharedBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/22/18. 7 | // 8 | 9 | class SharedBox { 10 | private(set) var unboxed: Unboxed 11 | 12 | init(_ wrapped: Unboxed) { 13 | unboxed = wrapped 14 | } 15 | 16 | func withShared(_ body: (inout Unboxed) throws -> T) rethrows -> T { 17 | return try body(&unboxed) 18 | } 19 | } 20 | 21 | extension SharedBox: Box { 22 | var isNull: Bool { 23 | return unboxed.isNull 24 | } 25 | 26 | var xmlString: String? { 27 | return unboxed.xmlString 28 | } 29 | } 30 | 31 | extension SharedBox: SharedBoxProtocol { 32 | func unbox() -> Unboxed { 33 | return unboxed 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/SingleKeyedBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by James Bean on 7/15/19. 7 | // 8 | 9 | /// A `Box` which contains a single `key` and `element` pair. This is useful for disambiguating elements which could either represent 10 | /// an element nested in a keyed or unkeyed container, or an choice between multiple known-typed values (implemented in Swift using 11 | /// enums with associated values). 12 | struct SingleKeyedBox: SimpleBox { 13 | var key: String 14 | var element: Box 15 | } 16 | 17 | extension SingleKeyedBox: Box { 18 | var isNull: Bool { 19 | return false 20 | } 21 | 22 | var xmlString: String? { 23 | return nil 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/StringBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/17/18. 7 | // 8 | 9 | struct StringBox: Equatable { 10 | typealias Unboxed = String 11 | 12 | let unboxed: Unboxed 13 | 14 | init(_ unboxed: Unboxed) { 15 | self.unboxed = unboxed 16 | } 17 | 18 | init(xmlString: Unboxed) { 19 | self.init(xmlString) 20 | } 21 | } 22 | 23 | extension StringBox: Box { 24 | var isNull: Bool { 25 | return false 26 | } 27 | 28 | var xmlString: String? { 29 | return unboxed.description 30 | } 31 | } 32 | 33 | extension StringBox: SimpleBox {} 34 | 35 | extension StringBox: CustomStringConvertible { 36 | var description: String { 37 | return unboxed.description 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/UIntBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/17/18. 7 | // 8 | 9 | struct UIntBox: Equatable { 10 | typealias Unboxed = UInt64 11 | 12 | let unboxed: Unboxed 13 | 14 | init(_ unboxed: Integer) { 15 | self.unboxed = Unboxed(unboxed) 16 | } 17 | 18 | init?(xmlString: String) { 19 | guard let unboxed = Unboxed(xmlString) else { 20 | return nil 21 | } 22 | self.init(unboxed) 23 | } 24 | 25 | func unbox() -> Integer? { 26 | return Integer(exactly: unboxed) 27 | } 28 | } 29 | 30 | extension UIntBox: Box { 31 | var isNull: Bool { 32 | return false 33 | } 34 | 35 | /// # Lexical representation 36 | /// Unsigned integer has a lexical representation consisting of an optional 37 | /// sign followed by a finite-length sequence of decimal digits. 38 | /// If the sign is omitted, the positive sign (`"+"`) is assumed. 39 | /// If the sign is present, it must be `"+"` except for lexical forms denoting zero, 40 | /// which may be preceded by a positive (`"+"`) or a negative (`"-"`) sign. 41 | /// For example: `1`, `0`, `12678967543233`, `+100000`. 42 | /// 43 | /// # Canonical representation 44 | /// The canonical representation for nonNegativeInteger is defined by prohibiting 45 | /// certain options from the Lexical representation. Specifically, 46 | /// the the optional `"+"` sign is prohibited and leading zeroes are prohibited. 47 | /// 48 | /// --- 49 | /// 50 | /// [Schema definition](https://www.w3.org/TR/xmlschema-2/#nonNegativeInteger) 51 | var xmlString: String? { 52 | return unboxed.description 53 | } 54 | } 55 | 56 | extension UIntBox: SimpleBox {} 57 | 58 | extension UIntBox: CustomStringConvertible { 59 | var description: String { 60 | return unboxed.description 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/URLBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/21/18. 7 | // 8 | 9 | import Foundation 10 | 11 | struct URLBox: Equatable { 12 | typealias Unboxed = URL 13 | 14 | let unboxed: Unboxed 15 | 16 | init(_ unboxed: Unboxed) { 17 | self.unboxed = unboxed 18 | } 19 | 20 | init?(xmlString: String) { 21 | guard let unboxed = Unboxed(string: xmlString) else { 22 | return nil 23 | } 24 | self.init(unboxed) 25 | } 26 | } 27 | 28 | extension URLBox: Box { 29 | var isNull: Bool { 30 | return false 31 | } 32 | 33 | var xmlString: String? { 34 | return unboxed.absoluteString 35 | } 36 | } 37 | 38 | extension URLBox: SimpleBox {} 39 | 40 | extension URLBox: CustomStringConvertible { 41 | var description: String { 42 | return unboxed.description 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/UnkeyedBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 11/20/18. 7 | // 8 | 9 | typealias UnkeyedBox = [Box] 10 | 11 | extension Array: Box { 12 | var isNull: Bool { 13 | return false 14 | } 15 | 16 | var xmlString: String? { 17 | return nil 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Box/ValueBox.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Max Desiatov on 05/10/2019. 7 | // 8 | 9 | protocol ValueBox: SimpleBox { 10 | associatedtype Unboxed 11 | 12 | init(_ value: Unboxed) 13 | } 14 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Element.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XMLElementNode.swift 3 | // XMLCoder 4 | // 5 | // Created by Benjamin Wetherfield on 6/4/20. 6 | // 7 | 8 | protocol XMLElementProtocol {} 9 | 10 | /** Property wrapper specifying that a given property should be encoded and decoded as an XML element. 11 | 12 | For example, this type 13 | ```swift 14 | struct Book: Codable { 15 | @Element var id: Int 16 | } 17 | ``` 18 | 19 | will encode value `Book(id: 42)` as `42`. And vice versa, 20 | it will decode the former into the latter. 21 | */ 22 | @propertyWrapper 23 | public struct Element: XMLElementProtocol { 24 | public var wrappedValue: Value 25 | 26 | public init(_ wrappedValue: Value) { 27 | self.wrappedValue = wrappedValue 28 | } 29 | } 30 | 31 | extension Element: Codable where Value: Codable { 32 | public func encode(to encoder: Encoder) throws { 33 | try wrappedValue.encode(to: encoder) 34 | } 35 | 36 | public init(from decoder: Decoder) throws { 37 | try wrappedValue = .init(from: decoder) 38 | } 39 | } 40 | 41 | extension Element: Equatable where Value: Equatable {} 42 | extension Element: Hashable where Value: Hashable {} 43 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/ElementAndAttribute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XMLBothNode.swift 3 | // XMLCoder 4 | // 5 | // Created by Benjamin Wetherfield on 6/7/20. 6 | // 7 | 8 | protocol XMLElementAndAttributeProtocol {} 9 | 10 | /** Property wrapper specifying that a given property should be decoded from either an XML element 11 | or an XML attribute. When encoding, the value will be present as both an attribute, and an element. 12 | 13 | For example, this type 14 | ```swift 15 | struct Book: Codable { 16 | @ElementAndAttribute var id: Int 17 | } 18 | ``` 19 | 20 | will encode value `Book(id: 42)` as `42`. It will decode both 21 | `42` and `` as `Book(id: 42)`. 22 | */ 23 | @propertyWrapper 24 | public struct ElementAndAttribute: XMLElementAndAttributeProtocol { 25 | public var wrappedValue: Value 26 | 27 | public init(_ wrappedValue: Value) { 28 | self.wrappedValue = wrappedValue 29 | } 30 | } 31 | 32 | extension ElementAndAttribute: Codable where Value: Codable { 33 | public func encode(to encoder: Encoder) throws { 34 | try wrappedValue.encode(to: encoder) 35 | } 36 | 37 | public init(from decoder: Decoder) throws { 38 | try wrappedValue = .init(from: decoder) 39 | } 40 | } 41 | 42 | extension ElementAndAttribute: Equatable where Value: Equatable {} 43 | extension ElementAndAttribute: Hashable where Value: Hashable {} 44 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/ISO8601DateFormatter.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017-2020 Shawn Moore and XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Shawn Moore on 11/21/17. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Shared ISO8601 Date Formatter 12 | /// NOTE: This value is implicitly lazy and _must_ be lazy. We're compiled 13 | /// against the latest SDK (w/ ISO8601DateFormatter), but linked against 14 | /// whichever Foundation the user has. ISO8601DateFormatter might not exist, so 15 | /// we better not hit this code path on an older OS. 16 | @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) 17 | var _iso8601Formatter: ISO8601DateFormatter = { 18 | let formatter = ISO8601DateFormatter() 19 | formatter.formatOptions = .withInternetDateTime 20 | return formatter 21 | }() 22 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/KeyedStorage.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Max Desiatov on 07/04/2019. 7 | // 8 | 9 | struct KeyedStorage { 10 | typealias Buffer = [(Key, Value)] 11 | typealias KeyMap = [Key: [Int]] 12 | 13 | fileprivate var keyMap = KeyMap() 14 | fileprivate var buffer = Buffer() 15 | 16 | var isEmpty: Bool { 17 | return buffer.isEmpty 18 | } 19 | 20 | var count: Int { 21 | return buffer.count 22 | } 23 | 24 | var keys: [Key] { 25 | return buffer.map { $0.0 } 26 | } 27 | 28 | var values: [Value] { 29 | return buffer.map { $0.1 } 30 | } 31 | 32 | init(_ sequence: S) where S: Sequence, S.Element == (Key, Value) { 33 | buffer = Buffer() 34 | keyMap = KeyMap() 35 | sequence.forEach { key, value in append(value, at: key) } 36 | } 37 | 38 | subscript(key: Key) -> [Value] { 39 | return keyMap[key]?.map { buffer[$0].1 } ?? [] 40 | } 41 | 42 | mutating func append(_ value: Value, at key: Key) { 43 | let i = buffer.count 44 | buffer.append((key, value)) 45 | if keyMap[key] != nil { 46 | keyMap[key]?.append(i) 47 | } else { 48 | keyMap[key] = [i] 49 | } 50 | } 51 | 52 | func map(_ transform: (Key, Value) throws -> T) rethrows -> [T] { 53 | return try buffer.map(transform) 54 | } 55 | 56 | func compactMap( 57 | _ transform: ((Key, Value)) throws -> T? 58 | ) rethrows -> [T] { 59 | return try buffer.compactMap(transform) 60 | } 61 | 62 | mutating func reserveCapacity(_ capacity: Int) { 63 | buffer.reserveCapacity(capacity) 64 | keyMap.reserveCapacity(capacity) 65 | } 66 | 67 | init() {} 68 | } 69 | 70 | extension KeyedStorage: Sequence { 71 | func makeIterator() -> Buffer.Iterator { 72 | return buffer.makeIterator() 73 | } 74 | } 75 | 76 | extension KeyedStorage: CustomStringConvertible { 77 | var description: String { 78 | let result = buffer.map { "\"\($0)\": \($1)" }.joined(separator: ", ") 79 | 80 | return "[\(result)]" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Metatypes.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Max Desiatov on 30/12/2018. 7 | // 8 | 9 | /// Type-erased protocol helper for a metatype check in generic `decode` 10 | /// overload. If you custom sequence type is not decoded correctly, try 11 | /// making it confirm to `XMLDecodableSequence`. Default conformances for 12 | /// `Array` and `Dictionary` are already provided by the XMLCoder library. 13 | public protocol XMLDecodableSequence { 14 | init() 15 | } 16 | 17 | extension Array: XMLDecodableSequence {} 18 | 19 | extension Dictionary: XMLDecodableSequence {} 20 | 21 | /// Type-erased protocol helper for a metatype check in generic `decode` 22 | /// overload. 23 | protocol AnyOptional { 24 | init() 25 | } 26 | 27 | extension Optional: AnyOptional { 28 | init() { 29 | self = nil 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/18/18. 7 | // 8 | 9 | import Foundation 10 | 11 | extension StringProtocol where Self.Index == String.Index { 12 | func escape(_ characterSet: [(character: String, escapedCharacter: String)]) -> String { 13 | var string = String(self) 14 | 15 | for set in characterSet { 16 | string = string.replacingOccurrences(of: set.character, with: set.escapedCharacter, options: .literal) 17 | } 18 | 19 | return string 20 | } 21 | } 22 | 23 | extension StringProtocol { 24 | func capitalizingFirstLetter() -> Self { 25 | guard !isEmpty else { 26 | return self 27 | } 28 | return Self(prefix(1).uppercased() + dropFirst())! 29 | } 30 | 31 | mutating func capitalizeFirstLetter() { 32 | self = capitalizingFirstLetter() 33 | } 34 | 35 | func lowercasingFirstLetter() -> Self { 36 | // avoid lowercasing single letters (I), or capitalized multiples (AThing ! to aThing, leave as AThing) 37 | guard count > 1, !(String(prefix(2)) == prefix(2).lowercased()) else { 38 | return self 39 | } 40 | return Self(prefix(1).lowercased() + dropFirst())! 41 | } 42 | 43 | mutating func lowercaseFirstLetter() { 44 | self = lowercasingFirstLetter() 45 | } 46 | } 47 | 48 | extension String { 49 | func isAllWhitespace() -> Bool { 50 | return unicodeScalars.allSatisfy(CharacterSet.whitespacesAndNewlines.contains) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/Utils.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2023 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Alkenso (Vladimir Vashurkin) on 08.06.2023. 7 | // 8 | 9 | import Foundation 10 | 11 | extension CodingKey { 12 | internal var isInlined: Bool { stringValue == "" } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/XMLChoiceCodingKey.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Benjamin Wetherfield on 7/17/19. 7 | // 8 | 9 | /// An empty marker protocol that can be used in place of `CodingKey`. It must be used when 10 | /// attempting to encode and decode union-type–like enums with associated values to and from `XML` 11 | /// choice elements. 12 | /// 13 | /// - Important: In order for your `XML`-destined `Codable` type to be encoded and/or decoded 14 | /// properly, you must conform your custom `CodingKey` type additionally to `XMLChoiceCodingKey`. 15 | /// 16 | /// For example, say you have defined a type which can hold _either_ an `Int` _or_ a `String`: 17 | /// 18 | /// enum IntOrString { 19 | /// case int(Int) 20 | /// case string(String) 21 | /// } 22 | /// 23 | /// Implementing the requirements for the `Codable` protocol like this: 24 | /// 25 | /// extension IntOrString: Codable { 26 | /// enum CodingKeys: String, XMLChoiceCodingKey { 27 | /// case int 28 | /// case string 29 | /// } 30 | /// 31 | /// func encode(to encoder: Encoder) throws { 32 | /// var container = encoder.container(keyedBy: CodingKeys.self) 33 | /// switch self { 34 | /// case let .int(value): 35 | /// try container.encode(value, forKey: .int) 36 | /// case let .string(value): 37 | /// try container.encode(value, forKey: .string) 38 | /// } 39 | /// } 40 | /// 41 | /// init(from decoder: Decoder) throws { 42 | /// let container = try decoder.container(keyedBy: CodingKeys.self) 43 | /// do { 44 | /// self = .int(try container.decode(Int.self, forKey: .int)) 45 | /// } catch { 46 | /// self = .string(try container.decode(String.self, forKey: .string)) 47 | /// } 48 | /// } 49 | /// } 50 | /// 51 | /// Retroactively conform the `CodingKeys` enum to `XMLChoiceCodingKey` when targeting `XML` as your 52 | /// encoded format. 53 | /// 54 | /// extension IntOrString.CodingKeys: XMLChoiceCodingKey {} 55 | /// 56 | /// - Note: The `XMLChoiceCodingKey` marker protocol allows the `XMLEncoder` / `XMLDecoder` to 57 | /// resolve ambiguities particular to the `XML` format between nested unkeyed container elements and 58 | /// choice elements. 59 | public protocol XMLChoiceCodingKey: CodingKey {} 60 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/XMLDocumentType.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Joannis Orlandos on 8/11/22. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct XMLDocumentType { 12 | public enum External: String { 13 | case `public` = "PUBLIC" 14 | case system = "SYSTEM" 15 | } 16 | 17 | public let rootElement: String 18 | public let external: External 19 | public let dtdName: String? 20 | public let dtdLocation: String 21 | 22 | internal init( 23 | rootElement: String, 24 | external: External, 25 | dtdName: String?, 26 | dtdLocation: String 27 | ) { 28 | self.rootElement = rootElement 29 | self.external = external 30 | self.dtdName = dtdName 31 | self.dtdLocation = dtdLocation 32 | } 33 | 34 | public static func `public`(rootElement: String, dtdName: String, dtdLocation: String) -> XMLDocumentType { 35 | XMLDocumentType( 36 | rootElement: rootElement, 37 | external: .public, 38 | dtdName: dtdName, 39 | dtdLocation: dtdLocation 40 | ) 41 | } 42 | 43 | public static func system(rootElement: String, dtdLocation: String) -> XMLDocumentType { 44 | XMLDocumentType( 45 | rootElement: rootElement, 46 | external: .system, 47 | dtdName: nil, 48 | dtdLocation: dtdLocation 49 | ) 50 | } 51 | 52 | func toXML() -> String { 53 | var string = "\n" 62 | 63 | return string 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/XMLHeader.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 12/18/18. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Type that allows overriding XML header during encoding. Pass a value of this type to the `encode` 12 | /// function of `XMLEncoder` to specify the exact value of the header you'd like to see in the encoded 13 | /// data. 14 | public struct XMLHeader { 15 | /// The XML standard that the produced document conforms to. 16 | public let version: Double? 17 | 18 | /// The encoding standard used to represent the characters in the produced document. 19 | public let encoding: String? 20 | 21 | /// Indicates whether a document relies on information from an external source. 22 | public let standalone: String? 23 | 24 | public init(version: Double? = nil, encoding: String? = nil, standalone: String? = nil) { 25 | self.version = version 26 | self.encoding = encoding 27 | self.standalone = standalone 28 | } 29 | 30 | func isEmpty() -> Bool { 31 | return version == nil && encoding == nil && standalone == nil 32 | } 33 | 34 | func toXML() -> String? { 35 | guard !isEmpty() else { 36 | return nil 37 | } 38 | 39 | var string = "\n" 54 | 55 | return string 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Auxiliaries/XMLKey.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017-2020 Shawn Moore and XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Shawn Moore on 11/21/17. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Shared Key Types 12 | struct XMLKey: CodingKey { 13 | public let stringValue: String 14 | public let intValue: Int? 15 | 16 | public init?(stringValue: String) { 17 | self.init(key: stringValue) 18 | } 19 | 20 | public init?(intValue: Int) { 21 | self.init(index: intValue) 22 | } 23 | 24 | public init(stringValue: String, intValue: Int?) { 25 | self.stringValue = stringValue 26 | self.intValue = intValue 27 | } 28 | 29 | init(key: String) { 30 | self.init(stringValue: key, intValue: nil) 31 | } 32 | 33 | init(index: Int) { 34 | self.init(stringValue: "\(index)", intValue: index) 35 | } 36 | 37 | static let `super` = XMLKey(stringValue: "super")! 38 | } 39 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Decoder/DecodingErrorExtension.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017-2020 Shawn Moore and XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Shawn Moore on 11/21/17. 7 | // 8 | 9 | import Foundation 10 | 11 | // MARK: - Error Utilities 12 | 13 | extension DecodingError { 14 | /// Returns a `.typeMismatch` error describing the expected type. 15 | /// 16 | /// - parameter path: The path of `CodingKey`s taken to decode a value of this type. 17 | /// - parameter expectation: The type expected to be encountered. 18 | /// - parameter reality: The value that was encountered instead of the expected type. 19 | /// - returns: A `DecodingError` with the appropriate path and debug description. 20 | static func typeMismatch(at path: [CodingKey], expectation: Any.Type, reality: Box) -> DecodingError { 21 | let description = "Expected to decode \(expectation) but found \(_typeDescription(of: reality)) instead." 22 | return .typeMismatch(expectation, Context(codingPath: path, debugDescription: description)) 23 | } 24 | 25 | /// Returns a description of the type of `value` appropriate for an error message. 26 | /// 27 | /// - parameter value: The value whose type to describe. 28 | /// - returns: A string describing `value`. 29 | /// - precondition: `value` is one of the types below. 30 | static func _typeDescription(of box: Box) -> String { 31 | switch box { 32 | case is NullBox: 33 | return "a null value" 34 | case is BoolBox: 35 | return "a boolean value" 36 | case is DecimalBox: 37 | return "a decimal value" 38 | case is IntBox: 39 | return "a signed integer value" 40 | case is UIntBox: 41 | return "an unsigned integer value" 42 | case is FloatBox: 43 | return "a floating-point value" 44 | case is DoubleBox: 45 | return "a double floating-point value" 46 | case is UnkeyedBox: 47 | return "a array value" 48 | case is KeyedBox: 49 | return "a dictionary value" 50 | case _: 51 | return "\(type(of: box))" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Decoder/DynamicNodeDecoding.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Max Desiatov on 01/03/2019. 7 | // 8 | 9 | /** Allows conforming types to specify how its properties will be decoded. 10 | 11 | For example: 12 | ```swift 13 | struct Book: Codable, Equatable, DynamicNodeDecoding { 14 | let id: UInt 15 | let title: String 16 | let categories: [Category] 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case id 20 | case title 21 | case categories = "category" 22 | } 23 | 24 | static func nodeDecoding(for key: CodingKey) -> XMLDecoder.NodeDecoding { 25 | switch key { 26 | case Book.CodingKeys.id: return .attribute 27 | default: return .element 28 | } 29 | } 30 | } 31 | ``` 32 | allows XML of this form to be decoded into values of type `Book`: 33 | 34 | ```xml 35 | 36 | Cat in the Hat 37 | Kids 38 | Wildlife 39 | 40 | ``` 41 | */ 42 | public protocol DynamicNodeDecoding: Decodable { 43 | static func nodeDecoding(for key: CodingKey) -> XMLDecoder.NodeDecoding 44 | } 45 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Decoder/SingleValueDecodingContainer.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017-2020 Shawn Moore and XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Shawn Moore on 11/20/17. 7 | // 8 | 9 | import Foundation 10 | 11 | extension XMLDecoderImplementation: SingleValueDecodingContainer { 12 | // MARK: SingleValueDecodingContainer Methods 13 | 14 | public func decodeNil() -> Bool { 15 | return (try? topContainer().isNull) ?? true 16 | } 17 | 18 | public func decode(_: Bool.Type) throws -> Bool { 19 | return try unbox(try topContainer()) 20 | } 21 | 22 | public func decode(_: Decimal.Type) throws -> Decimal { 23 | return try unbox(try topContainer()) 24 | } 25 | 26 | public func decode(_: T.Type) throws -> T { 27 | return try unbox(try topContainer()) 28 | } 29 | 30 | public func decode(_: T.Type) throws -> T { 31 | return try unbox(try topContainer()) 32 | } 33 | 34 | public func decode(_: Float.Type) throws -> Float { 35 | return try unbox(try topContainer()) 36 | } 37 | 38 | public func decode(_: Double.Type) throws -> Double { 39 | return try unbox(try topContainer()) 40 | } 41 | 42 | public func decode(_: String.Type) throws -> String { 43 | return try unbox(try topContainer()) 44 | } 45 | 46 | public func decode(_: String.Type) throws -> Date { 47 | return try unbox(try topContainer()) 48 | } 49 | 50 | public func decode(_: String.Type) throws -> Data { 51 | return try unbox(try topContainer()) 52 | } 53 | 54 | public func decode(_: T.Type) throws -> T { 55 | return try unbox(try topContainer()) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Decoder/XMLChoiceDecodingContainer.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by James Bean on 7/18/19. 7 | // 8 | 9 | /// Container specialized for decoding XML choice elements. 10 | struct XMLChoiceDecodingContainer: KeyedDecodingContainerProtocol { 11 | typealias Key = K 12 | 13 | // MARK: Properties 14 | 15 | /// A reference to the decoder we're reading from. 16 | private let decoder: XMLDecoderImplementation 17 | 18 | /// A reference to the container we're reading from. 19 | private let container: SharedBox 20 | 21 | /// The path of coding keys taken to get to this point in decoding. 22 | public private(set) var codingPath: [CodingKey] 23 | 24 | // MARK: - Initialization 25 | 26 | /// Initializes `self` by referencing the given decoder and container. 27 | init(referencing decoder: XMLDecoderImplementation, wrapping container: SharedBox) { 28 | self.decoder = decoder 29 | container.withShared { $0.key = decoder.keyTransform($0.key) } 30 | self.container = container 31 | codingPath = decoder.codingPath 32 | } 33 | 34 | // MARK: - KeyedDecodingContainerProtocol Methods 35 | 36 | public var allKeys: [Key] { 37 | return container.withShared { [Key(stringValue: $0.key)!] } 38 | } 39 | 40 | public func contains(_ key: Key) -> Bool { 41 | return container.withShared { $0.key == key.stringValue } 42 | } 43 | 44 | public func decodeNil(forKey key: Key) throws -> Bool { 45 | return container.withShared { $0.element.isNull } 46 | } 47 | 48 | public func decode(_ type: T.Type, forKey key: Key) throws -> T { 49 | guard container.withShared({ $0.key == key.stringValue }), key is XMLChoiceCodingKey else { 50 | throw DecodingError.typeMismatch( 51 | at: codingPath, 52 | expectation: type, 53 | reality: container 54 | ) 55 | } 56 | return try decoder.unbox(container.withShared { $0.element }) 57 | } 58 | 59 | public func nestedContainer( 60 | keyedBy _: NestedKey.Type, forKey key: Key 61 | ) throws -> KeyedDecodingContainer { 62 | guard container.unboxed.key == key.stringValue else { 63 | throw DecodingError.typeMismatch( 64 | at: codingPath, 65 | expectation: NestedKey.self, 66 | reality: container 67 | ) 68 | } 69 | 70 | let value = container.unboxed.element 71 | guard let container = XMLKeyedDecodingContainer(box: value, decoder: decoder) else { 72 | throw DecodingError.typeMismatch( 73 | at: codingPath, 74 | expectation: [String: Any].self, 75 | reality: value 76 | ) 77 | } 78 | 79 | return KeyedDecodingContainer(container) 80 | } 81 | 82 | public func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { 83 | throw DecodingError.typeMismatch( 84 | at: codingPath, 85 | expectation: Key.self, 86 | reality: container 87 | ) 88 | } 89 | 90 | public func superDecoder() throws -> Decoder { 91 | throw DecodingError.typeMismatch( 92 | at: codingPath, 93 | expectation: Key.self, 94 | reality: container 95 | ) 96 | } 97 | 98 | public func superDecoder(forKey key: Key) throws -> Decoder { 99 | throw DecodingError.typeMismatch( 100 | at: codingPath, 101 | expectation: Key.self, 102 | reality: container 103 | ) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Decoder/XMLDecodingStorage.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017-2020 Shawn Moore and XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Shawn Moore on 11/20/17. 7 | // 8 | 9 | import Foundation 10 | 11 | // MARK: - Decoding Storage 12 | 13 | struct XMLDecodingStorage { 14 | // MARK: Properties 15 | 16 | /// The container stack. 17 | /// Elements may be any one of the XML types (StringBox, KeyedBox). 18 | private var containers: [Box] = [] 19 | 20 | // MARK: - Initialization 21 | 22 | /// Initializes `self` with no containers. 23 | init() {} 24 | 25 | // MARK: - Modifying the Stack 26 | 27 | var count: Int { 28 | return containers.count 29 | } 30 | 31 | func topContainer() -> Box? { 32 | return containers.last 33 | } 34 | 35 | mutating func push(container: Box) { 36 | if let keyedBox = container as? KeyedBox { 37 | containers.append(SharedBox(keyedBox)) 38 | } else if let unkeyedBox = container as? UnkeyedBox { 39 | containers.append(SharedBox(unkeyedBox)) 40 | } else { 41 | containers.append(container) 42 | } 43 | } 44 | 45 | @discardableResult 46 | mutating func popContainer() -> Box? { 47 | guard !containers.isEmpty else { 48 | return nil 49 | } 50 | return containers.removeLast() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Encoder/DynamicNodeEncoding.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Joseph Mattiello on 1/24/19. 7 | // 8 | 9 | /** Allows conforming types to specify how its properties will be encoded. 10 | 11 | For example: 12 | ```swift 13 | struct Book: Codable, Equatable, DynamicNodeEncoding { 14 | let id: UInt 15 | let title: String 16 | let categories: [Category] 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case id 20 | case title 21 | case categories = "category" 22 | } 23 | 24 | static func nodeEncoding(for key: CodingKey) -> XMLEncoder.NodeEncoding { 25 | switch key { 26 | case Book.CodingKeys.id: return .both 27 | default: return .element 28 | } 29 | } 30 | } 31 | ``` 32 | produces XML of this form for values of type `Book`: 33 | 34 | ```xml 35 | 36 | 123 37 | Cat in the Hat 38 | Kids 39 | Wildlife 40 | 41 | ``` 42 | */ 43 | public protocol DynamicNodeEncoding: Encodable { 44 | static func nodeEncoding(for key: CodingKey) -> XMLEncoder.NodeEncoding 45 | } 46 | 47 | extension Array: DynamicNodeEncoding where Element: DynamicNodeEncoding { 48 | public static func nodeEncoding(for key: CodingKey) -> XMLEncoder.NodeEncoding { 49 | return Element.nodeEncoding(for: key) 50 | } 51 | } 52 | 53 | public extension DynamicNodeEncoding where Self: Collection, Self.Iterator.Element: DynamicNodeEncoding { 54 | static func nodeEncoding(for key: CodingKey) -> XMLEncoder.NodeEncoding { 55 | return Element.nodeEncoding(for: key) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Encoder/EncodingErrorExtension.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017-2020 Shawn Moore and XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Shawn Moore on 11/22/17. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Error Utilities 12 | extension EncodingError { 13 | /// Returns a `.invalidValue` error describing the given invalid floating-point value. 14 | /// 15 | /// 16 | /// - parameter value: The value that was invalid to encode. 17 | /// - parameter path: The path of `CodingKey`s taken to encode this value. 18 | /// - returns: An `EncodingError` with the appropriate path and debug description. 19 | static func _invalidFloatingPointValue(_ value: T, at codingPath: [CodingKey]) -> EncodingError { 20 | let valueDescription: String 21 | if value == T.infinity { 22 | valueDescription = "\(T.self).infinity" 23 | } else if value == -T.infinity { 24 | valueDescription = "-\(T.self).infinity" 25 | } else { 26 | valueDescription = "\(T.self).nan" 27 | } 28 | 29 | let debugDescription = """ 30 | Unable to encode \(valueDescription) directly in XML. \ 31 | Use XMLEncoder.NonConformingFloatEncodingStrategy.convertToString \ 32 | to specify how the value should be encoded. 33 | """ 34 | return .invalidValue(value, EncodingError.Context(codingPath: codingPath, debugDescription: debugDescription)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Encoder/SingleValueEncodingContainer.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017-2020 Shawn Moore and XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Shawn Moore on 11/22/17. 7 | // 8 | 9 | import Foundation 10 | 11 | extension XMLEncoderImplementation: SingleValueEncodingContainer { 12 | // MARK: - SingleValueEncodingContainer Methods 13 | 14 | func assertCanEncodeNewValue() { 15 | precondition( 16 | canEncodeNewValue, 17 | """ 18 | Attempt to encode value through single value container when \ 19 | previously value already encoded. 20 | """ 21 | ) 22 | } 23 | 24 | public func encodeNil() throws { 25 | assertCanEncodeNewValue() 26 | storage.push(container: box()) 27 | } 28 | 29 | public func encode(_ value: Bool) throws { 30 | assertCanEncodeNewValue() 31 | storage.push(container: box(value)) 32 | } 33 | 34 | public func encode(_ value: Int) throws { 35 | assertCanEncodeNewValue() 36 | storage.push(container: box(value)) 37 | } 38 | 39 | public func encode(_ value: Int8) throws { 40 | assertCanEncodeNewValue() 41 | storage.push(container: box(value)) 42 | } 43 | 44 | public func encode(_ value: Int16) throws { 45 | assertCanEncodeNewValue() 46 | storage.push(container: box(value)) 47 | } 48 | 49 | public func encode(_ value: Int32) throws { 50 | assertCanEncodeNewValue() 51 | storage.push(container: box(value)) 52 | } 53 | 54 | public func encode(_ value: Int64) throws { 55 | assertCanEncodeNewValue() 56 | storage.push(container: box(value)) 57 | } 58 | 59 | public func encode(_ value: UInt) throws { 60 | assertCanEncodeNewValue() 61 | storage.push(container: box(value)) 62 | } 63 | 64 | public func encode(_ value: UInt8) throws { 65 | assertCanEncodeNewValue() 66 | storage.push(container: box(value)) 67 | } 68 | 69 | public func encode(_ value: UInt16) throws { 70 | assertCanEncodeNewValue() 71 | storage.push(container: box(value)) 72 | } 73 | 74 | public func encode(_ value: UInt32) throws { 75 | assertCanEncodeNewValue() 76 | storage.push(container: box(value)) 77 | } 78 | 79 | public func encode(_ value: UInt64) throws { 80 | assertCanEncodeNewValue() 81 | storage.push(container: box(value)) 82 | } 83 | 84 | public func encode(_ value: String) throws { 85 | assertCanEncodeNewValue() 86 | storage.push(container: box(value)) 87 | } 88 | 89 | public func encode(_ value: Float) throws { 90 | assertCanEncodeNewValue() 91 | try storage.push(container: box(value)) 92 | } 93 | 94 | public func encode(_ value: Double) throws { 95 | assertCanEncodeNewValue() 96 | try storage.push(container: box(value)) 97 | } 98 | 99 | public func encode(_ value: T) throws { 100 | assertCanEncodeNewValue() 101 | try storage.push(container: box(value)) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Encoder/XMLEncodingStorage.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017-2020 Shawn Moore and XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Shawn Moore on 11/22/17. 7 | // 8 | 9 | import Foundation 10 | 11 | // MARK: - Encoding Storage and Containers 12 | 13 | struct XMLEncodingStorage { 14 | // MARK: Properties 15 | 16 | /// The container stack. 17 | private var containers: [Box] = [] 18 | 19 | // MARK: - Initialization 20 | 21 | /// Initializes `self` with no containers. 22 | init() {} 23 | 24 | // MARK: - Modifying the Stack 25 | 26 | var count: Int { 27 | return containers.count 28 | } 29 | 30 | var lastContainer: Box? { 31 | return containers.last 32 | } 33 | 34 | mutating func pushKeyedContainer(_ keyedBox: KeyedBox = KeyedBox()) -> SharedBox { 35 | let container = SharedBox(keyedBox) 36 | containers.append(container) 37 | return container 38 | } 39 | 40 | mutating func pushChoiceContainer() -> SharedBox { 41 | let container = SharedBox(ChoiceBox()) 42 | containers.append(container) 43 | return container 44 | } 45 | 46 | mutating func pushUnkeyedContainer() -> SharedBox { 47 | let container = SharedBox(UnkeyedBox()) 48 | containers.append(container) 49 | return container 50 | } 51 | 52 | mutating func push(container: Box) { 53 | if let keyedBox = container as? KeyedBox { 54 | containers.append(SharedBox(keyedBox)) 55 | } else if let unkeyedBox = container as? UnkeyedBox { 56 | containers.append(SharedBox(unkeyedBox)) 57 | } else { 58 | containers.append(container) 59 | } 60 | } 61 | 62 | mutating func popContainer() -> Box { 63 | precondition(!containers.isEmpty, "Empty container stack.") 64 | return containers.popLast()! 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Dependencies/XMLCoder/Encoder/XMLUnkeyedEncodingContainer.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018-2020 XMLCoder contributors 2 | // 3 | // This software is released under the MIT License. 4 | // https://opensource.org/licenses/MIT 5 | // 6 | // Created by Vincent Esche on 11/20/18. 7 | // 8 | 9 | import Foundation 10 | 11 | struct XMLUnkeyedEncodingContainer: UnkeyedEncodingContainer { 12 | // MARK: Properties 13 | 14 | /// A reference to the encoder we're writing to. 15 | private let encoder: XMLEncoderImplementation 16 | 17 | /// A reference to the container we're writing to. 18 | private let container: SharedBox 19 | 20 | /// The path of coding keys taken to get to this point in encoding. 21 | public private(set) var codingPath: [CodingKey] 22 | 23 | /// The number of elements encoded into the container. 24 | public var count: Int { 25 | return container.withShared { $0.count } 26 | } 27 | 28 | // MARK: - Initialization 29 | 30 | /// Initializes `self` with the given references. 31 | init( 32 | referencing encoder: XMLEncoderImplementation, 33 | codingPath: [CodingKey], 34 | wrapping container: SharedBox 35 | ) { 36 | self.encoder = encoder 37 | self.codingPath = codingPath 38 | self.container = container 39 | } 40 | 41 | // MARK: - UnkeyedEncodingContainer Methods 42 | 43 | public mutating func encodeNil() throws { 44 | container.withShared { container in 45 | container.append(encoder.box()) 46 | } 47 | } 48 | 49 | public mutating func encode(_ value: T) throws { 50 | try encode(value) { encoder, value in 51 | try encoder.box(value) 52 | } 53 | } 54 | 55 | private mutating func encode( 56 | _ value: T, 57 | encode: (XMLEncoderImplementation, T) throws -> Box 58 | ) rethrows { 59 | encoder.codingPath.append(XMLKey(index: count)) 60 | defer { self.encoder.codingPath.removeLast() } 61 | 62 | try container.withShared { container in 63 | container.append(try encode(encoder, value)) 64 | } 65 | } 66 | 67 | public mutating func nestedContainer( 68 | keyedBy _: NestedKey.Type 69 | ) -> KeyedEncodingContainer { 70 | if NestedKey.self is XMLChoiceCodingKey.Type { 71 | return nestedChoiceContainer(keyedBy: NestedKey.self) 72 | } else { 73 | return nestedKeyedContainer(keyedBy: NestedKey.self) 74 | } 75 | } 76 | 77 | public mutating func nestedKeyedContainer(keyedBy _: NestedKey.Type) -> KeyedEncodingContainer { 78 | codingPath.append(XMLKey(index: count)) 79 | defer { self.codingPath.removeLast() } 80 | 81 | let sharedKeyed = SharedBox(KeyedBox()) 82 | self.container.withShared { container in 83 | container.append(sharedKeyed) 84 | } 85 | 86 | let container = XMLKeyedEncodingContainer( 87 | referencing: encoder, 88 | codingPath: codingPath, 89 | wrapping: sharedKeyed 90 | ) 91 | return KeyedEncodingContainer(container) 92 | } 93 | 94 | public mutating func nestedChoiceContainer(keyedBy _: NestedKey.Type) -> KeyedEncodingContainer { 95 | codingPath.append(XMLKey(index: count)) 96 | defer { self.codingPath.removeLast() } 97 | 98 | let sharedChoice = SharedBox(ChoiceBox()) 99 | self.container.withShared { container in 100 | container.append(sharedChoice) 101 | } 102 | 103 | let container = XMLChoiceEncodingContainer( 104 | referencing: encoder, 105 | codingPath: codingPath, 106 | wrapping: sharedChoice 107 | ) 108 | return KeyedEncodingContainer(container) 109 | } 110 | 111 | public mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { 112 | codingPath.append(XMLKey(index: count)) 113 | defer { self.codingPath.removeLast() } 114 | 115 | let sharedUnkeyed = SharedBox(UnkeyedBox()) 116 | container.withShared { container in 117 | container.append(sharedUnkeyed) 118 | } 119 | 120 | return XMLUnkeyedEncodingContainer( 121 | referencing: encoder, 122 | codingPath: codingPath, 123 | wrapping: sharedUnkeyed 124 | ) 125 | } 126 | 127 | public mutating func superEncoder() -> Encoder { 128 | return XMLReferencingEncoder( 129 | referencing: encoder, 130 | at: count, 131 | wrapping: container 132 | ) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Helpers/PopUpHelper.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Orion 3 | 4 | struct PopUpHelper { 5 | private static var isPopUpShowing = false 6 | 7 | static let sharedPresenter = type( 8 | of: Dynamic.SPTEncorePopUpPresenter 9 | .alloc(interface: SPTEncorePopUpPresenter.self) 10 | ) 11 | .shared() 12 | 13 | static func showPopUp( 14 | delayed: Bool = false, 15 | message: String, 16 | buttonText: String, 17 | secondButtonText: String? = nil, 18 | onPrimaryClick: (() -> Void)? = nil, 19 | onSecondaryClick: (() -> Void)? = nil 20 | ) { 21 | DispatchQueue.main.asyncAfter(deadline: delayed ? .now() + 3.0 : .now()) { 22 | if isPopUpShowing { 23 | return 24 | } 25 | 26 | let model = Dynamic.SPTEncorePopUpDialogModel 27 | .alloc(interface: SPTEncorePopUpDialogModel.self) 28 | .initWithTitle( 29 | "EeveeSpotify", 30 | description: message, 31 | image: nil, 32 | primaryButtonTitle: buttonText, 33 | secondaryButtonTitle: secondButtonText 34 | ) 35 | 36 | let dialog = Dynamic.SPTEncorePopUpDialog 37 | .alloc(interface: SPTEncorePopUpDialog.self) 38 | .`init`() 39 | 40 | dialog.update(model) 41 | dialog.setEventHandler({ state in 42 | switch (state) { 43 | 44 | case .primary: onPrimaryClick?() 45 | case .secondary: onSecondaryClick?() 46 | 47 | } 48 | 49 | sharedPresenter.dismissPopupWithAnimate(true, clearQueue: false, completion: nil) 50 | isPopUpShowing.toggle() 51 | }) 52 | 53 | isPopUpShowing.toggle() 54 | sharedPresenter.presentPopUp(dialog) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Helpers/URLSessionHelper.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class URLSessionHelper { 4 | static let shared = URLSessionHelper() 5 | 6 | private var requestsMap: [URL:Data] 7 | 8 | private init() { 9 | self.requestsMap = [:] 10 | } 11 | 12 | static var DarwinVersion: String { 13 | var sysinfo = utsname() 14 | uname(&sysinfo) 15 | let dv = String( 16 | bytes: Data(bytes: &sysinfo.release, count: Int(_SYS_NAMELEN)), 17 | encoding: .ascii 18 | )!.trimmingCharacters(in: .controlCharacters) 19 | return "Darwin/\(dv)" 20 | } 21 | 22 | static var CFNetworkVersion: String { 23 | let dictionary = Bundle(identifier: "com.apple.CFNetwork")?.infoDictionary! 24 | let version = dictionary?["CFBundleShortVersionString"] as! String 25 | return "CFNetwork/\(version)" 26 | } 27 | 28 | func setOrAppend(_ data: Data, for url: URL) { 29 | var loadedData = requestsMap[url] ?? Data() 30 | loadedData.append(data) 31 | 32 | requestsMap[url] = loadedData 33 | } 34 | 35 | func obtainData(for url: URL) -> Data? { 36 | return requestsMap.removeValue(forKey: url) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Helpers/WindowHelper.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct WindowHelper { 4 | static let shared = WindowHelper() 5 | 6 | let window: UIWindow 7 | let rootViewController: UIViewController 8 | 9 | private init() { 10 | self.window = UIApplication.shared.windows.first! 11 | self.rootViewController = window.rootViewController! 12 | } 13 | 14 | func present(_ viewController: UIViewController) { 15 | rootViewController.present(viewController, animated: true) 16 | } 17 | 18 | func findFirstViewController(_ regex: String) -> UIViewController? { 19 | let rootView = self.rootViewController.view! 20 | var result: UIViewController? 21 | 22 | func searchViews(_ view: UIView) { 23 | if let viewController = self.viewController(for: view) { 24 | if NSStringFromClass(type(of: viewController)) ~= regex { 25 | result = viewController 26 | return 27 | } 28 | } 29 | 30 | for subview in view.subviews { 31 | searchViews(subview) 32 | } 33 | } 34 | 35 | searchViews(rootView) 36 | return result 37 | } 38 | 39 | func dismissCurrentViewController() { 40 | rootViewController.dismiss(animated: true) 41 | } 42 | 43 | func overrideUserInterfaceStyle(_ style: UIUserInterfaceStyle) { 44 | window.overrideUserInterfaceStyle = style 45 | } 46 | 47 | func viewController(for view: UIView) -> UIViewController? { 48 | var responder: UIResponder? = view 49 | while let nextResponder = responder?.next { 50 | if let viewController = nextResponder as? UIViewController { 51 | return viewController 52 | } 53 | responder = nextResponder 54 | } 55 | return nil 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/HookedInstances.x.swift: -------------------------------------------------------------------------------- 1 | import Orion 2 | import UIKit 3 | 4 | class HookedInstances { 5 | static var productState: SPTCoreProductState? 6 | static var currentTrack: SPTPlayerTrack? 7 | static var nowPlayingMetaBackgroundModel: SPTNowPlayingMetadataBackgroundViewModel? 8 | } 9 | 10 | class SPTNowPlayingModelHook: ClassHook { 11 | static let targetName = "SPTNowPlayingModel" 12 | 13 | func currentTrack() -> SPTPlayerTrack? { 14 | if let track = orig.currentTrack() { 15 | HookedInstances.currentTrack = track 16 | return track 17 | } 18 | 19 | return nil 20 | } 21 | } 22 | 23 | class SPTNowPlayingMetadataBackgroundViewModelHook: ClassHook { 24 | static let targetName = "SPTNowPlayingMetadataBackgroundViewModel" 25 | 26 | func color() -> UIColor { 27 | HookedInstances.nowPlayingMetaBackgroundModel = Dynamic.convert( 28 | target, 29 | to: SPTNowPlayingMetadataBackgroundViewModel.self 30 | ) 31 | return orig.color() 32 | } 33 | } 34 | 35 | class SPTCoreProductStateInstanceHook: ClassHook { 36 | static let targetName = "SPTCoreProductState" 37 | 38 | func stringForKey(_ key: String) -> NSString { 39 | HookedInstances.productState = Dynamic.convert( 40 | target, 41 | to: SPTCoreProductState.self 42 | ) 43 | return orig.stringForKey(key) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/CustomLyrics+AllTracksLyrics.x.swift: -------------------------------------------------------------------------------- 1 | import Orion 2 | 3 | class SPTPlayerTrackHook: ClassHook { 4 | typealias Group = LyricsGroup 5 | static let targetName = "SPTPlayerTrack" 6 | 7 | func metadata() -> [String:String] { 8 | var meta = orig.metadata() 9 | 10 | meta["has_lyrics"] = "true" 11 | return meta 12 | } 13 | } 14 | 15 | class LyricsScrollProviderHook: ClassHook { 16 | typealias Group = LyricsGroup 17 | 18 | static var targetName: String { 19 | return EeveeSpotify.isOldSpotifyVersion 20 | ? "Lyrics_CoreImpl.ScrollProvider" 21 | : "Lyrics_NPVCommunicatorImpl.ScrollProvider" 22 | } 23 | 24 | func isEnabledForTrack(_ track: SPTPlayerTrack) -> Bool { 25 | return true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusDataResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum GeniusDataResponse: Decodable { 4 | case sections(GeniusSectionsResponse) 5 | case song(GeniusSongResponse) 6 | 7 | init(from decoder: Decoder) throws { 8 | let container = try decoder.singleValueContainer() 9 | 10 | if let sections = try? container.decode(GeniusSectionsResponse.self) { 11 | self = .sections(sections) 12 | } 13 | else if let song = try? container.decode(GeniusSongResponse.self) { 14 | self = .song(song) 15 | } 16 | else { 17 | throw DecodingError.typeMismatch( 18 | GeniusDataResponse.self, 19 | DecodingError.Context( 20 | codingPath: decoder.codingPath, 21 | debugDescription: "Invalid data format" 22 | ) 23 | ) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusHit.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GeniusHit: Decodable { 4 | var result: GeniusHitResult 5 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusHitResult.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GeniusHitResult: Decodable { 4 | var id: Int 5 | var title: String 6 | var artistNames: String 7 | } 8 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusLyrics.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GeniusLyrics: Decodable { 4 | var plain: String 5 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusRootResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GeniusRootResponse : Decodable { 4 | var response: GeniusDataResponse? 5 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusSection.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GeniusSection: Decodable { 4 | var hits: [GeniusHit] 5 | } 6 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusSectionsResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GeniusSectionsResponse: Decodable { 4 | var sections: [GeniusSection] 5 | } 6 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusSong.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GeniusSong: Decodable { 4 | var lyrics: GeniusLyrics 5 | var language: String 6 | } 7 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Genius/GeniusSongResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GeniusSongResponse: Decodable { 4 | var song: GeniusSong 5 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Lrclib/LrclibSong.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LrclibSong: Decodable { 4 | var name: String 5 | var plainLyrics: String? 6 | var syncedLyrics: String? 7 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/LyricsDto.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LyricsDto { 4 | var lines: [LyricsLineDto] 5 | var timeSynced: Bool 6 | var romanization: LyricsRomanizationStatus 7 | var translation: LyricsTranslationDto? 8 | 9 | func toLyricsData(source: String) -> LyricsData { 10 | var lyricsData = LyricsData.with { 11 | $0.timeSynchronized = timeSynced 12 | $0.restriction = .unrestricted 13 | $0.providedBy = "\(source) (EeveeSpotify)" 14 | } 15 | 16 | let shouldRomanize = UserDefaults.lyricsOptions.romanization 17 | 18 | if lines.isEmpty { 19 | lyricsData.lines = [ 20 | LyricsLine.with { 21 | $0.content = "song_is_instrumental".localized 22 | }, 23 | LyricsLine.with { 24 | $0.content = "let_the_music_play".localized 25 | }, 26 | LyricsLine.with { 27 | $0.content = "" 28 | } 29 | ] 30 | } 31 | else { 32 | lyricsData.lines = lines.map { line in 33 | LyricsLine.with { 34 | $0.content = (shouldRomanize && romanization == .canBeRomanized) 35 | ? line.content.applyingTransform(.toLatin, reverse: false)! 36 | : line.content 37 | $0.offsetMs = Int32(line.offsetMs ?? 0) 38 | } 39 | } 40 | } 41 | 42 | if let translation = translation { 43 | lyricsData.translation = LyricsTranslation.with { 44 | $0.languageCode = translation.languageCode 45 | $0.lines = translation.lines 46 | } 47 | } 48 | 49 | return lyricsData 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/LyricsError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum LyricsError: Error, CustomStringConvertible { 4 | case noCurrentTrack 5 | case musixmatchRestricted 6 | case invalidMusixmatchToken 7 | case decodingError 8 | case noSuchSong 9 | case unknownError 10 | case invalidSource 11 | 12 | var description: String { 13 | switch self { 14 | case .noSuchSong: "no_such_song".localized 15 | case .musixmatchRestricted: "musixmatch_restricted".localized 16 | case .invalidMusixmatchToken: "invalid_musixmatch_token".localized 17 | case .decodingError: "decoding_error".localized 18 | case .noCurrentTrack: "no_current_track".localized 19 | case .unknownError: "unknown_error".localized 20 | case .invalidSource: "" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/LyricsLineDto.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LyricsLineDto { 4 | var content: String 5 | var offsetMs: Int? 6 | } 7 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/LyricsLoadingState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LyricsLoadingState { 4 | var wasRomanized = false 5 | var areEmpty = false 6 | var fallbackError: LyricsError? = nil 7 | var loadedSuccessfully = false 8 | } 9 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/LyricsRomanizationStatus.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum LyricsRomanizationStatus { 4 | case romanized 5 | case canBeRomanized 6 | case original 7 | } 8 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/LyricsSearchQuery.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LyricsSearchQuery { 4 | var title: String 5 | var primaryArtist: String 6 | var spotifyTrackId: String 7 | } 8 | 9 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/LyricsTranslationDto.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LyricsTranslationDto { 4 | var languageCode: String 5 | var lines: [String] 6 | } 7 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Musixmatch/MusixmatchSubtitle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MusixmatchSubtitle: Decodable { 4 | var text: String 5 | var time: MusixmatchTime 6 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Musixmatch/MusixmatchTime.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MusixmatchTime: Decodable { 4 | var total: Float 5 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Petit/PetitLyricsData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct PetitLyricsData: Codable { 4 | var lines: [PetitLyricsLine] 5 | 6 | enum CodingKeys: String, CodingKey { 7 | case lines = "line" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Petit/PetitLyricsLine.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct PetitLyricsLine: Codable { 4 | var linestring: String 5 | var words: [PetitLyricsWord] 6 | 7 | enum CodingKeys: String, CodingKey { 8 | case linestring 9 | case words = "word" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Petit/PetitLyricsType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum PetitLyricsType: Int, Codable { 4 | case notDetermined = 0 5 | case plain = 1 6 | case linesSynced = 2 7 | case wordsSynced = 3 8 | } 9 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Petit/PetitLyricsWord.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct PetitLyricsWord: Codable { 4 | var starttime: Int 5 | } 6 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Petit/PetitResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct PetitResponse: Codable { 4 | var songs: [PetitSong] 5 | 6 | init(from decoder: Decoder) throws { 7 | let container = try decoder.container(keyedBy: CodingKeys.self) 8 | songs = try container.decode(PetitSongs.self, forKey: .songs).songs 9 | } 10 | 11 | struct PetitSongs: Decodable { 12 | var songs: [PetitSong] 13 | 14 | enum CodingKeys: String, CodingKey { 15 | case songs = "song" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Petit/PetitSong.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct PetitSong: Codable { 4 | var lyricsId: Int 5 | var title: String 6 | var availableLyricsType: PetitLyricsType 7 | var lyricsType: PetitLyricsType 8 | var lyricsData: String 9 | } 10 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsColorsSettings.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LyricsColorsSettings: Codable, Equatable { 4 | var displayOriginalColors: Bool 5 | var useStaticColor: Bool 6 | var staticColor: String 7 | var normalizationFactor: CGFloat 8 | } 9 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsOptions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LyricsOptions: Codable, Equatable { 4 | var romanization: Bool 5 | var musixmatchLanguage: String 6 | } 7 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Models/Settings/LyricsSource.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum LyricsSource: Int, CaseIterable, CustomStringConvertible { 4 | case genius 5 | case lrclib 6 | case musixmatch 7 | case petit 8 | case notReplaced 9 | 10 | static var allCases: [LyricsSource] { 11 | return [.genius, .lrclib, .musixmatch, .petit] 12 | } 13 | 14 | var description: String { 15 | switch self { 16 | case .genius: "Genius" 17 | case .lrclib: "LRCLIB" 18 | case .musixmatch: "Musixmatch" 19 | case .petit: "PetitLyrics" 20 | case .notReplaced: "Spotify" 21 | } 22 | } 23 | 24 | var isReplacing: Bool { self != .notReplaced } 25 | 26 | static var defaultSource: LyricsSource { 27 | Locale.isInRegion("JP", orHasLanguage: "ja") 28 | ? .petit 29 | : .lrclib 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Protocols/LyricsRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol LyricsRepository { 4 | func getLyrics(_ query: LyricsSearchQuery, options: LyricsOptions) throws -> LyricsDto 5 | } 6 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Lyrics/Repositories/LrclibLyricsRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct LrcLibLyricsRepository: LyricsRepository { 4 | private let apiUrl = "https://lrclib.net/api" 5 | private let session: URLSession 6 | 7 | init() { 8 | let configuration = URLSessionConfiguration.default 9 | configuration.httpAdditionalHeaders = [ 10 | "User-Agent": "EeveeSpotify v\(EeveeSpotify.version) https://github.com/whoeevee/EeveeSpotify" 11 | ] 12 | 13 | session = URLSession(configuration: configuration) 14 | } 15 | 16 | private func perform( 17 | _ path: String, 18 | query: [String:Any] = [:] 19 | ) throws -> Data { 20 | var stringUrl = "\(apiUrl)\(path)" 21 | 22 | if !query.isEmpty { 23 | let queryString = query.queryString.addingPercentEncoding( 24 | withAllowedCharacters: .urlHostAllowed 25 | )! 26 | 27 | stringUrl += "?\(queryString)" 28 | } 29 | 30 | let request = URLRequest(url: URL(string: stringUrl)!) 31 | 32 | let semaphore = DispatchSemaphore(value: 0) 33 | var data: Data? 34 | var error: Error? 35 | 36 | let task = session.dataTask(with: request) { response, _, err in 37 | error = err 38 | data = response 39 | semaphore.signal() 40 | } 41 | 42 | task.resume() 43 | semaphore.wait() 44 | 45 | if let error = error { 46 | throw error 47 | } 48 | 49 | return data! 50 | } 51 | 52 | private func searchSong(_ query: String) throws -> [LrclibSong] { 53 | let data = try perform("/search", query: ["q": query]) 54 | return try JSONDecoder().decode([LrclibSong].self, from: data) 55 | } 56 | 57 | // 58 | 59 | private func mostRelevantSong(songs: [LrclibSong], strippedTitle: String) -> LrclibSong? { 60 | return songs.first( 61 | where: { $0.name.containsInsensitive(strippedTitle) } 62 | ) ?? songs.first 63 | } 64 | 65 | private func mapSyncedLyricsLines(_ lines: [String]) -> [LyricsLineDto] { 66 | return lines.compactMap { line in 67 | guard let match = line.firstMatch( 68 | "\\[(?\\d*):(?\\d*\\.?\\d*)\\] ?(?.*)" 69 | ) else { 70 | return nil 71 | } 72 | 73 | var captures: [String: String] = [:] 74 | 75 | for name in ["minute", "seconds", "content"] { 76 | 77 | let matchRange = match.range(withName: name) 78 | 79 | if let substringRange = Range(matchRange, in: line) { 80 | captures[name] = String(line[substringRange]) 81 | } 82 | } 83 | 84 | let minute = Int(captures["minute"]!)! 85 | let seconds = Float(captures["seconds"]!)! 86 | let content = captures["content"]! 87 | 88 | return LyricsLineDto( 89 | content: content.lyricsNoteIfEmpty, 90 | offsetMs: Int(minute * 60 * 1000 + Int(seconds * 1000)) 91 | ) 92 | } 93 | } 94 | 95 | func getLyrics(_ query: LyricsSearchQuery, options: LyricsOptions) throws -> LyricsDto { 96 | let strippedTitle = query.title.strippedTrackTitle 97 | let songs = try searchSong("\(strippedTitle) \(query.primaryArtist)") 98 | 99 | guard let song = mostRelevantSong(songs: songs, strippedTitle: strippedTitle) else { 100 | throw LyricsError.noSuchSong 101 | } 102 | 103 | if let syncedLyrics = song.syncedLyrics { 104 | let lines = Array(syncedLyrics.components(separatedBy: "\n").dropLast()) 105 | return LyricsDto( 106 | lines: mapSyncedLyricsLines(lines), 107 | timeSynced: true, 108 | romanization: lines.canBeRomanized ? .canBeRomanized : .original 109 | ) 110 | } 111 | 112 | guard let plainLyrics = song.plainLyrics else { 113 | throw LyricsError.decodingError 114 | } 115 | 116 | let lines = Array(plainLyrics.components(separatedBy: "\n").dropLast()) 117 | 118 | return LyricsDto( 119 | lines: lines.map { content in LyricsLineDto(content: content) }, 120 | timeSynced: false, 121 | romanization: lines.canBeRomanized ? .canBeRomanized : .original 122 | ) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/Collection+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Collection { 4 | func dropLast(while predicate: (Element) throws -> Bool) rethrows -> SubSequence { 5 | guard let index = try indices.reversed().first(where: { try !predicate(self[$0]) }) else { 6 | return self[startIndex..> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) 15 | case 6: 16 | (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) 17 | case 8: 18 | (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) 19 | default: 20 | (a, r, g, b) = (1, 1, 1, 0) 21 | } 22 | 23 | self.init( 24 | .sRGB, 25 | red: Double(r) / 255, 26 | green: Double(g) / 255, 27 | blue: Double(b) / 255, 28 | opacity: Double(a) / 255 29 | ) 30 | } 31 | 32 | var components: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { 33 | var r: CGFloat = 0 34 | var g: CGFloat = 0 35 | var b: CGFloat = 0 36 | var a: CGFloat = 0 37 | 38 | guard UIColor(self).getRed(&r, green: &g, blue: &b, alpha: &a) else { 39 | return (0, 0, 0, 0) 40 | } 41 | 42 | return (r, g, b, a) 43 | } 44 | 45 | func lighter(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).lighter(by: amount)) } 46 | func darker(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).darker(by: amount)) } 47 | 48 | var brightness: CGFloat { 49 | ( 50 | components.red * 299 51 | + components.green * 587 52 | + components.blue * 114 53 | ) / 1000 54 | } 55 | 56 | func normalized(_ by: CGFloat) -> Color { 57 | brightness < 0.5 58 | ? self.lighter(by: max(by - brightness, 0)) 59 | : self.darker(by: max(brightness - by, 0)) 60 | } 61 | 62 | var hexString: String { 63 | String( 64 | format: "%02X%02X%02X", 65 | Int(components.red * 255), 66 | Int(components.green * 255), 67 | Int(components.blue * 255) 68 | ) 69 | } 70 | 71 | var uInt32: UInt32 { 72 | UInt32(components.alpha * 255) << 24 73 | | UInt32(components.red * 255) << 16 74 | | UInt32(components.green * 255) << 8 75 | | UInt32(components.blue * 255) 76 | } 77 | } 78 | 79 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/Data+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | 5 | static func random(_ length: Int) -> Data { 6 | return Data((0 ..< length).map { _ in UInt8.random(in: UInt8.min ... UInt8.max) }) 7 | } 8 | 9 | var hexEncodedString: String { 10 | map { String(format: "%02hhx", $0) }.joined() 11 | } 12 | 13 | static var musixmatchTokenPlaceholder: String { 14 | "2" + self.random(53).hexEncodedString 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/Dictionary+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Dictionary { 4 | var queryString: String { 5 | return self 6 | .compactMap({ (key, value) -> String in 7 | return "\(key)=\(value)" 8 | }) 9 | .joined(separator: "&") 10 | } 11 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/Locale+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Locale { 4 | static func isInRegion(_ regionCode: String, orHasLanguage languageCode: String) -> Bool { 5 | self.current.regionCode == regionCode || self.preferredLanguages.contains(languageCode) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/StirngArray+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NaturalLanguage 3 | 4 | extension Array where Element == String { 5 | var canBeRomanized: Bool { 6 | var languageList: [NLLanguage] = [] 7 | 8 | for line in self { 9 | if let language = NLLanguageRecognizer.dominantLanguage(for: line) { 10 | languageList.append(language) 11 | } 12 | } 13 | 14 | let canBeRomanizedLanguageCount = languageList.filter { 15 | [.japanese, .korean, .simplifiedChinese, .traditionalChinese].contains($0) 16 | }.count 17 | 18 | return Double(canBeRomanizedLanguageCount) / Double(languageList.count) > 0.15 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/String+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import NaturalLanguage 4 | 5 | extension String { 6 | static func ~= (lhs: String, rhs: String) -> Bool { 7 | lhs.firstMatch(rhs) != nil 8 | } 9 | 10 | var localized: String { 11 | BundleHelper.shared.localizedString(self) 12 | } 13 | 14 | var uiKitLocalized: String { 15 | let bundle = Bundle(for: UIApplication.self) 16 | return bundle.localizedString(forKey: self, value: nil, table: nil) 17 | } 18 | 19 | func localizeWithFormat(_ arguments: CVarArg...) -> String{ 20 | String(format: self.localized, arguments: arguments) 21 | } 22 | 23 | var range: NSRange { 24 | NSRange(self.startIndex..., in: self) 25 | } 26 | 27 | var strippedTrackTitle: String { 28 | String( 29 | self 30 | .removeMatches("\\(.*\\)") 31 | .removeMatches("- .*") 32 | .trimmingCharacters(in: .whitespaces) 33 | ) 34 | } 35 | 36 | var isHex: Bool { 37 | self ~= "^[a-f0-9]+$" 38 | } 39 | 40 | var lyricsNoteIfEmpty: String { 41 | self.isEmpty ? "♪" : self 42 | } 43 | 44 | func containsInsensitive(_ s: S) -> Bool { 45 | self.range(of: s, options: .caseInsensitive) != nil 46 | } 47 | 48 | func firstMatch(_ pattern: String) -> NSTextCheckingResult? { 49 | try! NSRegularExpression(pattern: pattern) 50 | .firstMatch(in: self, range: self.range) 51 | } 52 | 53 | func removeMatches(_ pattern: String) -> String { 54 | try! NSRegularExpression(pattern: pattern) 55 | .stringByReplacingMatches( 56 | in: self, 57 | range: self.range, 58 | withTemplate: "" 59 | ) 60 | } 61 | 62 | var isCanBeRomanizedLanguage: Bool { 63 | ["ja", "ko", "z1"].contains(self) || self.contains("zh") 64 | } 65 | 66 | var hexadecimal: Data? { 67 | var data = Data(capacity: count / 2) 68 | 69 | let regex = try! NSRegularExpression(pattern: "[0-9a-f]{1,2}", options: .caseInsensitive) 70 | regex.enumerateMatches(in: self, range: NSRange(startIndex..., in: self)) { match, _, _ in 71 | let byteString = (self as NSString).substring(with: match!.range) 72 | let num = UInt8(byteString, radix: 16)! 73 | data.append(num) 74 | } 75 | 76 | guard data.count > 0 else { return nil } 77 | 78 | return data 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/UIColor+Extension.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | extension UIColor { 5 | 6 | func mix(with target: UIColor, amount: CGFloat) -> Self { 7 | var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 8 | var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 9 | 10 | self.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) 11 | target.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) 12 | 13 | return Self( 14 | red: r1 * (1.0 - amount) + r2 * amount, 15 | green: g1 * (1.0 - amount) + g2 * amount, 16 | blue: b1 * (1.0 - amount) + b2 * amount, 17 | alpha: a1 * (1.0 - amount) + a2 * amount 18 | ) 19 | } 20 | 21 | func lighter(by amount: CGFloat = 0.2) -> Self { mix(with: .white, amount: amount) } 22 | func darker(by amount: CGFloat = 0.2) -> Self { mix(with: .black, amount: amount) } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/UIDevice+Extension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIDevice { 4 | var isIpad: Bool { 5 | self.userInterfaceIdiom == .pad 6 | } 7 | 8 | var musixmatchAppId: String { 9 | UIDevice.current.isIpad 10 | ? "mac-ios-ipad-v1.0" 11 | : "mac-ios-v2.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/UIView+Extension.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIView { 4 | func hasParent(matching regex: String) -> Bool { 5 | guard let parent = self.superview else { 6 | return false 7 | } 8 | 9 | let parentClassName = NSStringFromClass(type(of: parent)) 10 | if parentClassName ~= regex { 11 | return true 12 | } else { 13 | return parent.hasParent(matching: regex) 14 | } 15 | } 16 | 17 | func subviews(matching regex: String) -> [UIView] { 18 | var matchingSubviews = [UIView]() 19 | var stack = [self] 20 | 21 | while !stack.isEmpty { 22 | let currentView = stack.removeLast() 23 | 24 | for subview in currentView.subviews { 25 | if NSStringFromClass(type(of: subview)) ~= regex { 26 | matchingSubviews.append(subview) 27 | } 28 | stack.append(subview) 29 | } 30 | } 31 | 32 | return matchingSubviews 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/URL+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URL { 4 | var isLyrics: Bool { 5 | self.path.contains("color-lyrics/v2") 6 | } 7 | 8 | var isOpenSpotifySafariExtension: Bool { 9 | self.host == "eevee" 10 | } 11 | 12 | var isCustomize: Bool { 13 | self.path.contains("v1/customize") 14 | } 15 | 16 | var isBootstrap: Bool { 17 | self.path.contains("v1/bootstrap") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Extensions/UserDefaults+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension UserDefaults { 4 | private static let defaults = UserDefaults.standard 5 | 6 | private static let lyricsSourceKey = "lyricsSource" 7 | private static let musixmatchTokenKey = "musixmatchToken" 8 | private static let geniusFallbackKey = "geniusFallback" 9 | private static let fallbackReasonsKey = "fallbackReasons" 10 | private static let darkPopUpsKey = "darkPopUps" 11 | private static let patchTypeKey = "patchType" 12 | private static let overwriteConfigurationKey = "overwriteConfiguration" 13 | private static let lyricsColorsKey = "lyricsColors" 14 | private static let lyricsOptionsKey = "lyricsOptions" 15 | private static let hasShownCommonIssuesTipKey = "hasShownCommonIssuesTip" 16 | 17 | static var lyricsSource: LyricsSource { 18 | get { 19 | if let rawValue = defaults.object(forKey: lyricsSourceKey) as? Int { 20 | return LyricsSource(rawValue: rawValue)! 21 | } 22 | 23 | return LyricsSource.defaultSource 24 | } 25 | set (newSource) { 26 | defaults.set(newSource.rawValue, forKey: lyricsSourceKey) 27 | } 28 | } 29 | 30 | static var musixmatchToken: String { 31 | get { 32 | defaults.string(forKey: musixmatchTokenKey) ?? "" 33 | } 34 | set (token) { 35 | defaults.set(token, forKey: musixmatchTokenKey) 36 | } 37 | } 38 | 39 | static var geniusFallback: Bool { 40 | get { 41 | defaults.object(forKey: geniusFallbackKey) as? Bool ?? true 42 | } 43 | set (fallback) { 44 | defaults.set(fallback, forKey: geniusFallbackKey) 45 | } 46 | } 47 | 48 | static var lyricsOptions: LyricsOptions { 49 | get { 50 | if let data = defaults.object(forKey: lyricsOptionsKey) as? Data, 51 | let lyricsOptions = try? JSONDecoder().decode(LyricsOptions.self, from: data) { 52 | return lyricsOptions 53 | } 54 | 55 | return LyricsOptions( 56 | romanization: false, 57 | musixmatchLanguage: Locale.current.languageCode ?? "" 58 | ) 59 | } 60 | set (lyricsOptions) { 61 | defaults.set(try! JSONEncoder().encode(lyricsOptions), forKey: lyricsOptionsKey) 62 | } 63 | } 64 | 65 | static var fallbackReasons: Bool { 66 | get { 67 | defaults.object(forKey: fallbackReasonsKey) as? Bool ?? true 68 | } 69 | set (reasons) { 70 | defaults.set(reasons, forKey: fallbackReasonsKey) 71 | } 72 | } 73 | 74 | static var darkPopUps: Bool { 75 | get { 76 | defaults.object(forKey: darkPopUpsKey) as? Bool ?? true 77 | } 78 | set (darkPopUps) { 79 | defaults.set(darkPopUps, forKey: darkPopUpsKey) 80 | } 81 | } 82 | 83 | static var patchType: PatchType { 84 | get { 85 | if let rawValue = defaults.object(forKey: patchTypeKey) as? Int { 86 | return PatchType(rawValue: rawValue) ?? .requests 87 | } 88 | 89 | return .notSet 90 | } 91 | set (patchType) { 92 | defaults.set(patchType.rawValue, forKey: patchTypeKey) 93 | } 94 | } 95 | 96 | static var overwriteConfiguration: Bool { 97 | get { 98 | defaults.bool(forKey: overwriteConfigurationKey) 99 | } 100 | set (overwriteConfiguration) { 101 | defaults.set(overwriteConfiguration, forKey: overwriteConfigurationKey) 102 | } 103 | } 104 | 105 | static var hasShownCommonIssuesTip: Bool { 106 | get { 107 | defaults.bool(forKey: hasShownCommonIssuesTipKey) 108 | } 109 | set (hasShownCommonIssuesTip) { 110 | defaults.set(hasShownCommonIssuesTip, forKey: hasShownCommonIssuesTipKey) 111 | } 112 | } 113 | 114 | static var lyricsColors: LyricsColorsSettings { 115 | get { 116 | if let data = defaults.object(forKey: lyricsColorsKey) as? Data { 117 | return try! JSONDecoder().decode(LyricsColorsSettings.self, from: data) 118 | } 119 | 120 | return LyricsColorsSettings( 121 | displayOriginalColors: true, 122 | useStaticColor: false, 123 | staticColor: "", 124 | normalizationFactor: 0.5 125 | ) 126 | } 127 | set (lyricsColors) { 128 | defaults.set(try! JSONEncoder().encode(lyricsColors), forKey: lyricsColorsKey) 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTCoreProductState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc protocol SPTCoreProductState { 4 | func stringForKey(_ key: String) -> String 5 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTDisclosureAccessoryView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | @objc protocol SPTDisclosureAccessoryView { 5 | static func disclosureAccessoryView() -> UIView 6 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTEncoreAttributedString.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc protocol SPTEncoreAttributedString { 4 | func initWithString(_ string: String, typeStyle: SPTEncoreTypeStyle, attributes: SPTEncoreAttributes) -> SPTEncoreAttributedString 5 | func text() -> String 6 | 7 | @available(iOS 15.0, *) 8 | func typeStyle() -> SPTEncoreTypeStyle 9 | 10 | @available(iOS 15.0, *) 11 | func attributes() -> SPTEncoreAttributes 12 | } 13 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTEncoreAttributes.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @objc protocol SPTEncoreAttributes { 4 | func `init`(_: (SPTEncoreAttributes) -> Void) -> SPTEncoreAttributes 5 | func foregroundColor() -> UIColor 6 | func setForegroundColor(_ color: UIColor) 7 | } 8 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTEncoreLabel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc protocol SPTEncoreLabel { 4 | func text() -> NSArray 5 | func setNumberOfLines(_ number: Int) 6 | func setText(_ text: NSArray) 7 | } 8 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTEncorePopUpDialog.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @objc protocol SPTEncorePopUpDialog { 4 | func `init`() -> SPTEncorePopUpDialog 5 | func uiView() -> UIView 6 | func update(_ popUpModel: SPTEncorePopUpDialogModel) 7 | func setEventHandler(_ handler: @escaping (_ state: ClickState) -> Void) 8 | } 9 | 10 | @objc enum ClickState: Int { 11 | case primary 12 | case secondary 13 | } 14 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTEncorePopUpDialogModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc protocol SPTEncorePopUpDialogModel { 4 | func initWithTitle( 5 | _ title: String, 6 | description: String, 7 | image: Any?, 8 | primaryButtonTitle: String, 9 | secondaryButtonTitle: String? 10 | ) -> SPTEncorePopUpDialogModel 11 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTEncorePopUpPresenter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc protocol SPTEncorePopUpPresenter { 4 | static func shared() -> SPTEncorePopUpPresenter 5 | func presentPopUp(_ popUp: SPTEncorePopUpDialog) 6 | func dismissPopupWithAnimate(_ animate: Bool, clearQueue: Bool, completion: Any?) 7 | } 8 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTEncoreTypeStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc protocol SPTEncoreTypeStyle { 4 | static func bodyMediumBold() -> SPTEncoreTypeStyle 5 | } 6 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTNowPlayingMetadataBackgroundViewModel.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @objc protocol SPTNowPlayingMetadataBackgroundViewModel { 4 | func color() -> UIColor 5 | } 6 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTPlayerTrack.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc protocol SPTPlayerTrack { 4 | func setMetadata(_ metadata: [String:String]) 5 | func metadata() -> [String:String] 6 | func extractedColorHex() -> String? 7 | func trackTitle() -> String 8 | func artistTitle() -> String 9 | func URI() -> SPTURL 10 | } 11 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTSettingsTableViewCell.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @objc protocol SPTSettingsTableViewCell { 4 | func initWithStyle(_ style: Int, reuseIdentifier: String) -> SPTSettingsTableViewCell 5 | } -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Models/Headers/SPTURL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // it's NSURL actually, just with Spotify extensions 4 | @objc protocol SPTURL { 5 | func spt_trackIdentifier() -> String 6 | } 7 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/OpenSpotify.x.swift: -------------------------------------------------------------------------------- 1 | import Orion 2 | import UIKit 3 | 4 | class UIOpenURLContextHook: ClassHook { 5 | func URL() -> URL { 6 | let url = orig.URL() 7 | 8 | if url.isOpenSpotifySafariExtension { 9 | return Foundation.URL(string: "https:/\(url.path)")! 10 | } 11 | 12 | return url 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/DynamicPremium+ModifyBootstrap.x.swift: -------------------------------------------------------------------------------- 1 | import Orion 2 | 3 | private func showHavePremiumPopUp() { 4 | PopUpHelper.showPopUp( 5 | delayed: true, 6 | message: "have_premium_popup".localized, 7 | buttonText: "OK".uiKitLocalized 8 | ) 9 | } 10 | 11 | class SpotifySessionDelegateBootstrapHook: ClassHook, SpotifySessionDelegate { 12 | static var targetName: String { 13 | EeveeSpotify.isOldSpotifyVersion 14 | ? "SPTCoreURLSessionDataDelegate" 15 | : "SPTDataLoaderService" 16 | } 17 | 18 | func URLSession( 19 | _ session: URLSession, 20 | dataTask task: URLSessionDataTask, 21 | didReceiveResponse response: HTTPURLResponse, 22 | completionHandler handler: @escaping (URLSession.ResponseDisposition) -> Void 23 | ) { 24 | orig.URLSession(session, dataTask: task, didReceiveResponse: response, completionHandler: handler) 25 | } 26 | 27 | func URLSession( 28 | _ session: URLSession, 29 | dataTask task: URLSessionDataTask, 30 | didReceiveData data: Data 31 | ) { 32 | guard 33 | let request = task.currentRequest, 34 | let url = request.url 35 | else { 36 | return 37 | } 38 | 39 | if url.isBootstrap { 40 | URLSessionHelper.shared.setOrAppend(data, for: url) 41 | return 42 | } 43 | 44 | orig.URLSession(session, dataTask: task, didReceiveData: data) 45 | } 46 | 47 | func URLSession( 48 | _ session: URLSession, 49 | task: URLSessionDataTask, 50 | didCompleteWithError error: Error? 51 | ) { 52 | guard 53 | let request = task.currentRequest, 54 | let url = request.url 55 | else { 56 | return 57 | } 58 | 59 | if error == nil && url.isBootstrap { 60 | let buffer = URLSessionHelper.shared.obtainData(for: url)! 61 | 62 | do { 63 | var bootstrapMessage = try BootstrapMessage(serializedBytes: buffer) 64 | 65 | if UserDefaults.patchType == .notSet { 66 | if bootstrapMessage.attributes["type"]?.stringValue == "premium" { 67 | UserDefaults.patchType = .disabled 68 | showHavePremiumPopUp() 69 | } 70 | else { 71 | UserDefaults.patchType = .requests 72 | PremiumPatchingGroup().activate() 73 | } 74 | 75 | NSLog("[EeveeSpotify] Fetched bootstrap, \(UserDefaults.patchType) was set") 76 | } 77 | 78 | if UserDefaults.patchType == .requests { 79 | modifyRemoteConfiguration(&bootstrapMessage.ucsResponse) 80 | 81 | orig.URLSession( 82 | session, 83 | dataTask: task, 84 | didReceiveData: try bootstrapMessage.serializedBytes() 85 | ) 86 | 87 | NSLog("[EeveeSpotify] Modified bootstrap data") 88 | } 89 | else { 90 | orig.URLSession(session, dataTask: task, didReceiveData: buffer) 91 | } 92 | 93 | orig.URLSession(session, task: task, didCompleteWithError: nil) 94 | return 95 | } 96 | catch { 97 | NSLog("[EeveeSpotify] Unable to modify bootstrap data: \(error)") 98 | } 99 | } 100 | 101 | orig.URLSession(session, task: task, didCompleteWithError: error) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/DynamicPremium+ModifyingFunctions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | func modifyRemoteConfiguration(_ configuration: inout UcsResponse) { 4 | if UserDefaults.overwriteConfiguration { 5 | configuration.resolve.configuration = try! BundleHelper.shared.resolveConfiguration() 6 | } 7 | 8 | modifyAttributes(&configuration.attributes.accountAttributes) 9 | } 10 | 11 | func modifyAttributes(_ attributes: inout [String: AccountAttribute]) { 12 | attributes["type"] = AccountAttribute.with { 13 | $0.stringValue = "premium" 14 | } 15 | attributes["player-license"] = AccountAttribute.with { 16 | $0.stringValue = "premium" 17 | } 18 | attributes["financial-product"] = AccountAttribute.with { 19 | $0.stringValue = "pr:premium,tc:0" 20 | } 21 | attributes["name"] = AccountAttribute.with { 22 | $0.stringValue = "Spotify Premium" 23 | } 24 | attributes["payments-initial-campaign"] = AccountAttribute.with { 25 | $0.stringValue = "default" 26 | } 27 | 28 | // 29 | 30 | attributes["unrestricted"] = AccountAttribute.with { 31 | $0.boolValue = true 32 | } 33 | attributes["catalogue"] = AccountAttribute.with { 34 | $0.stringValue = "premium" 35 | } 36 | attributes["streaming-rules"] = AccountAttribute.with { 37 | $0.stringValue = "" 38 | } 39 | attributes["pause-after"] = AccountAttribute.with { 40 | $0.longValue = 0 41 | } 42 | attributes["on-demand"] = AccountAttribute.with { 43 | $0.boolValue = true 44 | } 45 | 46 | // 47 | 48 | attributes["ads"] = AccountAttribute.with { 49 | $0.boolValue = false 50 | } 51 | 52 | attributes.removeValue(forKey: "ad-use-adlogic") 53 | attributes.removeValue(forKey: "ad-catalogues") 54 | 55 | // 56 | 57 | attributes["shuffle-eligible"] = AccountAttribute.with { 58 | $0.boolValue = true 59 | } 60 | attributes["high-bitrate"] = AccountAttribute.with { 61 | $0.boolValue = true 62 | } 63 | attributes["offline"] = AccountAttribute.with { 64 | $0.boolValue = true 65 | } 66 | attributes["nft-disabled"] = AccountAttribute.with { 67 | $0.stringValue = "1" 68 | } 69 | attributes["can_use_superbird"] = AccountAttribute.with { 70 | $0.boolValue = true 71 | } 72 | attributes["social-session"] = AccountAttribute.with { 73 | $0.boolValue = true 74 | } 75 | attributes["social-session-free-tier"] = AccountAttribute.with { 76 | $0.boolValue = false 77 | } 78 | 79 | // 80 | 81 | attributes["com.spotify.madprops.delivered.by.ucs"] = AccountAttribute.with { 82 | $0.boolValue = true 83 | } 84 | attributes["com.spotify.madprops.use.ucs.product.state"] = AccountAttribute.with { 85 | $0.boolValue = true 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/Helpers/BundleHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import libroot 4 | 5 | class BundleHelper { 6 | private let bundleName = "EeveeSpotify" 7 | 8 | private let bundle: Bundle 9 | private let enBundle: Bundle 10 | 11 | static let shared = BundleHelper() 12 | 13 | private init() { 14 | self.bundle = Bundle( 15 | path: Bundle.main.path( 16 | forResource: bundleName, 17 | ofType: "bundle" 18 | ) 19 | ?? jbRootPath("/Library/Application Support/\(bundleName).bundle") 20 | )! 21 | 22 | enBundle = Bundle(path: bundle.path(forResource: "en", ofType: "lproj")!)! 23 | } 24 | 25 | func uiImage(_ name: String) -> UIImage { 26 | return UIImage( 27 | contentsOfFile: self.bundle.path( 28 | forResource: name, 29 | ofType: "png" 30 | )! 31 | )! 32 | } 33 | 34 | func localizedString(_ key: String) -> String { 35 | let value = bundle.localizedString(forKey: key, value: "No translation", table: nil) 36 | 37 | if value != "No translation" { 38 | return value 39 | } 40 | 41 | return enBundle.localizedString(forKey: key, value: nil, table: nil) 42 | } 43 | 44 | func resolveConfiguration() throws -> ResolveConfiguration { 45 | return try ResolveConfiguration( 46 | serializedBytes: try Data( 47 | contentsOf: self.bundle.url( 48 | forResource: "resolveconfiguration", 49 | withExtension: "bnk" 50 | )! 51 | ) 52 | ) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/Helpers/OfflineHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class OfflineHelper { 4 | static private let applicationSupportDirectory = FileManager.default.urls( 5 | for: .applicationSupportDirectory, in: .userDomainMask 6 | ) 7 | .first! 8 | 9 | static private let cachesDirectory = FileManager.default.urls( 10 | for: .cachesDirectory, in: .userDomainMask 11 | ).first! 12 | 13 | // 14 | 15 | static private let persistentCachePath = applicationSupportDirectory 16 | .appendingPathComponent("PersistentCache") 17 | 18 | static private let remoteConfigPath = applicationSupportDirectory 19 | .appendingPathComponent("remote-config") 20 | 21 | // 22 | 23 | static private func resetPersistentCache() throws { 24 | try FileManager.default.removeItem(at: self.persistentCachePath) 25 | } 26 | 27 | static private func resetRemoteConfig() throws { 28 | try FileManager.default.removeItem(at: self.remoteConfigPath) 29 | } 30 | 31 | static private func resetCaches() throws { 32 | try FileManager.default.removeItem(at: self.cachesDirectory) 33 | } 34 | 35 | // 36 | 37 | static func resetData(clearCaches: Bool = false) { 38 | try? resetPersistentCache() 39 | try? resetRemoteConfig() 40 | 41 | if clearCaches { 42 | try? resetCaches() 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/LikedSongsEnabler.x.swift: -------------------------------------------------------------------------------- 1 | import Orion 2 | 3 | private let likedTracksRow: [String: Any] = [ 4 | "id": "artist-entity-view-liked-tracks-row", 5 | "text": [ "title": "liked_songs".localized ] 6 | ] 7 | 8 | class HUBViewModelBuilderImplementationHook: ClassHook { 9 | typealias Group = PremiumPatchingGroup 10 | static let targetName: String = "HUBViewModelBuilderImplementation" 11 | 12 | func addJSONDictionary(_ dictionary: NSDictionary?) { 13 | guard let dictionary = dictionary else { 14 | return 15 | } 16 | 17 | let mutableDictionary = NSMutableDictionary(dictionary: dictionary) 18 | 19 | let id = dictionary["id"] as? String 20 | 21 | if id == "artist-entity-view" { 22 | guard var components = dictionary["body"] as? [[String: Any]] else { 23 | orig.addJSONDictionary(dictionary) 24 | return 25 | } 26 | 27 | if let index = components.firstIndex( 28 | where: { $0["id"] as? String == "artist-entity-view-artist-tab-container" } 29 | ) { 30 | if var childrenArray = components[index]["children"] as? [[String: Any]], 31 | var innerChildrenArray = childrenArray[0]["children"] as? [Any] { 32 | 33 | innerChildrenArray.insert(likedTracksRow, at: 0) 34 | 35 | childrenArray[0]["children"] = innerChildrenArray 36 | components[index]["children"] = childrenArray 37 | } 38 | } 39 | else if let index = components.firstIndex( 40 | where: { $0["id"] as? String == "artist-entity-view-top-tracks-combined" } 41 | ) { 42 | components.insert(likedTracksRow, at: index) 43 | } 44 | 45 | mutableDictionary["body"] = components 46 | } 47 | 48 | orig.addJSONDictionary(mutableDictionary) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/Models/Extensions/BootstrapMessage+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension BootstrapMessage { 4 | 5 | var ucsResponse: UcsResponse { 6 | get { 7 | self.wrapper.oneMoreWrapper.message.response 8 | } 9 | set(ucsResponse) { 10 | self.wrapper.oneMoreWrapper.message.response = ucsResponse 11 | } 12 | } 13 | 14 | var attributes: Dictionary { 15 | get { 16 | self.ucsResponse.attributes.accountAttributes 17 | } 18 | set(attributes) { 19 | self.ucsResponse.attributes.accountAttributes = attributes 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/Models/Extensions/UcsResponse+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension UcsResponse { 4 | 5 | var assignedValues: [AssignedValue] { 6 | get { 7 | self.resolve.configuration.assignedValues 8 | } 9 | set(assignedValues) { 10 | self.resolve.configuration.assignedValues = assignedValues 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/Models/PatchType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum PatchType: Int { 4 | case notSet 5 | case disabled 6 | case requests 7 | 8 | var isPatching: Bool { self == .requests } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/Models/SpotifySessionDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol SpotifySessionDelegate { 4 | func URLSession( 5 | _ session: URLSession, 6 | task: URLSessionDataTask, 7 | didCompleteWithError error: Error? 8 | ) 9 | 10 | func URLSession( 11 | _ session: URLSession, 12 | dataTask task: URLSessionDataTask, 13 | didReceiveData data: Data 14 | ) 15 | 16 | func URLSession( 17 | _ session: URLSession, 18 | dataTask task: URLSessionDataTask, 19 | didReceiveResponse response: HTTPURLResponse, 20 | completionHandler handler: @escaping (URLSession.ResponseDisposition) -> Void 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/ServerSidedReminder.x.swift: -------------------------------------------------------------------------------- 1 | import Orion 2 | import UIKit 3 | 4 | class StreamQualitySettingsSectionHook: ClassHook { 5 | typealias Group = PremiumPatchingGroup 6 | static let targetName = "StreamQualitySettingsSection" 7 | 8 | func shouldResetSelection() -> Bool { 9 | PopUpHelper.showPopUp( 10 | message: "high_audio_quality_popup".localized, 11 | buttonText: "OK".uiKitLocalized 12 | ) 13 | 14 | return true 15 | } 16 | } 17 | 18 | class ContentOffliningUIHelperImplementationHook: ClassHook { 19 | typealias Group = PremiumPatchingGroup 20 | static let targetName = "Offline_ContentOffliningUIImpl.ContentOffliningUIHelperImplementation" 21 | 22 | func downloadToggledWithCurrentAvailability( 23 | _ availability: Int, 24 | addAction: NSObject, 25 | removeAction: NSObject, 26 | pageIdentifier: String, 27 | pageURI: URL 28 | ) -> String? { 29 | let isPlaylist = [ 30 | "free-tier-playlist", 31 | "playlist/ondemand" 32 | ].contains(pageIdentifier) 33 | 34 | PopUpHelper.showPopUp( 35 | message: "playlist_downloading_popup".localized, 36 | buttonText: "OK".uiKitLocalized, 37 | secondButtonText: isPlaylist 38 | ? "download_local_playlist".localized 39 | : nil, 40 | onSecondaryClick: isPlaylist 41 | ? { 42 | _ = self.orig.downloadToggledWithCurrentAvailability( 43 | availability, 44 | addAction: addAction, 45 | removeAction: removeAction, 46 | pageIdentifier: pageIdentifier, 47 | pageURI: pageURI 48 | ) 49 | } 50 | : nil 51 | ) 52 | 53 | return nil 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Premium/TrackRowsEnabler.x.swift: -------------------------------------------------------------------------------- 1 | import Orion 2 | 3 | class SPTFreeTierArtistHubRemoteURLResolverHook: ClassHook { 4 | typealias Group = PremiumPatchingGroup 5 | static let targetName = "SPTFreeTierArtistHubRemoteURLResolver" 6 | 7 | func initWithViewURI( 8 | _ uri: NSURL, 9 | onDemandSet: Any, 10 | onDemandTrialService: Any, 11 | trackRowsEnabled: Bool, 12 | productState: SPTCoreProductState 13 | ) -> Target { 14 | return orig.initWithViewURI( 15 | uri, 16 | onDemandSet: onDemandSet, 17 | onDemandTrialService: onDemandTrialService, 18 | trackRowsEnabled: true, 19 | productState: productState 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/EeveeSettings.x.swift: -------------------------------------------------------------------------------- 1 | import Orion 2 | import SwiftUI 3 | import UIKit 4 | 5 | class ProfileSettingsSectionHook: ClassHook { 6 | static let targetName = "ProfileSettingsSection" 7 | 8 | func numberOfRows() -> Int { 9 | return 2 10 | } 11 | 12 | func didSelectRow(_ row: Int) { 13 | if row == 1 { 14 | let rootSettingsController = WindowHelper.shared.findFirstViewController( 15 | "RootSettingsViewController" 16 | )! 17 | 18 | let navigationController = rootSettingsController.navigationController! 19 | 20 | let eeveeSettingsController = EeveeSettingsViewController( 21 | rootSettingsController.view.bounds, 22 | settingsView: AnyView(EeveeSettingsView(navigationController: navigationController)), 23 | navigationTitle: "EeveeSpotify" 24 | ) 25 | 26 | // 27 | 28 | let button = UIButton() 29 | 30 | button.setImage( 31 | BundleHelper.shared.uiImage("github").withRenderingMode(.alwaysOriginal), 32 | for: .normal 33 | ) 34 | 35 | button.addTarget( 36 | eeveeSettingsController, 37 | action: #selector(eeveeSettingsController.openRepositoryUrl(_:)), 38 | for: .touchUpInside 39 | ) 40 | 41 | // 42 | 43 | let menuBarItem = UIBarButtonItem(customView: button) 44 | 45 | menuBarItem.customView?.heightAnchor.constraint(equalToConstant: 22).isActive = true 46 | menuBarItem.customView?.widthAnchor.constraint(equalToConstant: 22).isActive = true 47 | 48 | eeveeSettingsController.navigationItem.rightBarButtonItem = menuBarItem 49 | 50 | navigationController.pushViewController( 51 | eeveeSettingsController, 52 | animated: true 53 | ) 54 | 55 | return 56 | } 57 | 58 | orig.didSelectRow(row) 59 | } 60 | 61 | func cellForRow(_ row: Int) -> UITableViewCell { 62 | if row == 1 { 63 | let settingsTableCell = Dynamic.SPTSettingsTableViewCell 64 | .alloc(interface: SPTSettingsTableViewCell.self) 65 | .initWithStyle(3, reuseIdentifier: "EeveeSpotify") 66 | 67 | let tableViewCell = Dynamic.convert(settingsTableCell, to: UITableViewCell.self) 68 | 69 | tableViewCell.accessoryView = type( 70 | of: Dynamic.SPTDisclosureAccessoryView 71 | .alloc(interface: SPTDisclosureAccessoryView.self) 72 | ) 73 | .disclosureAccessoryView() 74 | 75 | tableViewCell.textLabel?.text = "EeveeSpotify" 76 | return tableViewCell 77 | } 78 | 79 | return orig.cellForRow(row) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Helpers/AnonymousTokenHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | struct AnonymousTokenHelper { 5 | private static let apiUrl = "https://apic.musixmatch.com" 6 | 7 | static func requestAnonymousMusixmatchToken() async throws -> String { 8 | let url = URL(string: "\(apiUrl)/ws/1.1/token.get?app_id=\(await UIDevice.current.musixmatchAppId)")! 9 | let (data, _) = try await URLSession.shared.data(from: url) 10 | 11 | guard 12 | let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], 13 | let message = json["message"] as? [String: Any], 14 | let body = message["body"] as? [String: Any], 15 | let userToken = body["user_token"] as? String 16 | else { 17 | throw AnonymousTokenError.invalidResponse 18 | } 19 | 20 | return userToken 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Helpers/GitHubHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GitHubHelper { 4 | private let apiUrl = "https://api.github.com" 5 | private let decoder = JSONDecoder() 6 | 7 | static let shared = GitHubHelper() 8 | 9 | init() { 10 | decoder.keyDecodingStrategy = .convertFromSnakeCase 11 | } 12 | 13 | private func perform(_ path: String) async throws -> Data { 14 | let url = URL(string: "\(apiUrl)\(path)")! 15 | let (data, _) = try await URLSession.shared.data(from: url) 16 | 17 | return data 18 | } 19 | 20 | func getLatestRelease() async throws -> GitHubRelease { 21 | let data = try await perform("/repos/whoeevee/EeveeSpotify/releases/latest") 22 | return try decoder.decode(GitHubRelease.self, from: data) 23 | } 24 | 25 | func getUser(_ username: String) async throws -> GitHubUser { 26 | let data = try await perform("/users/\(username)") 27 | return try decoder.decode(GitHubUser.self, from: data) 28 | } 29 | 30 | func getContributors() async throws -> [GitHubUser] { 31 | let data = try await perform("/repos/whoeevee/EeveeSpotify/contributors") 32 | return try decoder.decode([GitHubUser].self, from: data) 33 | } 34 | 35 | func getEeveeContributorSections() async throws -> [EeveeContributorSection] { 36 | let (data, _) = try await URLSession.shared.data( 37 | from: URL( 38 | string: "https://raw.githubusercontent.com/whoeevee/EeveeSpotify/swift/contributors.json" 39 | )! 40 | ) 41 | return try decoder.decode([EeveeContributorSection].self, from: data) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Models/AnonymousTokenError.swift: -------------------------------------------------------------------------------- 1 | enum AnonymousTokenError: Swift.Error { 2 | case invalidResponse 3 | } 4 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Models/EeveeContributor.swift: -------------------------------------------------------------------------------- 1 | struct EeveeContributor: Decodable, Equatable { 2 | var username: String 3 | var roles: [String] 4 | } 5 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Models/EeveeContributorSection.swift: -------------------------------------------------------------------------------- 1 | struct EeveeContributorSection: Decodable, Equatable { 2 | var title: String 3 | var shuffled: Bool 4 | var contributors: [EeveeContributor] 5 | } 6 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Models/GitHubRelease.swift: -------------------------------------------------------------------------------- 1 | struct GitHubRelease: Decodable { 2 | var tagName: String 3 | } 4 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Models/GitHubUser.swift: -------------------------------------------------------------------------------- 1 | struct GitHubUser: Decodable, Equatable { 2 | var avatarUrl: String 3 | var htmlUrl: String 4 | var login: String 5 | } 6 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/ViewControllers/EeveeSettingsViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | class EeveeSettingsViewController: SPTPageViewController { 5 | 6 | let frame: CGRect 7 | let settingsView: AnyView 8 | 9 | init(_ frame: CGRect, settingsView: AnyView, navigationTitle: String) { 10 | self.frame = frame 11 | self.settingsView = settingsView 12 | super.init(nibName: nil, bundle: nil) 13 | 14 | title = navigationTitle 15 | } 16 | 17 | required init?(coder: NSCoder) { 18 | fatalError("init(coder:) has not been implemented") 19 | } 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | 24 | let hostingController = UIHostingController(rootView: settingsView) 25 | hostingController.view.frame = frame 26 | 27 | view.addSubview(hostingController.view) 28 | addChild(hostingController) 29 | hostingController.didMove(toParent: self) 30 | } 31 | 32 | @objc func openRepositoryUrl(_ sender: UIButton) { 33 | UIApplication.shared.open(URL(string: "https://github.com/whoeevee/EeveeSpotify")!) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/ViewControllers/SPTPageViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class SPTPageViewController: UIViewController { 4 | 5 | override func conforms(to aProtocol: Protocol) -> Bool { 6 | 7 | if NSStringFromProtocol(aProtocol) ~= "SPTPageController" { 8 | return true 9 | } 10 | 11 | return super.conforms(to: aProtocol) 12 | } 13 | 14 | @objc func spt_pageIdentifier() -> String? { 15 | return "EeveeSpotify" 16 | } 17 | 18 | @objc func spt_pageURI() -> NSURL? { 19 | return NSURL(string: "") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/ViewModels/ImageViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | class ImageViewModel: ObservableObject { 5 | @Published var image: UIImage? 6 | 7 | private var imageCache: NSCache? 8 | 9 | init(urlString: String?) { 10 | loadImage(urlString: urlString) 11 | } 12 | 13 | private func loadImage(urlString: String?) { 14 | guard let urlString = urlString else { return } 15 | 16 | if let imageFromCache = getImageFromCache(from: urlString) { 17 | self.image = imageFromCache 18 | return 19 | } 20 | 21 | loadImageFromURL(urlString: urlString) 22 | } 23 | 24 | private func loadImageFromURL(urlString: String) { 25 | guard let url = URL(string: urlString) else { return } 26 | 27 | URLSession.shared.dataTask(with: url) { data, response, error in 28 | guard error == nil else { 29 | return 30 | } 31 | 32 | guard let data = data else { 33 | return 34 | } 35 | 36 | DispatchQueue.main.async { [weak self] in 37 | guard let loadedImage = UIImage(data: data) else { return } 38 | self?.image = loadedImage 39 | self?.setImageCache(image: loadedImage, key: urlString) 40 | } 41 | }.resume() 42 | } 43 | 44 | private func setImageCache(image: UIImage, key: String) { 45 | imageCache?.setObject(image, forKey: key as NSString) 46 | } 47 | 48 | private func getImageFromCache(from key: String) -> UIImage? { 49 | return imageCache?.object(forKey: key as NSString) as? UIImage 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/ViewModifiers/ListRowSeparatorHidden.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ListRowSeparatorHidden: ViewModifier { 4 | func body(content: Content) -> some View { 5 | if #available(iOS 15.0, *) { 6 | content 7 | .listRowSeparator(.hidden) 8 | } 9 | else { 10 | content 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Contributors/EeveeContributorView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct EeveeContributorView: View { 4 | var contributor: EeveeContributor 5 | var githubUser: GitHubUser 6 | 7 | var body: some View { 8 | VStack { 9 | Link(destination: URL(string: githubUser.htmlUrl)!) { 10 | HStack(spacing: 10) { 11 | ImageView(urlString: githubUser.avatarUrl) 12 | .frame(width: 48, height: 48) 13 | .clipShape(Circle()) 14 | 15 | VStack(alignment: .leading, spacing: 0) { 16 | Text(contributor.username) 17 | .foregroundColor(.white) 18 | .font(.headline) 19 | 20 | ForEach(contributor.roles, id: \.self) { role in 21 | Text(role) 22 | } 23 | .foregroundColor(.gray) 24 | } 25 | 26 | Spacer() 27 | 28 | ChevronRightView() 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Contributors/EeveeContributorsSheetView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct EeveeContributorsSheetView: View { 4 | @State private var users: [GitHubUser] = [] 5 | @State private var sections: [EeveeContributorSection] = [] 6 | 7 | var body: some View { 8 | NavigationView { 9 | VStack { 10 | if users.isEmpty && sections.isEmpty { 11 | ProgressView("Loading".uiKitLocalized) 12 | } 13 | else { 14 | List { 15 | ForEach(sections, id: \.title) { section in 16 | Section { 17 | ForEach( 18 | section.shuffled 19 | ? section.contributors.shuffled() 20 | : section.contributors, 21 | id: \.username 22 | ) { contributor in 23 | if let user = users.first(where: { $0.login == contributor.username }) { 24 | EeveeContributorView(contributor: contributor, githubUser: user) 25 | } 26 | } 27 | } header: { 28 | Text(section.title) 29 | } 30 | } 31 | } 32 | } 33 | } 34 | .navigationTitle("contributors".localized) 35 | .navigationBarTitleDisplayMode(.inline) 36 | 37 | .toolbar { 38 | Button { 39 | WindowHelper.shared.dismissCurrentViewController() 40 | } label: { 41 | Text("Done".uiKitLocalized) 42 | .font(.headline) 43 | } 44 | } 45 | 46 | .animation(.default, value: users) 47 | .animation(.default, value: sections) 48 | 49 | .onAppear { 50 | Task { 51 | users = try await GitHubHelper.shared.getContributors() 52 | sections = try await GitHubHelper.shared.getEeveeContributorSections() 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/EeveeSettingsVersionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct EeveeSettingsVersionView: View { 4 | @State private var latestVersion: String? 5 | @State private var isPresentingContributorsSheet = false 6 | 7 | private func loadVersion() async throws { 8 | let release = try await GitHubHelper.shared.getLatestRelease() 9 | latestVersion = String(release.tagName.dropFirst(5)) // swiftX.X 10 | } 11 | 12 | private var isUpdateAvailable: Bool { 13 | latestVersion != nil && latestVersion != EeveeSpotify.version 14 | } 15 | 16 | var body: some View { 17 | Section { 18 | if isUpdateAvailable { 19 | Link( 20 | "update_available".localized, 21 | destination: URL(string: "https://github.com/whoeevee/EeveeSpotify/releases")! 22 | ) 23 | } 24 | } footer: { 25 | VStack(alignment: .leading) { 26 | Text("v\(EeveeSpotify.version)") 27 | 28 | if latestVersion == nil { 29 | HStack(spacing: 10) { 30 | ProgressView() 31 | Text("checking_for_update".localized) 32 | } 33 | } 34 | else { 35 | Button("\("contributors".localized)...") { 36 | isPresentingContributorsSheet = true 37 | } 38 | .foregroundColor(.gray) 39 | .font(.subheadline.weight(.semibold)) 40 | } 41 | } 42 | } 43 | .sheet(isPresented: $isPresentingContributorsSheet) { 44 | EeveeContributorsSheetView() 45 | } 46 | 47 | .animation(.default, value: latestVersion) 48 | 49 | .onAppear { 50 | Task { 51 | try await loadVersion() 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/EeveeSettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | struct EeveeSettingsView: View { 5 | let navigationController: UINavigationController 6 | static let spotifyAccentColor = Color(hex: "#1ed760") 7 | 8 | @State private var hasShownCommonIssuesTip = UserDefaults.hasShownCommonIssuesTip 9 | @State private var isClearingData = false 10 | 11 | private func pushSettingsController(with view: any View, title: String) { 12 | let viewController = EeveeSettingsViewController( 13 | navigationController.view.frame, 14 | settingsView: AnyView(view), 15 | navigationTitle: title 16 | ) 17 | navigationController.pushViewController(viewController, animated: true) 18 | } 19 | 20 | init(navigationController: UINavigationController) { 21 | self.navigationController = navigationController 22 | UIView.appearance().tintColor = UIColor(EeveeSettingsView.spotifyAccentColor) 23 | } 24 | 25 | var body: some View { 26 | List { 27 | EeveeSettingsVersionView() 28 | 29 | if !hasShownCommonIssuesTip { 30 | CommonIssuesTipView( 31 | onDismiss: { 32 | hasShownCommonIssuesTip = true 33 | UserDefaults.hasShownCommonIssuesTip = true 34 | } 35 | ) 36 | } 37 | 38 | // 39 | 40 | Button { 41 | pushSettingsController( 42 | with: EeveePatchingSettingsView(), 43 | title: "patching".localized 44 | ) 45 | } label: { 46 | NavigationSectionView( 47 | color: .orange, 48 | title: "patching".localized, 49 | imageSystemName: "hammer.fill" 50 | ) 51 | } 52 | 53 | Button { 54 | pushSettingsController( 55 | with: EeveeLyricsSettingsView(), 56 | title: "lyrics".localized 57 | ) 58 | } label: { 59 | NavigationSectionView( 60 | color: .blue, 61 | title: "lyrics".localized, 62 | imageSystemName: "quote.bubble.fill" 63 | ) 64 | } 65 | 66 | Button { 67 | pushSettingsController( 68 | with: EeveeUISettingsView(), 69 | title: "customization".localized 70 | ) 71 | } label: { 72 | NavigationSectionView( 73 | color: Color(hex: "#64D2FF"), 74 | title: "customization".localized, 75 | imageSystemName: "paintpalette.fill" 76 | ) 77 | } 78 | 79 | // 80 | 81 | Section(footer: Text("reset_data_description".localized)) { 82 | Button { 83 | isClearingData = true 84 | 85 | DispatchQueue.global(qos: .userInitiated).async { 86 | OfflineHelper.resetData(clearCaches: true) 87 | 88 | DispatchQueue.main.async { 89 | exitApplication() 90 | } 91 | } 92 | } label: { 93 | if isClearingData { 94 | ProgressView() 95 | } 96 | else { 97 | Text("reset_data".localized) 98 | } 99 | } 100 | } 101 | } 102 | .listStyle(GroupedListStyle()) 103 | 104 | .animation(.default, value: isClearingData) 105 | .animation(.default, value: hasShownCommonIssuesTip) 106 | 107 | .onAppear { 108 | WindowHelper.shared.overrideUserInterfaceStyle(.dark) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+Extension.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension EeveeLyricsSettingsView { 4 | func getMusixmatchToken(_ input: String) -> String? { 5 | if let match = input.firstMatch("\\[UserToken\\]: ([a-f0-9]+)"), 6 | let tokenRange = Range(match.range(at: 1), in: input) { 7 | return String(input[tokenRange]) 8 | } 9 | else if input ~= "^[a-f0-9]+$" { 10 | return input 11 | } 12 | 13 | return nil 14 | } 15 | 16 | func showMusixmatchTokenAlert(_ oldSource: LyricsSource, showAnonymousTokenOption: Bool) { 17 | var message = "enter_user_token_message".localized 18 | 19 | if showAnonymousTokenOption { 20 | message.append("\n\n") 21 | message.append("request_anonymous_token_description".localized) 22 | } 23 | 24 | let alert = UIAlertController( 25 | title: "enter_user_token".localized, 26 | message: message, 27 | preferredStyle: .alert 28 | ) 29 | 30 | alert.addTextField() { textField in 31 | textField.placeholder = "---- Debug Info ---- [Device]: iPhone" 32 | } 33 | 34 | alert.addAction(UIAlertAction(title: "Cancel".uiKitLocalized, style: .cancel) { _ in 35 | lyricsSource = oldSource 36 | }) 37 | 38 | if showAnonymousTokenOption { 39 | alert.addAction(UIAlertAction(title: "request_anonymous_token".localized, style: .default) { _ in 40 | Task { 41 | defer { 42 | isRequestingMusixmatchToken.toggle() 43 | } 44 | do { 45 | isRequestingMusixmatchToken.toggle() 46 | 47 | musixmatchToken = try await AnonymousTokenHelper.requestAnonymousMusixmatchToken() 48 | UserDefaults.lyricsSource = .musixmatch 49 | } 50 | catch { 51 | showMusixmatchTokenAlert(oldSource, showAnonymousTokenOption: false) 52 | } 53 | } 54 | }) 55 | } 56 | 57 | alert.addAction(UIAlertAction(title: "OK".uiKitLocalized, style: .default) { _ in 58 | let text = alert.textFields!.first!.text! 59 | 60 | guard let token = getMusixmatchToken(text) else { 61 | lyricsSource = oldSource 62 | return 63 | } 64 | 65 | musixmatchToken = token 66 | UserDefaults.lyricsSource = .musixmatch 67 | }) 68 | 69 | WindowHelper.shared.present(alert) 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView+LyricsSourceSection.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension EeveeLyricsSettingsView { 4 | func lyricsSourceFooter() -> some View { 5 | var text = "lyrics_source_description".localized 6 | 7 | text.append("\n\n") 8 | text.append("petitlyrics_description".localized) 9 | 10 | text.append("\n\n") 11 | text.append("lyrics_additional_info".localized) 12 | 13 | return Text(text) 14 | } 15 | 16 | @ViewBuilder func LyricsSourceSection() -> some View { 17 | Section(footer: Text("restart_is_required_description".localized)) { 18 | Toggle( 19 | "do_not_replace_lyrics".localized, 20 | isOn: Binding( 21 | get: { lyricsSource == .notReplaced }, 22 | set: { lyricsSource = $0 ? .notReplaced : LyricsSource.defaultSource } 23 | ) 24 | ) 25 | } 26 | .onChange(of: lyricsSource) { [lyricsSource] newSource in 27 | if newSource == .musixmatch && musixmatchToken.isEmpty { 28 | showMusixmatchTokenAlert(lyricsSource, showAnonymousTokenOption: true) 29 | return 30 | } 31 | 32 | UserDefaults.lyricsSource = newSource 33 | } 34 | 35 | if lyricsSource.isReplacing { 36 | Section(footer: lyricsSourceFooter()) { 37 | Picker( 38 | "lyrics_source".localized, 39 | selection: $lyricsSource 40 | ) { 41 | ForEach(LyricsSource.allCases, id: \.self) { lyricsSource in 42 | Text(lyricsSource.description).tag(lyricsSource) 43 | } 44 | } 45 | 46 | if lyricsSource == .musixmatch { 47 | VStack(alignment: .leading, spacing: 5) { 48 | Text("musixmatch_user_token".localized) 49 | 50 | TextField("user_token_placeholder".localized, text: $musixmatchToken) 51 | .foregroundColor(.gray) 52 | } 53 | .frame(maxWidth: .infinity, alignment: .leading) 54 | } 55 | } 56 | .onChange(of: musixmatchToken) { input in 57 | if input.isEmpty { 58 | return 59 | } 60 | 61 | if let token = getMusixmatchToken(input) { 62 | UserDefaults.musixmatchToken = token 63 | self.musixmatchToken = token 64 | } 65 | else { 66 | self.musixmatchToken = "" 67 | } 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Sections/EeveeLyricsSettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct EeveeLyricsSettingsView: View { 4 | @State var musixmatchToken = UserDefaults.musixmatchToken 5 | @State var lyricsSource = UserDefaults.lyricsSource 6 | @State var geniusFallback = UserDefaults.geniusFallback 7 | 8 | @State private var lyricsOptions = UserDefaults.lyricsOptions 9 | 10 | @State var isRequestingMusixmatchToken = false 11 | @State private var isShowingLanguageWarning = false 12 | 13 | var body: some View { 14 | List { 15 | LyricsSourceSection() 16 | 17 | if lyricsSource != .notReplaced { 18 | if lyricsSource != .genius { 19 | Section( 20 | footer: Text("genius_fallback_description".localizeWithFormat(lyricsSource.description)) 21 | ) { 22 | Toggle( 23 | "genius_fallback".localized, 24 | isOn: $geniusFallback 25 | ) 26 | 27 | if geniusFallback { 28 | Toggle( 29 | "show_fallback_reasons".localized, 30 | isOn: Binding( 31 | get: { UserDefaults.fallbackReasons }, 32 | set: { UserDefaults.fallbackReasons = $0 } 33 | ) 34 | ) 35 | } 36 | } 37 | } 38 | 39 | // 40 | 41 | Section(footer: Text("romanized_lyrics_description".localized)) { 42 | Toggle( 43 | "romanized_lyrics".localized, 44 | isOn: $lyricsOptions.romanization 45 | ) 46 | } 47 | 48 | if lyricsSource == .musixmatch { 49 | Section { 50 | HStack { 51 | if isShowingLanguageWarning { 52 | Image(systemName: "exclamationmark.triangle") 53 | .font(.title3) 54 | .foregroundColor(.yellow) 55 | } 56 | 57 | Text("musixmatch_language".localized) 58 | 59 | Spacer() 60 | 61 | TextField("en", text: $lyricsOptions.musixmatchLanguage) 62 | .frame(maxWidth: 20) 63 | .foregroundColor(.gray) 64 | } 65 | } footer: { 66 | Text("musixmatch_language_description".localized) 67 | } 68 | } 69 | } 70 | 71 | if !UIDevice.current.isIpad { 72 | Spacer() 73 | .frame(height: 40) 74 | .listRowBackground(Color.clear) 75 | .modifier(ListRowSeparatorHidden()) 76 | } 77 | } 78 | .listStyle(GroupedListStyle()) 79 | 80 | .disabled(isRequestingMusixmatchToken) 81 | 82 | .animation(.default, value: lyricsSource) 83 | .animation(.default, value: isRequestingMusixmatchToken) 84 | .animation(.default, value: isShowingLanguageWarning) 85 | .animation(.default, value: geniusFallback) 86 | 87 | .onChange(of: geniusFallback) { geniusFallback in 88 | UserDefaults.geniusFallback = geniusFallback 89 | } 90 | 91 | .onChange(of: lyricsOptions) { lyricsOptions in 92 | let selectedLanguage = lyricsOptions.musixmatchLanguage 93 | 94 | if selectedLanguage.isEmpty || selectedLanguage ~= "^[\\w\\d]{2}$" { 95 | isShowingLanguageWarning = false 96 | 97 | MusixmatchLyricsRepository.shared.selectedLanguage = selectedLanguage 98 | UserDefaults.lyricsOptions = lyricsOptions 99 | 100 | return 101 | } 102 | 103 | isShowingLanguageWarning = true 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Sections/EeveePatchingSettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | struct EeveePatchingSettingsView: View { 5 | @State var patchType = UserDefaults.patchType 6 | @State var overwriteConfiguration = UserDefaults.overwriteConfiguration 7 | 8 | var body: some View { 9 | List { 10 | Section( 11 | footer: patchType == .disabled 12 | ? nil 13 | : Text( 14 | "patching_description" 15 | .localizeWithFormat("restart_is_required_description".localized) 16 | ) 17 | ) { 18 | Toggle( 19 | "do_not_patch_premium".localized, 20 | isOn: Binding( 21 | get: { patchType == .disabled }, 22 | set: { patchType = $0 ? .disabled : .requests } 23 | ) 24 | ) 25 | } 26 | 27 | .onChange(of: patchType) { newPatchType in 28 | UserDefaults.patchType = newPatchType 29 | OfflineHelper.resetData() 30 | } 31 | 32 | .onChange(of: overwriteConfiguration) { overwriteConfiguration in 33 | UserDefaults.overwriteConfiguration = overwriteConfiguration 34 | OfflineHelper.resetData() 35 | } 36 | 37 | if patchType == .requests { 38 | Section( 39 | footer: Text("overwrite_configuration_description".localized) 40 | ) { 41 | Toggle( 42 | "overwrite_configuration".localized, 43 | isOn: $overwriteConfiguration 44 | ) 45 | } 46 | } 47 | 48 | if !UIDevice.current.isIpad { 49 | Spacer() 50 | .frame(height: 40) 51 | .listRowBackground(Color.clear) 52 | .modifier(ListRowSeparatorHidden()) 53 | } 54 | } 55 | .listStyle(GroupedListStyle()) 56 | .animation(.default, value: patchType) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Sections/EeveeUISettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | struct EeveeUISettingsView: View { 5 | @State var lyricsColors = UserDefaults.lyricsColors 6 | 7 | var body: some View { 8 | List { 9 | if UserDefaults.lyricsSource.isReplacing { 10 | Section( 11 | header: Text("lyrics_background_color_section".localized), 12 | footer: Text("lyrics_background_color_section_description".localized) 13 | ) { 14 | Toggle( 15 | "display_original_colors".localized, 16 | isOn: $lyricsColors.displayOriginalColors 17 | ) 18 | 19 | Toggle( 20 | "use_static_color".localized, 21 | isOn: $lyricsColors.useStaticColor 22 | ) 23 | 24 | if lyricsColors.useStaticColor { 25 | ColorPicker( 26 | "static_color".localized, 27 | selection: Binding( 28 | get: { Color(hex: lyricsColors.staticColor) }, 29 | set: { lyricsColors.staticColor = $0.hexString } 30 | ), 31 | supportsOpacity: false 32 | ) 33 | } 34 | else { 35 | VStack(alignment: .leading, spacing: 5) { 36 | Text("color_normalization_factor".localized) 37 | 38 | Slider( 39 | value: $lyricsColors.normalizationFactor, 40 | in: 0.2...0.8, 41 | step: 0.1 42 | ) 43 | } 44 | } 45 | } 46 | .onChange(of: lyricsColors) { lyricsColors in 47 | UserDefaults.lyricsColors = lyricsColors 48 | } 49 | } 50 | 51 | Section { 52 | Toggle( 53 | "dark_popups".localized, 54 | isOn: Binding( 55 | get: { UserDefaults.darkPopUps }, 56 | set: { UserDefaults.darkPopUps = $0 } 57 | ) 58 | ) 59 | } 60 | 61 | if !UIDevice.current.isIpad { 62 | Spacer() 63 | .frame(height: 40) 64 | .listRowBackground(Color.clear) 65 | .modifier(ListRowSeparatorHidden()) 66 | } 67 | } 68 | 69 | .listStyle(GroupedListStyle()) 70 | .animation(.default, value: lyricsColors) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Shared/ChevronRightView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ChevronRightView: View { 4 | var body: some View { 5 | Image(systemName: "chevron.right") 6 | .font(.subheadline.weight(.semibold)) 7 | .foregroundColor(Color(UIColor.systemGray2)) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Shared/CommonIssuesTipView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CommonIssuesTipView: View { 4 | var onDismiss: () -> Void 5 | 6 | var body: some View { 7 | Section { 8 | HStack(spacing: 15) { 9 | Image(systemName: "exclamationmark.bubble") 10 | .font(.title) 11 | .foregroundColor(.gray) 12 | 13 | VStack(alignment: .leading, spacing: 3) { 14 | VStack(alignment: .leading) { 15 | Text("common_issues_tip_title".localized) 16 | .font(.headline) 17 | } 18 | 19 | Link( 20 | destination: URL( 21 | string: "https://github.com/whoeevee/EeveeSpotify/blob/swift/common_issues.md" 22 | )!, 23 | label: { 24 | VStack { 25 | Text("\("common_issues_tip_message".localized) ") 26 | .foregroundColor(.white) 27 | 28 | + Text("common_issues_tip_button".localized) 29 | .foregroundColor(EeveeSettingsView.spotifyAccentColor) 30 | 31 | + Text(".") 32 | .foregroundColor(.white) 33 | } 34 | .font(.subheadline) 35 | } 36 | ) 37 | } 38 | 39 | Spacer() 40 | 41 | Button { 42 | onDismiss() 43 | } label: { 44 | Image(systemName: "xmark") 45 | .font(.headline) 46 | .foregroundColor(Color(UIColor.systemGray2)) 47 | } 48 | .buttonStyle(PlainButtonStyle()) 49 | } 50 | .padding(.vertical, 5) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Shared/ImageView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ImageView: View { 4 | @ObservedObject private var imageViewModel: ImageViewModel 5 | 6 | init(urlString: String?) { 7 | imageViewModel = ImageViewModel(urlString: urlString) 8 | } 9 | 10 | var body: some View { 11 | Image(uiImage: imageViewModel.image ?? UIImage()) 12 | .resizable() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Settings/Views/Shared/NavigationSectionView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct NavigationSectionView: View { 4 | var color: Color 5 | var title: String 6 | var imageSystemName: String 7 | 8 | var body: some View { 9 | HStack(spacing: 15) { 10 | ZStack { 11 | RoundedRectangle(cornerRadius: 8, style: .continuous) 12 | .foregroundColor(color) 13 | 14 | Image(systemName: imageSystemName) 15 | .foregroundColor(.white) 16 | .font(.system(size: 16, weight: .medium)) 17 | } 18 | .frame(width: 30, height: 30) 19 | 20 | Text(title) 21 | .foregroundColor(.white) 22 | 23 | Spacer() 24 | 25 | ChevronRightView() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/EeveeSpotify/Tweak.x.swift: -------------------------------------------------------------------------------- 1 | import Orion 2 | import UIKit 3 | 4 | func exitApplication() { 5 | UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil) 6 | Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in 7 | exit(EXIT_SUCCESS) 8 | } 9 | } 10 | 11 | struct PremiumPatchingGroup: HookGroup { } 12 | 13 | struct EeveeSpotify: Tweak { 14 | static let version = "5.8.3" 15 | static let isOldSpotifyVersion = NSClassFromString("Lyrics_NPVCommunicatorImpl.LyricsOnlyViewController") == nil 16 | 17 | init() { 18 | if UserDefaults.darkPopUps { 19 | DarkPopUps().activate() 20 | } 21 | 22 | if UserDefaults.patchType.isPatching { 23 | PremiumPatchingGroup().activate() 24 | } 25 | 26 | if UserDefaults.lyricsSource.isReplacing { 27 | LyricsGroup().activate() 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/EeveeSpotifyC/Tweak.m: -------------------------------------------------------------------------------- 1 | #import 2 | #import 3 | 4 | __attribute__((constructor)) static void init() { 5 | // Initialize Orion - do not remove this line. 6 | orion_init(); 7 | // Custom initialization code goes here. 8 | } 9 | -------------------------------------------------------------------------------- /Sources/EeveeSpotifyC/include/Tweak.h: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asdfzxcvbn/EeveeSpotify/1db9dbbfb63748fee4325667dd5ea60934e93881/Sources/EeveeSpotifyC/include/Tweak.h -------------------------------------------------------------------------------- /Sources/EeveeSpotifyC/include/module.modulemap: -------------------------------------------------------------------------------- 1 | module EeveeSpotifyC { 2 | umbrella "." 3 | export * 4 | } 5 | -------------------------------------------------------------------------------- /Tools/altSourceConverter: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asdfzxcvbn/EeveeSpotify/1db9dbbfb63748fee4325667dd5ea60934e93881/Tools/altSourceConverter -------------------------------------------------------------------------------- /Tools/updateRepo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asdfzxcvbn/EeveeSpotify/1db9dbbfb63748fee4325667dd5ea60934e93881/Tools/updateRepo -------------------------------------------------------------------------------- /common_issues.md: -------------------------------------------------------------------------------- 1 | # Common Issues 2 | 3 | If you're facing an issue, read this document before opening it on GitHub. 4 | 5 | *** 6 | 7 | ## Unable to Play Songs 8 | _References: Too many_ 9 | ### If all tracks are skipped, a song stops as soon as you play it, songs play in a random order, you see the "You discovered a Premium feature" popup when trying to play a song, or encounter other restrictions, 10 | You can only use Spotify abroad for 14 days. Connect to a VPN server in any country, change your region at [accounts.spotify.com](https://accounts.spotify.com), then sign out and log back into Spotify with the VPN enabled. 11 | 12 | *** 13 | 14 | There might be an issue not caused by EeveeSpotify. For example, Smart Shuffle is not affected by the tweak at all. If you encounter an error, enable Smart Shuffle again and avoid scrolling through songs excessively. Also, always test and refrain from reporting original Spotify issues, such as being unable to share lyrics on Instagram/Facebook when colors are changed, songs count truncated in liked songs or horizontal rotation UI bugs. 15 | 16 | If you're experiencing a crash, install the debug tweak from Github Actions and open an issue with the crash log and console messages with "EeveeSpotify". Consider using [Cr4shed Rootless](https://github.com/crazymind90/Cr4shed-Rootless) to provide more detailed logs. If you have jailbreak and installed EeveeSpotify with TrollStore, disable tweak injection with Choicy. 17 | 18 | ## Sideloading 19 | 20 | There might be an issue with your sideloading method. Widgets work only with TrollStore, and CarPlay only with TrollStore or a certificate with CarPlay entitlements. To navigate to a song from the lock screen, control center, or Dynamic Island, and to use Spatial Audio or Siri, change the app and bundle identifiers to match your provisioning profile (https://github.com/whoeevee/EeveeSpotify/issues/32). 21 | 22 | ## Feature Requests 23 | 24 | EeveeSpotify does not accept large feature requests. Options to disable podcasts, add themes like Spicetify, enable true shuffle, and similar features also will not be implemented, so please refrain from opening issues about them. However, pull requests are always appreciated. If you can implement something useful, feel free to submit a PR; it will likely be merged. 25 | 26 | Read the [Restrictions](https://github.com/whoeevee/EeveeSpotify?tab=readme-ov-file#restrictions) to learn about which Premium features are server-sided. 27 | 28 | ## Releases 29 | 30 | EeveeSpotify versions are typically released alongside Spotify versions on the App Store. You can get the latest IPAs on [Github Releases](https://github.com/whoeevee/EeveeSpotify/releases) or [EeveeSpotify IPAs](https://t.me/SpotilifeIPAs). You can also add https://raw.githubusercontent.com/whoeevee/EeveeSpotify/swift/repo.json as a source to Scarlet, ESign, or other apps, and https://raw.githubusercontent.com/whoeevee/EeveeSpotify/swift/repo.altsource.json for TrollApps, AltStore, SideStore, and derivatives. If you would like to make your own IPA, make sure you've injected SwiftProtobuf, Orion, and CydiaSubstrate frameworks. 31 | 32 | EeveeSpotify only supports iOS and iPadOS and is not planned to be supported on other platforms. You can easily search for different projects with lots of features. 33 | -------------------------------------------------------------------------------- /contributors.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Development", 4 | "shuffled": false, 5 | "contributors": [ 6 | { 7 | "username": "whoeevee", 8 | "roles": [ 9 | "Owner", 10 | "Main Developer" 11 | ] 12 | }, 13 | { 14 | "username": "asdfzxcvbn", 15 | "roles": [ 16 | "Collaborator", 17 | "EeveeRepoUpdater" 18 | ] 19 | } 20 | ] 21 | }, 22 | { 23 | "title": "Localization", 24 | "shuffled": true, 25 | "contributors": [ 26 | { 27 | "username": "longopy", 28 | "roles": [ 29 | "Spanish" 30 | ] 31 | }, 32 | { 33 | "username": "xiangfeidexiaohuo", 34 | "roles": [ 35 | "Simplified Chinese" 36 | ] 37 | }, 38 | { 39 | "username": "LivioZ", 40 | "roles": [ 41 | "Italian" 42 | ] 43 | }, 44 | { 45 | "username": "LIKVIDATOR1337", 46 | "roles": [ 47 | "Ukrainian" 48 | ] 49 | }, 50 | { 51 | "username": "gototheskinny", 52 | "roles": [ 53 | "Turkish" 54 | ] 55 | }, 56 | { 57 | "username": "ElliotCHEN37", 58 | "roles": [ 59 | "Traditional Chinese" 60 | ] 61 | }, 62 | { 63 | "username": "schweppes-0x", 64 | "roles": [ 65 | "Bulgarian" 66 | ] 67 | }, 68 | { 69 | "username": "speedyfriend433", 70 | "roles": [ 71 | "Korean" 72 | ] 73 | }, 74 | { 75 | "username": "Richard-NDC", 76 | "roles": [ 77 | "Vietnamese" 78 | ] 79 | }, 80 | { 81 | "username": "wlxxd", 82 | "roles": [ 83 | "German" 84 | ] 85 | }, 86 | { 87 | "username": "Incognito-Coder", 88 | "roles": [ 89 | "Persian" 90 | ] 91 | }, 92 | { 93 | "username": "UnexcitingDean", 94 | "roles": [ 95 | "Danish" 96 | ] 97 | }, 98 | { 99 | "username": "An0n-00", 100 | "roles": [ 101 | "Swiss German" 102 | ] 103 | }, 104 | { 105 | "username": "CukierDev", 106 | "roles": [ 107 | "Polish" 108 | ] 109 | }, 110 | { 111 | "username": "3xynos7", 112 | "roles": [ 113 | "Nepalese" 114 | ] 115 | }, 116 | { 117 | "username": "by3lish", 118 | "roles": [ 119 | "Azerbaijani" 120 | ] 121 | }, 122 | { 123 | "username": "5jd", 124 | "roles": [ 125 | "French" 126 | ] 127 | }, 128 | { 129 | "username": "emal0n", 130 | "roles": [ 131 | "Brazil" 132 | ] 133 | }, 134 | { 135 | "username": "LPCC12", 136 | "roles": [ 137 | "Portuguese" 138 | ] 139 | }, 140 | { 141 | "username": "dvirzxc", 142 | "roles": [ 143 | "Catalan" 144 | ] 145 | }, 146 | { 147 | "username": "zrvpuna", 148 | "roles": [ 149 | "Japanese" 150 | ] 151 | }, 152 | { 153 | "username": "RikyPy", 154 | "roles": [ 155 | "Arabic (Egypt, DeepL)", 156 | "Hungarian (DeepL)", 157 | "Romanian" 158 | ] 159 | } 160 | ] 161 | } 162 | ] 163 | -------------------------------------------------------------------------------- /control: -------------------------------------------------------------------------------- 1 | Package: com.eevee.spotify 2 | Name: EeveeSpotify 3 | Version: 5.8.3 4 | Architecture: iphoneos-arm 5 | Description: A tweak to get Spotify Premium for free, just like Spotilife 6 | Maintainer: Eevee 7 | Author: Eevee 8 | Section: Tweaks 9 | Depends: ${ORION}, firmware (>= 14.0), org.swift.protobuf.swiftprotobuf (>= 1.28.1) 10 | -------------------------------------------------------------------------------- /layout/Library/Application Support/EeveeSpotify.bundle/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | 8 | 9 | -------------------------------------------------------------------------------- /layout/Library/Application Support/EeveeSpotify.bundle/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asdfzxcvbn/EeveeSpotify/1db9dbbfb63748fee4325667dd5ea60934e93881/layout/Library/Application Support/EeveeSpotify.bundle/github.png -------------------------------------------------------------------------------- /layout/Library/Application Support/EeveeSpotify.bundle/ja.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* MARK: Settings */ 2 | 3 | patching = "パッチ"; 4 | lyrics = "歌詞"; 5 | customization = "カスタマイズ"; 6 | 7 | common_issues_tip_title = "問題が発生していますか?"; 8 | 9 | common_issues_tip_message = "曲を再生できないなどのバグが発生している場合は、問題をチェックしてください"; 10 | common_issues_tip_button = "一般的な問題"; 11 | 12 | reset_data = "データをリセット"; 13 | reset_data_description = "キャッシュデータを消去してSpotifyを再起動します。"; 14 | 15 | checking_for_update = "アップデートを確認中..."; 16 | update_available = "アップデート可能"; 17 | 18 | restart_is_required_description = "変更後はSpotifyの再起動が必要です。"; 19 | 20 | // Patching 21 | 22 | do_not_patch_premium = "パッチを適用しない"; 23 | patching_description = "EeveeSpotifyは、ユーザーデータを読み込むリクエストをインターセプトし、それをデシリアライズして、リアルタイムでパラメータを変更します。 24 | 25 | 有効なプレミアムサブスクリプションをお持ちの場合は、「プレミアムにパッチを適用しない」をオンにすることができます。この調整によって、データがパッチされたり、プレミアムのサーバー側で実現される機能の使用が制限されたりすることはありません。 26 | 27 | %@"; 28 | 29 | overwrite_configuration = "設定を上書き"; 30 | overwrite_configuration_description = "リモート構成をダンプされた Premium の設定に置き換えます。この設定はほどんどの UI/UX パラメーターを定義しており、役立つかもしれませんが、問題が発生する可能性があります。"; 31 | 32 | // Lyrics 33 | 34 | do_not_replace_lyrics = "歌詞を置き換えない"; 35 | lyrics_source = "歌詞ソース"; 36 | lyrics_source_description = "歌詞ソースを選択できます。 37 | 38 | Genius: 最高品質の歌詞で、最も多くの曲があり、歌詞を最も早く更新します。歌詞同期はされません。 39 | 40 | LRCLIB: 最もオープンはサービスで、歌詞同期されます。曲は多くありません。 41 | 42 | Musixmatch: Spotify が使用しているサービスです。多くの曲がありますが、このソースを使用するにはユーザートークンが必要です。"; 43 | 44 | musixmatch_user_token = "Musixmatch ユーザートークン"; 45 | user_token_placeholder = "ユーザートークンを入力、またはデバッグ情報を貼り付け"; 46 | 47 | enter_user_token = "ユーザートークンを入力"; 48 | enter_user_token_message = "Musixmatchを使用するには、公式アプリからユーザートークンを取得する必要があります。App StoreからMusixmatchをダウンロードし、サインアップしてから、[設定] > [ヘルプを表示] > [デバッグ情報をコピー] に移動して、ここに貼り付けます。MITMを使用してトークンを抽出することもできます。"; 49 | 50 | genius_fallback = "Geniusでフォールバック"; 51 | genius_fallback_description = "%@ に問題がある場合は、Geniusから歌詞を読み込みます。"; 52 | 53 | show_fallback_reasons = "フォールバックの理由を表示する"; 54 | 55 | romanized_lyrics = "ローマ字の歌詞"; 56 | romanized_lyrics_description = "日本語、韓国語、中国語のローマ字の歌詞を表示します。"; 57 | 58 | musixmatch_language = "Musixmatch 歌詞の言語"; 59 | musixmatch_language_description = "2文字の Musixmatch 言語コードを入力すると、Musixmatch で翻訳された歌詞が表示されます (利用可能な場合)。"; 60 | 61 | // UI 62 | 63 | lyrics_background_color_section = "歌詞の背景色"; 64 | lyrics_background_color_section_description = "[元の色を表示]をオンにすると、歌詞があるトラックでは、歌詞が元のSpotifyの色で表示されます。 65 | 66 | 抽出されたアルバムアートの色に基づいて、静的な色または正規化係数を設定できます。この係数によって、暗い色がどれだけ明るくなり、明るい色がどれだけ暗くなるかが決まります。通常、正規化係数が高いほど、明るい色が表示されます。"; 67 | 68 | display_original_colors = "オリジナルの背景色で表示"; 69 | 70 | use_static_color = "静的な色を使用"; 71 | static_color = "色"; 72 | 73 | color_normalization_factor = "色の正規化係数"; 74 | dark_popups = "暗いポップアップ"; 75 | 76 | /* MARK: Premium PopUps */ 77 | 78 | have_premium_popup = "Premium が有効なので、データにパッチを適用したり、Premium のサーバー側機能の使用が制限されたりすることはありません。EeveeSpotify の設定で管理できます。"; 79 | 80 | high_audio_quality_popup = "最高音質はサーバー側で行われるため、利用できません。"; 81 | playlist_downloading_popup = "ネイティブプレイリストのダウンロードはサーバー側で行われるため、利用できません。なお、ポッドキャストのエピソードとローカルプレイリストはダウンロードできます。"; 82 | download_local_playlist = "ローカルプレイリストをダウンロード"; 83 | 84 | /* MARK: Lyrics */ 85 | 86 | fallback_attribute = "フォールバック"; 87 | romanized_attribute = "ローマ字"; 88 | 89 | musixmatch_unauthorized_popup = "Unauthorized エラーのため、Musixmatch から歌詞を取得することができません。Musixmatch トークンを確認、または変更してください。iPad を使用している場合は、iPad 用の Musixmatch アプリからトークンを取得してください。"; 90 | musixmatch_restricted_popup = "歌詞が制限されているため取得できません。米国の IP アドレスによる著作権の問題である可能性が高いため、米国にいる場合は IP アドレスを変更するか、VPN を使用する必要があります。"; 91 | 92 | // Errors Titles 93 | 94 | no_such_song = "曲が見つかりません"; 95 | musixmatch_restricted = "制限"; 96 | invalid_musixmatch_token = "無許可"; 97 | decoding_error = "解読エラー"; 98 | no_current_track = "トラックインスタンスなし"; 99 | unknown_error = "不明なエラー"; 100 | 101 | // Instrumental Titles 102 | 103 | song_is_instrumental = "この曲はインストゥルメンタルです。"; 104 | let_the_music_play = "音楽を再生しよう..."; 105 | 106 | // liked songs title, should match official spotify loc 107 | 108 | liked_songs = "好きな曲"; 109 | 110 | contributors = "コントリビューター"; 111 | -------------------------------------------------------------------------------- /layout/Library/Application Support/EeveeSpotify.bundle/ko.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* MARK: Settings */ 2 | 3 | patching = "패치 중"; 4 | lyrics = "가사"; 5 | customization = "커스텀화"; 6 | 7 | common_issues_tip_title = "문제가 있습니까?"; 8 | 9 | common_issues_tip_message = "노래를 재생할 수 없는 문제가 있다면, 확인해 보세요."; 10 | common_issues_tip_button = "일반적인 문제"; 11 | 12 | reset_data = "데이터 초기화"; 13 | reset_data_description = "캐시된 데이터를 지우고 앱을 다시 시작하세요."; 14 | 15 | checking_for_update = "업데이트 확인 중..."; 16 | update_available = "업데이트 가능"; 17 | 18 | // Patching 19 | 20 | do_not_patch_premium = "프리미엄으로 패치 안 함"; 21 | patching_description = "트윅은 사용자 데이터를 로드하기 위한 요청을 가로채고, 역직렬화하고, 실시간으로 매개 변수를 수정합니다. 22 | 23 | 활성화된 프리미엄 구독이 있다면, 프리미엄으로 패치 안 함을 켤 수 있습니다. 조정은 데이터를 패치하거나 프리미엄 서버 측 기능의 사용을 제한하지 않습니다. 변경 후 앱을 다시 시작해야 합니다."; 24 | 25 | overwrite_configuration = "구성 덮어쓰기"; 26 | overwrite_configuration_description = "원격 구성을 덤프된 프리미엄으로 교체하세요. 광고 표시와 같은 몇 가지 문제를 해결할 수 있지만, 보장되지는 않습니다."; 27 | 28 | // Lyrics 29 | 30 | lyrics_source = "가사 출처"; 31 | lyrics_source_description = "선호하는 가사 소스를 선택할 수 있습니다. 32 | 33 | Genius: 최고 품질의 가사를 제공하고, 가장 많은 노래를 제공하며, 가사를 가장 빠르게 업데이트합니다. 시간과 동기화되지 않으며 절대 동기화되지 않을 것입니다. 34 | 35 | LRCLIB: 시간 동기화된 가사를 제공하는 가장 개방적인 서비스입니다. 하지만, 많은 노래의 가사가 부족합니다. 36 | 37 | Musixmatch: 스포티파이가 사용하는 서비스입니다. 많은 노래에 시간-동기화된 가사를 제공하지만, 이 소스를 사용하려면 사용자 토큰이 필요합니다."; 38 | lyrics_additional_info = "조정이 노래를 찾거나 가사를 처리할 수 없다면, \”이 노래의 가사를 로드할 수 없습니다\“ 메시지가 표시됩니다. 트윅이 노래를 검색하는 방식 때문에 Genius를 사용할 때 일부 노래의 가사가 틀릴 수 있습니다. 저는 대부분의 경우 작동하게 만들었습니다."; 39 | petitlyrics_description = "PetitLyrics: 많은 시간 동기화된 일본어와 국제적인 가사를 제공합니다."; 40 | 41 | musixmatch_user_token = "Musixmatch 사용자 토큰"; 42 | user_token_placeholder = "사용자 토큰을 입력하거나 디버그 정보를 붙여넣으세요"; 43 | 44 | enter_user_token = "사용자 토큰을 입력하세요"; 45 | enter_user_token_message = "Musixmatch를 사용하려면 공식 앱에서 사용자 토큰을 검색해야 합니다. App Store에서 Musixmatch를 다운로드하고 가입한 다음 설정 > 도움말 > 디버그 정보 복사로 이동하여 여기에 붙여넣으세요. MITM을 사용하여 토큰을 추출할 수도 있습니다."; 46 | 47 | genius_fallback = "genius 폴백"; 48 | genius_fallback_description = "%@에 문제가 있다면 Genius의 가사를 불러오세요."; 49 | 50 | show_fallback_reasons = "폴백 이유를 보여주기"; 51 | 52 | romanized_lyrics = "로마자화된 가사"; 53 | romanized_lyrics_description = "일본어, 한국어, 중국어의 로마자 가사를 표시하세요."; 54 | 55 | musixmatch_language = "Musixmatch 가사 언어"; 56 | musixmatch_language_description = "2글자 Musixmatch 언어 코드를 입력하고 Musixmatch에서 번역된 가사를 볼 수 있습니다."; 57 | 58 | // UI 59 | 60 | lyrics_background_color_section = "가사 배경색"; 61 | lyrics_background_color_section_description = "Display Original Colors를 켜면, 가사가 있는 트랙의 원래 Spotify 색상으로 표시됩니다. 62 | 63 | 추출된 트랙 커버의 색상을 기반으로 정적 색상 또는 정규화 요소를 설정할 수 있습니다. 이 요소는 얼마나 많은 어두운 색이 밝아지고 밝은 색이 어두워지는지를 결정합니다. 일반적으로, 더 높은 정규화 계수를 가진 더 밝은 색을 보게 될 것입니다."; 64 | 65 | display_original_colors = "원래 색상 표시"; 66 | 67 | use_static_color = "정적 색상 사용"; 68 | static_color = "정적 색상"; 69 | 70 | color_normalization_factor = "색상 정규화 요소"; 71 | dark_popups = "다크 팝업"; 72 | 73 | /* MARK: Premium PopUps */ 74 | 75 | have_premium_popup = "활성 프리미엄 구독이 있는 것처럼 보이므로, 조정은 데이터를 패치하거나 프리미엄 서버 측 기능의 사용을 제한하지 않습니다. EeveeSpotify 설정에서 이것을 관리할 수 있습니다."; 76 | 77 | high_audio_quality_popup = "매우 높은 오디오 품질은 서버 측이며 이 트윅으로 사용할 수 없습니다."; 78 | playlist_downloading_popup = "네이티브 재생 목록 다운로드는 서버 측이며 이 트윅에서는 사용할 수 없습니다. 하지만 팟캐스트 에피소드를 다운로드할 수 있습니다."; 79 | 80 | /* MARK: Lyrics */ 81 | 82 | fallback_attribute = "폴백"; 83 | romanized_attribute = "로마자화된"; 84 | 85 | musixmatch_unauthorized_popup = "무단 오류로 인해 Musixmatch의 가사를 로드할 수 없습니다. Musixmatch 토큰을 확인하거나 업데이트하세요. iPad를 사용하는 경우, iPad용 Musixmatch 앱에서 토큰을 받아야 합니다."; 86 | musixmatch_restricted_popup = "트윅은 제한되어 있기 때문에 Musixmatch의 가사를 로드할 수 없습니다. 미국 IP 주소로 인해 저작권 문제일 가능성이 높으므로, 미국에 있거나 VPN을 사용하는 경우 변경해야 합니다."; 87 | 88 | // Errors Titles 89 | 90 | no_such_song = "노래를 찾을 수 없습니다."; 91 | musixmatch_restricted = "제한된"; 92 | invalid_musixmatch_token = "허가받지 않은"; 93 | decoding_error = "디코딩 오류"; 94 | no_current_track = "트랙 인스턴스 없음"; 95 | unknown_error = "알 수 없는 오류"; 96 | 97 | // Instrumental Titles 98 | 99 | song_is_instrumental = "이 노래는 악기전용 노래입니다."; 100 | let_the_music_play = "음악이 재생되게 놔두세요..."; 101 | -------------------------------------------------------------------------------- /layout/Library/Application Support/EeveeSpotify.bundle/resolveconfiguration.bnk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asdfzxcvbn/EeveeSpotify/1db9dbbfb63748fee4325667dd5ea60934e93881/layout/Library/Application Support/EeveeSpotify.bundle/resolveconfiguration.bnk -------------------------------------------------------------------------------- /layout/Library/Application Support/EeveeSpotify.bundle/zh-CN.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* MARK: Settings */ 2 | 3 | patching = "补丁"; 4 | lyrics = "歌词"; 5 | customization = "定制"; 6 | 7 | common_issues_tip_title = "发现问题?"; 8 | 9 | common_issues_tip_message = "如果遇到问题,例如无法播放任何歌曲,请查看"; 10 | common_issues_tip_button = "常见问题"; 11 | 12 | reset_data = "重置数据"; 13 | reset_data_description = "清除缓存数据并重新启动应用程序。"; 14 | 15 | checking_for_update = "正在检查更新..."; 16 | update_available = "发现可用更新"; 17 | 18 | restart_is_required_description = "更改后需要重启应用程序。"; 19 | 20 | // Patching 21 | 22 | do_not_patch_premium = "不启用 Premium 补丁"; 23 | patching_description = "该插件会拦截加载用户数据的请求,对其进行反序列化,并实时修改参数。\n\n如果您拥有真实有效的 Premium 订阅,则可以启用“不启用 Premium 补丁”。该插件不会修补数据或限制Premium服务器端功能的使用。更改后需要重新启动应用。"; 24 | 25 | overwrite_configuration = "覆盖配置"; 26 | overwrite_configuration_description = "用转存的 Premium 配置替换远程配置。虽然不能保证一定有效,但可以解决一些问题,如出现广告。"; 27 | 28 | // Lyrics 29 | 30 | do_not_replace_lyrics = "请勿替换歌词"; 31 | lyrics_source = "歌词来源"; 32 | lyrics_source_description = "可以自由选择自己喜欢的歌词来源。\n\nGenius:拥有最优质的歌词,提供最多的歌曲,歌词更新速度最快。但是,不会进行实时同步。\nLRCLIB:最开放的服务,提供实时同步的歌词。但是,它缺少很多歌曲的歌词。\nMusixmatch:Spotify 本身所使用的服务,为很多歌曲提供实时同步的歌词,但需要用户令牌才能使用此来源。"; 33 | lyrics_additional_info = "如果插件无法找到歌曲或处理歌词,将看到“无法加载这首歌的歌词”的提示。使用 Genius 时,由于插件搜索歌曲的方式,某些歌词可能会有误。在大多数情况下,它都正常工作。"; 34 | petitlyrics_description = "PetitLyrics:提供大量实时同步的日语歌词和一些国际歌词。"; 35 | 36 | musixmatch_user_token = "Musixmatch 用户令牌"; 37 | user_token_placeholder = "输入用户令牌或粘贴调试信息"; 38 | 39 | enter_user_token = "输入用户令牌"; 40 | enter_user_token_message = "为了使用 Musixmatch,需要从官方应用程序中检索你的用户令牌。从 AppStore 下载 Musixmatch,注册后转到“设置”>“获取帮助”>“复制调试信息”,并将其粘贴在此处。你也可以使用 MITM 提取令牌。"; 41 | 42 | genius_fallback = "回退Genius"; 43 | genius_fallback_description = "如果 %@ 有问题,将使用 Genius 加载歌词。"; 44 | 45 | show_fallback_reasons = "显示回退原因"; 46 | 47 | romanized_lyrics = "罗马化歌词"; 48 | romanized_lyrics_description = "显示中文、韩语和日语的罗马化歌词。"; 49 | 50 | musixmatch_language = "Musixmatch 歌词语言"; 51 | musixmatch_language_description = "可以输入两个字母的 Musixmatch 语言代码,并在 Musixmatch 上查看翻译的歌词(如果有)。"; 52 | 53 | // UI 54 | 55 | lyrics_background_color_section = "歌词背景颜色"; 56 | lyrics_background_color_section_description = "如果打开“原始颜色”,歌词将以 Spotify 的默认颜色显示。\n\n也可以根据提取的曲目封面的颜色设置“静态颜色”或“标准颜色”。颜色标准参数决定深色变浅的程度以及浅色变暗的程度。通常,标准参数越高,看到的颜色越浅。"; 57 | 58 | display_original_colors = "显示原始颜色"; 59 | 60 | use_static_color = "使用静态颜色"; 61 | static_color = "静态颜色"; 62 | 63 | color_normalization_factor = "颜色标准参数"; 64 | dark_popups = "深色弹窗"; 65 | 66 | /* MARK: Premium PopUps */ 67 | 68 | have_premium_popup = "看来您有一个真实有效的 Premium 订阅,因此此插件不会修补数据或限制 Premium 服务器端功能的使用。可以在 EeveeSpotify 设置中管理此操作。"; 69 | 70 | high_audio_quality_popup = "非常高的音频质量是服务器端的,并且无法通过此插件获得。"; 71 | playlist_downloading_popup = "原生播放列表下载是服务器端的,无法通过此插件下载。不过可以下载播客节目。"; 72 | 73 | /* MARK: Lyrics */ 74 | 75 | fallback_attribute = "回退"; 76 | romanized_attribute = "罗马化"; 77 | 78 | musixmatch_unauthorized_popup = "由于未授权错误,该插件无法从 Musixmatch 加载歌词。请检查或更新 Musixmatch 令牌。如果使用 iPad,则应从 iPad 版 Musixmatch 应用中获取令牌。"; 79 | musixmatch_restricted_popup = "由于受到限制,该插件无法加载 Musixmatch 的歌词。这可能是由于美国 IP 地址导致的版权问题,因此如果在美国,则应更改它或使用 VPN。"; 80 | 81 | // Errors Titles 82 | 83 | no_such_song = "未找到歌曲"; 84 | musixmatch_restricted = "受限制"; 85 | invalid_musixmatch_token = "未授权"; 86 | decoding_error = "解码错误"; 87 | no_current_track = "无跟踪实例"; 88 | unknown_error = "未知错误"; 89 | 90 | // Instrumental Titles 91 | 92 | song_is_instrumental = "这首歌是器乐曲。"; 93 | let_the_music_play = "让音乐播放..."; 94 | 95 | // liked songs title, should match official spotify loc 96 | liked_songs = "喜欢的歌曲"; 97 | 98 | contributors = "参与者"; 99 | Developement = "开发"; 100 | Localization = "本地化翻译"; 101 | 102 | request_anonymous_token = "请求匿名令牌"; 103 | request_anonymous_token_description = "点击“请求匿名令牌”即可在无需授权的情况下向 Musixmatch 请求令牌。"; 104 | -------------------------------------------------------------------------------- /layout/Library/Application Support/EeveeSpotify.bundle/zh-TW.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 標記: 設定 */ 2 | 3 | patching = "修補方案"; 4 | lyrics = "歌詞"; 5 | customization = "客製化"; 6 | 7 | common_issues_tip_title = "遇到問題?"; 8 | 9 | common_issues_tip_message = "如果遇到了一些問題,例如無法播放音樂,請看"; 10 | common_issues_tip_button = "常見問題"; 11 | 12 | reset_data = "重設"; 13 | reset_data_description = "清除快取並重新啟動Spotify."; 14 | 15 | checking_for_update = "正在檢查更新..."; 16 | update_available = "有可用的更新"; 17 | 18 | restart_is_required_description = "選項變更後必須重新啟動Spotify"; 19 | 20 | // 修補方案 21 | 22 | do_not_patch_premium = "不修補Premium"; 23 | patching_description = "EeveeSpotify會即時攔截使用者資料請求,反序列化並修改資料。 24 | 25 | 若您擁有有效的Premium方案, 則可以啟用「不修補Premium」。啟用後EeveeSpotify將不會對任何Premium功能做出限制。 26 | 27 | %@"; 28 | 29 | overwrite_configuration = "覆寫配置"; 30 | overwrite_configuration_description = "使用轉存的Premium配置替換遠端配置。該檔案定義了大部分的UI/UX參數,雖然可能會導致一些問題,但仍然很有幫助。"; 31 | 32 | // 歌詞 33 | 34 | do_not_replace_lyrics = "禁用歌詞替換(使用Spotify原版歌詞)"; 35 | lyrics_source = "歌詞提供者"; 36 | lyrics_source_description = "你可以在此選擇喜歡的歌詞提供者。 37 | 38 | Genius:為絕大多數歌曲提供最高品質的歌詞且為更新最快的提供者,但永遠不會提供同步的歌詞。 39 | 40 | LRCLIB:最為開放的歌詞提供者,提供即時同步的歌詞。然而其缺少大量歌曲的歌詞。 41 | 42 | Musixmatch:Spotify使用的提供者。為許多歌曲提供同步歌詞以及翻譯,但是你需要一個使用者權杖才可以使用該歌詞提供者。"; 43 | lyrics_additional_info = "若EeveeSpotify無法找到或無法處理歌詞, 您將會看到「無法載入歌詞」。由於EeveeSpotify搜尋歌詞的方式所以使用Genius時可能會出現錯誤的歌詞。在大多數情況下,它都能正常運作。"; 44 | petitlyrics_description = "PetitLyrics:提供大量已同步的日語歌詞和一些國際(非日語)歌詞。"; 45 | 46 | musixmatch_user_token = "Musixmatch使用者權杖"; 47 | user_token_placeholder = "請在此輸入 Musixmatch 使用者權杖或貼上除錯資訊"; 48 | 49 | enter_user_token = "輸入使用者權杖"; 50 | enter_user_token_message = "若要使用 Musixmatch作為歌詞提供者,則需要從官方應用程式中找到你的使用者權杖。 從App Store下載Musixmatch,註冊或登錄,接著來到 設定(右上角的齒輪圖示) > Get help > 複製除錯資訊, 並在此處貼上。亦可使用中間人攻擊提取使用者權杖。"; 51 | 52 | genius_fallback = "Genius替代歌詞"; 53 | genius_fallback_description = "如果無法從 %@ 載入歌詞,將使用 Genius 載入歌詞。"; 54 | 55 | show_fallback_reasons = "顯示替代原因"; 56 | 57 | romanized_lyrics = "羅馬拼音歌詞"; 58 | romanized_lyrics_description = "為中文,日文以及韓文顯示羅馬拼音歌詞"; 59 | 60 | musixmatch_language = "Musixmatch 歌詞翻譯語言"; 61 | musixmatch_language_description = "可以輸入兩個字母的 Musixmatch 語言碼,並在翻譯可用時查看翻譯。*繁體中文語言碼為z1"; 62 | 63 | // 圖型化介面 64 | 65 | lyrics_background_color_section = "歌詞背景色"; 66 | lyrics_background_color_section_description = "若您啟用「顯示原彩」則會顯示Spotify原本的歌詞背景顏色(根據歌曲專輯封面動態調整)。 67 | 68 | 您亦可設定一個固定背景顏色或者設定顏色標準化程度。該選項決定了多少淺色被調深以及多少深色被調淺。通常來講,標準化程度越高則背景顏色越淺。"; 69 | 70 | display_original_colors = "顯示原彩"; 71 | 72 | use_static_color = "使用固定背景顏色"; 73 | static_color = "固定背景顏色"; 74 | 75 | color_normalization_factor = "顏色標準化程度"; 76 | dark_popups = "深色提示"; 77 | 78 | /* 標記: Premium 彈出視窗 */ 79 | 80 | have_premium_popup = "您似乎沒有訂閱Premium,所以EeveeSpotify不會對使用者資料做出修補或限制Premium功能。您可以在EeveeSpotify設定中管理。"; 81 | 82 | high_audio_quality_popup = "由於極高音質由伺服器處理因此EeveeSpotify無法啟用該功能。"; 83 | playlist_downloading_popup = "由於原生的下載功能由伺服器處理因此EeveeSpotify無法啟用該功能。不過您仍可下載Podcast與本機檔案。"; 84 | download_local_playlist = "下載本機檔案"; 85 | 86 | /* 標記: 歌詞 */ 87 | 88 | fallback_attribute = "替代歌詞"; 89 | romanized_attribute = "羅馬拼音"; 90 | 91 | musixmatch_unauthorized_popup = "由於歌詞未授權因此EeveeSpotify無法從Musixmatch載入。請檢查或更新你的使用者權杖。若您正在使用iPad。則您需要從iPad版本的Musixmatch取得使用者權杖。"; 92 | musixmatch_restricted_popup = "由於歌詞受到限制因此EeveeSpotify無法從Musixmatch載入。這可能是由於美國 IP 地址而導致的版權問題,因此如果在美國,則應更換 IP 位址或使用 VPN。"; 93 | 94 | // 錯誤標題 95 | 96 | no_such_song = "找不到歌曲"; 97 | musixmatch_restricted = "受限制"; 98 | invalid_musixmatch_token = "未授權"; 99 | decoding_error = "解碼錯誤"; 100 | no_current_track = "目前未播放音樂"; 101 | unknown_error = "未知錯誤"; 102 | 103 | // 純音樂標題 104 | 105 | song_is_instrumental = "此歌曲為純音樂。"; 106 | let_the_music_play = "讓音樂演奏吧..."; 107 | 108 | // 已按讚的歌曲 109 | liked_songs = "已按讚的歌曲"; 110 | 111 | contributors = "貢獻者"; 112 | 113 | request_anonymous_token = "請求匿名使用者權杖"; 114 | request_anonymous_token_description = "點擊 「請求匿名使用者權杖」 後 EeveeSpotify 會嘗試在未授權的情況下從 Musixmatch 取得使用者權杖."; --------------------------------------------------------------------------------