├── .vscode └── settings.json ├── AMLyrics.plist ├── control ├── Makefile ├── LICENSE ├── README.md ├── .gitignore ├── .github └── workflows │ └── build.yml └── AMLyrics.xm /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "makefile.configureOnOpen": false 3 | } -------------------------------------------------------------------------------- /AMLyrics.plist: -------------------------------------------------------------------------------- 1 | { Filter = { Bundles = ( "com.apple.Music" ); }; } 2 | -------------------------------------------------------------------------------- /control: -------------------------------------------------------------------------------- 1 | Package: com.82flex.amlyrics 2 | Name: AMLyrics 3 | Version: 0.0.1 4 | Architecture: iphoneos-arm 5 | Description: Dump lyrics from Apple Music. 6 | Maintainer: Lessica <82flex@gmail.com> 7 | Author: Lessica <82flex@gmail.com> 8 | Section: Tweaks 9 | Depends: mobilesubstrate (>= 0.9.5000) 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export PACKAGE_VERSION := 1.0 2 | 3 | TARGET := iphone:clang:16.5:15.0 4 | INSTALL_TARGET_PROCESSES = Music 5 | 6 | include $(THEOS)/makefiles/common.mk 7 | 8 | TWEAK_NAME += AMLyrics 9 | 10 | AMLyrics_FILES += AMLyrics.xm 11 | AMLyrics_CFLAGS += -fobjc-arc 12 | 13 | include $(THEOS_MAKE_PATH)/tweak.mk 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Lessica 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AMLyrics 2 | 3 | 在 iOS 15 及以上版本「音乐」的标题、控制中心模块及锁屏上显示歌词。并将歌词文件导出到「音乐」的沙盒容器中。 4 | 5 | Display lyrics in the title, Control Center module, and lock screen of the “Music” app on iOS 15 and later. Also exports the lyrics file to the “Music” app’s sandbox container. 6 | 7 | ## 使用方法 8 | 9 | 1. 越狱设备直接安装即可,巨魔设备参照以下步骤 10 | 2. 打开「巨魔注入器」→「音乐」→「高级选项」→「优先处理主要可执行文件」 11 | 3. 使用「巨魔注入器」将 AMLyrics 注入到「音乐」 12 | 13 | ## 已知问题 14 | 15 | 使用「巨魔注入器」注入「音乐」会导致其始终处于离线状态,无法访问除「资料库」以外的任何内容。 16 | 17 | ## How To Use 18 | 19 | 1. Open “TrollFools” → “Music” → “Advanced Options” → “Prefer Main Executable”. 20 | 2. Use “TrollFools” to inject AMLyrics into “Music”. 21 | 22 | ## Use it with Letterpress! 23 | 24 | [now-on-havoc]: https://havoc.app/package/letterpress 25 | 26 | [][now-on-havoc] 27 | 28 | Letterpress is an awesome music visualizer “tweak” that based on TrollStore floating window technology. 29 | 30 | https://github.com/user-attachments/assets/4d22da7c-280f-49d8-b3cf-e62b2e8d5f01 31 | 32 | ## Known Issues 33 | 34 | Injecting AMLyrics into “Music” using “TrollFools” will cause it to always be offline, unable to access any content other than the “Library”. 35 | 36 | ## License 37 | 38 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .theos/ 2 | packages/ 3 | .DS_Store 4 | __handlers__/ 5 | .cache/ 6 | compile_commands.json 7 | 8 | # Created by https://www.toptal.com/developers/gitignore/api/macos,xcode,swift 9 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,xcode,swift 10 | 11 | ### macOS ### 12 | # General 13 | .DS_Store 14 | .AppleDouble 15 | .LSOverride 16 | 17 | # Icon must end with two \r 18 | Icon 19 | 20 | 21 | # Thumbnails 22 | ._* 23 | 24 | # Files that might appear in the root of a volume 25 | .DocumentRevisions-V100 26 | .fseventsd 27 | .Spotlight-V100 28 | .TemporaryItems 29 | .Trashes 30 | .VolumeIcon.icns 31 | .com.apple.timemachine.donotpresent 32 | 33 | # Directories potentially created on remote AFP share 34 | .AppleDB 35 | .AppleDesktop 36 | Network Trash Folder 37 | Temporary Items 38 | .apdisk 39 | 40 | ### macOS Patch ### 41 | # iCloud generated files 42 | *.icloud 43 | 44 | ### Swift ### 45 | # Xcode 46 | # 47 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 48 | 49 | ## User settings 50 | xcuserdata/ 51 | 52 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 53 | *.xcscmblueprint 54 | *.xccheckout 55 | 56 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 57 | build/ 58 | DerivedData/ 59 | *.moved-aside 60 | *.pbxuser 61 | !default.pbxuser 62 | *.mode1v3 63 | !default.mode1v3 64 | *.mode2v3 65 | !default.mode2v3 66 | *.perspectivev3 67 | !default.perspectivev3 68 | 69 | ## Obj-C/Swift specific 70 | *.hmap 71 | 72 | ## App packaging 73 | *.ipa 74 | *.dSYM.zip 75 | *.dSYM 76 | 77 | ## Playgrounds 78 | timeline.xctimeline 79 | playground.xcworkspace 80 | 81 | # Swift Package Manager 82 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 83 | # Packages/ 84 | # Package.pins 85 | # Package.resolved 86 | # *.xcodeproj 87 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 88 | # hence it is not needed unless you have added a package configuration file to your project 89 | # .swiftpm 90 | 91 | .build/ 92 | 93 | # CocoaPods 94 | # We recommend against adding the Pods directory to your .gitignore. However 95 | # you should judge for yourself, the pros and cons are mentioned at: 96 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 97 | # Pods/ 98 | # Add this line if you want to avoid checking in source code from the Xcode workspace 99 | # *.xcworkspace 100 | 101 | # Carthage 102 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 103 | # Carthage/Checkouts 104 | 105 | Carthage/Build/ 106 | 107 | # Accio dependency management 108 | Dependencies/ 109 | .accio/ 110 | 111 | # fastlane 112 | # It is recommended to not store the screenshots in the git repo. 113 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 114 | # For more information about the recommended setup visit: 115 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 116 | 117 | fastlane/report.xml 118 | fastlane/Preview.html 119 | fastlane/screenshots/**/*.png 120 | fastlane/test_output 121 | 122 | # Code Injection 123 | # After new code Injection tools there's a generated folder /iOSInjectionProject 124 | # https://github.com/johnno1962/injectionforxcode 125 | 126 | iOSInjectionProject/ 127 | 128 | ### Xcode ### 129 | 130 | ## Xcode 8 and earlier 131 | 132 | ### Xcode Patch ### 133 | *.xcodeproj/* 134 | !*.xcodeproj/project.pbxproj 135 | !*.xcodeproj/xcshareddata/ 136 | !*.xcodeproj/project.xcworkspace/ 137 | !*.xcworkspace/contents.xcworkspacedata 138 | /*.gcno 139 | **/xcshareddata/WorkspaceSettings.xcsettings 140 | 141 | # End of https://www.toptal.com/developers/gitignore/api/macos,xcode,swift 142 | 143 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: [ release ] 6 | workflow_dispatch: 7 | 8 | env: 9 | FINALPACKAGE: 1 10 | HOMEBREW_NO_AUTO_UPDATE: 1 11 | HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK: 1 12 | 13 | jobs: 14 | build: 15 | runs-on: macos-14 16 | 17 | strategy: 18 | matrix: 19 | scheme: ['', 'rootless', 'roothide'] 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Install dependencies 26 | run: | 27 | # Install xcbeautify for build output formatting 28 | # Install ldid for iOS code signing 29 | # Install 7zip for compression 30 | brew install xcbeautify ldid-procursus p7zip make 31 | 32 | - name: Checkout roothide/theos 33 | uses: actions/checkout@v4 34 | with: 35 | repository: roothide/theos 36 | path: theos-roothide 37 | submodules: recursive 38 | 39 | - name: Install iOS SDKs 40 | run: | 41 | export THEOS=$GITHUB_WORKSPACE/theos-roothide 42 | cd theos-roothide 43 | ./bin/install-sdk iPhoneOS16.5 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: Setup Xcode 48 | uses: maxim-lobanov/setup-xcode@v1 49 | with: 50 | xcode-version: '16.2' 51 | 52 | - name: Build package (${{ matrix.scheme || 'default' }}) 53 | run: | 54 | export THEOS=$GITHUB_WORKSPACE/theos-roothide 55 | THEOS_PACKAGE_SCHEME=${{ matrix.scheme }} FINALPACKAGE=1 gmake clean package 56 | 57 | - name: Prepare artifacts 58 | run: | 59 | # Create directories for artifacts 60 | mkdir -p artifacts/dsym-${{ matrix.scheme || 'default' }} 61 | mkdir -p artifacts/packages-${{ matrix.scheme || 'default' }} 62 | 63 | # Copy dSYM files 64 | if [ -d ".theos/obj" ]; then 65 | find .theos/obj -name "*.dSYM" -exec cp -r {} artifacts/dsym-${{ matrix.scheme || 'default' }}/ \; 66 | fi 67 | 68 | # Copy packages 69 | if [ -d "packages" ]; then 70 | cp -r packages/* artifacts/packages-${{ matrix.scheme || 'default' }}/ 71 | fi 72 | 73 | - name: Upload dSYM artifacts 74 | uses: actions/upload-artifact@v4 75 | with: 76 | name: dsym-${{ matrix.scheme || 'default' }} 77 | path: artifacts/dsym-${{ matrix.scheme || 'default' }} 78 | if-no-files-found: warn 79 | 80 | - name: Upload package artifacts 81 | uses: actions/upload-artifact@v4 82 | with: 83 | name: packages-${{ matrix.scheme || 'default' }} 84 | path: artifacts/packages-${{ matrix.scheme || 'default' }} 85 | if-no-files-found: warn 86 | 87 | release: 88 | if: github.event_name == 'push' && github.ref == 'refs/heads/release' 89 | needs: build 90 | runs-on: macos-14 91 | 92 | steps: 93 | - name: Checkout repository 94 | uses: actions/checkout@v4 95 | 96 | - name: Download all package artifacts 97 | uses: actions/download-artifact@v4 98 | with: 99 | pattern: packages-* 100 | path: release-packages 101 | merge-multiple: true 102 | 103 | - name: Create release tag 104 | id: tag 105 | run: | 106 | # Read PACKAGE_VERSION from Makefile 107 | PACKAGE_VERSION=$(grep 'PACKAGE_VERSION' Makefile | cut -d' ' -f4) 108 | TAG_NAME="v$PACKAGE_VERSION" 109 | echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT 110 | 111 | - name: Create GitHub Release 112 | uses: softprops/action-gh-release@v1 113 | with: 114 | tag_name: ${{ steps.tag.outputs.tag_name }} 115 | name: Release ${{ steps.tag.outputs.tag_name }} 116 | body: | 117 | Automated build from release branch 118 | 119 | This release contains packages built with: 120 | - Default scheme 121 | - Rootless scheme 122 | - Roothide scheme 123 | 124 | Built on: ${{ github.sha }} 125 | files: release-packages/**/* 126 | draft: false 127 | prerelease: false 128 | -------------------------------------------------------------------------------- /AMLyrics.xm: -------------------------------------------------------------------------------- 1 | @import Darwin; 2 | @import Foundation; 3 | @import MediaPlayer; 4 | 5 | // Interface for Apple Music URL response 6 | @interface ICURLResponse : NSObject 7 | @property (nonatomic, readonly) NSData *bodyData; 8 | @end 9 | 10 | // Completion handler type for ICURLSession 11 | typedef void (^ICURLSessionCompletionHandler)(ICURLResponse *, NSError *); 12 | 13 | // Represents a single line of lyrics with timing information 14 | @interface MSVLyricsLine : NSObject 15 | @property (assign, nonatomic) NSTimeInterval startTime; 16 | @property (assign, nonatomic) NSTimeInterval endTime; 17 | @property (copy, nonatomic) NSAttributedString *lyricsText; 18 | @property (nonatomic, strong) MSVLyricsLine *nextLine; 19 | - (BOOL)containsTimeOffset:(NSTimeInterval)arg1 withErrorMargin:(NSTimeInterval)arg2; 20 | @end 21 | 22 | // Request context for Apple MusicKit 23 | @interface ICMusicKitRequestContext : NSObject 24 | @end 25 | 26 | // URL request for Apple MusicKit 27 | @interface ICMusicKitURLRequest : NSObject 28 | @property (nonatomic, copy, readonly) ICMusicKitRequestContext *requestContext; 29 | - (instancetype)initWithURL:(NSURL *)arg1 requestContext:(ICMusicKitRequestContext *)arg2; 30 | @end 31 | 32 | // Metadata for a content item (song) 33 | @interface MRContentItemMetadata : NSObject 34 | @property (assign, nonatomic) NSInteger iTunesStoreIdentifier; 35 | @property (assign, nonatomic) BOOL hasITunesStoreIdentifier; 36 | @property (copy, nonatomic) NSString *title; 37 | @property (copy, nonatomic) NSString *trackArtistName; 38 | @property (nonatomic, copy) NSString *amLyricsTitle; 39 | @property (assign, nonatomic) NSTimeInterval elapsedTime; 40 | @property (assign, nonatomic) BOOL lyricsAvailable; 41 | @property (assign, nonatomic) BOOL hasLyricsAvailable; 42 | @property (assign, nonatomic) NSInteger lyricsAdamID; 43 | @property (assign, nonatomic) BOOL hasLyricsAdamID; 44 | @end 45 | 46 | // Represents a content item (song) 47 | @interface MRContentItem : NSObject 48 | @property (nonatomic, copy) MRContentItemMetadata *metadata; 49 | @end 50 | 51 | // Now playing content item with additional properties 52 | @interface MPNowPlayingContentItem : MPContentItem 53 | @property (assign, nonatomic) NSInteger storeID; 54 | @property (nonatomic, strong) NSTimer *amlTimer; 55 | @property (assign, nonatomic) float playbackRate; 56 | - (NSTimeInterval)calculatedElapsedTime; 57 | - (void)setElapsedTime:(double)elapsedTime playbackRate:(float)arg2; 58 | @end 59 | 60 | // Parser for TTML lyrics data 61 | @interface MSVLyricsTTMLParser : NSObject 62 | - (instancetype)initWithTTMLData:(NSData *)data; 63 | - (NSArray *)lyricLines; 64 | - (id)parseWithError:(id*)arg1; 65 | @end 66 | 67 | // Apple MusicKit URL session 68 | @interface ICURLSession : NSObject 69 | - (void)enqueueDataRequest:(id)arg1 withCompletionHandler:(ICURLSessionCompletionHandler)arg2; 70 | @end 71 | 72 | // Now playing player client 73 | @interface MRNowPlayingPlayerClient : NSObject 74 | @property (nonatomic, readonly) MRContentItem *nowPlayingContentItem; 75 | - (void)sendContentItemChanges:(NSArray *)contentItems; 76 | @end 77 | 78 | // Private extension for now playing info center 79 | @interface MPNowPlayingInfoCenter (Private) 80 | - (MPNowPlayingContentItem *)nowPlayingContentItem; 81 | @end 82 | 83 | // Definition of a lyrics fetch task 84 | @interface LyricsTask : NSObject 85 | @property (nonatomic, assign) NSInteger iTunesStoreID; // iTunes Store identifier for the song 86 | @property (nonatomic, assign) NSInteger lyricsAdamID; // Lyrics Adam ID 87 | @property (nonatomic, assign) NSInteger retryCount; // Retry count for the task 88 | @property (nonatomic, strong) NSURL *lyricURL; // URL to fetch lyrics 89 | @property (nonatomic, strong) NSString *lyricsFilePath; // Local file path for cached lyrics 90 | @end 91 | 92 | @implementation LyricsTask 93 | @end 94 | 95 | // Global variables for lyrics management 96 | static dispatch_queue_t gLyricsQueue = nil; // Serial queue for lyrics operations 97 | static ICURLSession *gSession = nil; // Shared URL session 98 | static ICMusicKitRequestContext *gRequestContext = nil; // Shared request context 99 | static NSMutableArray *gLyricsTaskQueue = nil; // Task queue for lyrics fetching 100 | static NSMutableSet *gPendingLyricsIDs = nil; // Set of lyrics IDs currently being processed 101 | static BOOL gIsProcessingQueue = NO; // Whether the queue is being processed 102 | static NSString *gLyricsRootPath = nil; // Root directory for lyrics cache 103 | static NSInteger gLastLyricsAdamID = 0; // Last processed lyrics Adam ID 104 | static NSMutableDictionary *> *gLyricsCache = nil; // In-memory lyrics cache 105 | static pthread_mutex_t gLyricsCacheMutex = PTHREAD_MUTEX_INITIALIZER; // Mutex for lyrics cache 106 | static MPNowPlayingInfoCenter *gNowPlayingInfoCenter = nil; // Reference to now playing info center 107 | 108 | // Initialize and return the root path for lyrics cache directory 109 | static NSString *GetLyricsRootPath(void) { 110 | static dispatch_once_t onceToken; 111 | dispatch_once(&onceToken, ^{ 112 | gLyricsRootPath = [NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES) firstObject]; 113 | gLyricsRootPath = [gLyricsRootPath stringByAppendingPathComponent:@"AMLyrics"]; 114 | [[NSFileManager defaultManager] createDirectoryAtPath:gLyricsRootPath withIntermediateDirectories:YES attributes:nil error:nil]; 115 | }); 116 | return gLyricsRootPath; 117 | } 118 | 119 | // Parse lyrics data and store it in the in-memory cache 120 | static void ParseLyricsData(NSData *data, NSInteger iTunesStoreID, NSInteger lyricsAdamID, NSString *sourceHint) { 121 | if (!data || gLastLyricsAdamID == lyricsAdamID) { 122 | return; 123 | } 124 | pthread_mutex_lock(&gLyricsCacheMutex); 125 | // Check if lyrics for this song are already cached 126 | if (gLyricsCache[@(iTunesStoreID)]) { 127 | pthread_mutex_unlock(&gLyricsCacheMutex); 128 | return; 129 | } 130 | pthread_mutex_unlock(&gLyricsCacheMutex); 131 | NSError *parseError = nil; 132 | MSVLyricsTTMLParser *parser = [[%c(MSVLyricsTTMLParser) alloc] initWithTTMLData:data]; 133 | [parser parseWithError:&parseError]; 134 | if (parseError) { 135 | #if DEBUG 136 | NSLog(@"Error parsing lyrics: %@", parseError); 137 | #endif 138 | return; 139 | } 140 | NSMutableArray *lyricLines = [[parser lyricLines] mutableCopy]; 141 | #if DEBUG 142 | NSMutableArray *lyricData = [NSMutableArray array]; 143 | for (MSVLyricsLine *line in lyricLines) { 144 | if (line.lyricsText.string.length > 0) { 145 | [lyricData addObject:@{ 146 | @"startTime": @(line.startTime), 147 | @"endTime": @(line.endTime), 148 | @"text": line.lyricsText.string, 149 | }]; 150 | } 151 | } 152 | NSLog(@"Fetched %@ lyrics for item %lld: %@", sourceHint, (long long)lyricsAdamID, lyricData); 153 | #endif 154 | // Sort lyrics lines by start time 155 | [lyricLines sortUsingComparator:^NSComparisonResult(MSVLyricsLine *line1, MSVLyricsLine *line2) { 156 | if (line1.startTime < line2.startTime) { 157 | return NSOrderedAscending; 158 | } else if (line1.startTime > line2.startTime) { 159 | return NSOrderedDescending; 160 | } else { 161 | return NSOrderedSame; 162 | } 163 | }]; 164 | pthread_mutex_lock(&gLyricsCacheMutex); 165 | gLyricsCache[@(iTunesStoreID)] = [lyricLines copy]; 166 | pthread_mutex_unlock(&gLyricsCacheMutex); 167 | gLastLyricsAdamID = lyricsAdamID; 168 | } 169 | 170 | // Process the next lyrics fetch task in the queue 171 | static void ProcessNextTask(void) { 172 | if (!gSession || !gRequestContext || [gLyricsTaskQueue count] == 0) { 173 | gIsProcessingQueue = NO; 174 | return; 175 | } 176 | 177 | gIsProcessingQueue = YES; 178 | LyricsTask *task = [gLyricsTaskQueue firstObject]; 179 | [gLyricsTaskQueue removeObjectAtIndex:0]; 180 | 181 | ICMusicKitURLRequest *request = [[%c(ICMusicKitURLRequest) alloc] initWithURL:task.lyricURL requestContext:gRequestContext]; 182 | [gSession enqueueDataRequest:request withCompletionHandler:^(ICURLResponse *response, NSError *error) { 183 | dispatch_async(gLyricsQueue, ^{ 184 | BOOL taskFailed = NO; 185 | 186 | if (error) { 187 | #if DEBUG 188 | NSLog(@"Error fetching lyrics (retry %ld): %@", (long)task.retryCount, error); 189 | #endif 190 | taskFailed = YES; 191 | } else if (![response.bodyData isKindOfClass:[NSData class]]) { 192 | #if DEBUG 193 | NSLog(@"Invalid response body (retry %ld)", (long)task.retryCount); 194 | #endif 195 | taskFailed = YES; 196 | } else { 197 | // Parse JSON response and extract TTML lyrics string 198 | id object = [NSJSONSerialization JSONObjectWithData:response.bodyData options:0 error:nil]; 199 | if ([object isKindOfClass:[NSDictionary class]]) { 200 | if (((NSDictionary *)object)[@"data"]) { 201 | object = ((NSDictionary *)object)[@"data"]; 202 | if ([object isKindOfClass:[NSArray class]]) { 203 | object = ((NSArray *)object).firstObject; 204 | if ([object isKindOfClass:[NSDictionary class]]) { 205 | object = ((NSDictionary *)object)[@"attributes"]; 206 | if ([object isKindOfClass:[NSDictionary class]]) { 207 | object = ((NSDictionary *)object)[@"ttml"]; 208 | } 209 | } 210 | } 211 | } else if (((NSDictionary *)object)[@"ttml"]) { 212 | object = ((NSDictionary *)object)[@"ttml"]; 213 | } 214 | } 215 | 216 | if (![object isKindOfClass:[NSString class]]) { 217 | #if DEBUG 218 | NSLog(@"Invalid ttml data format (retry %ld): %@", (long)task.retryCount, object); 219 | #endif 220 | taskFailed = YES; 221 | } else { 222 | NSData *data = [(NSString *)object dataUsingEncoding:NSUTF8StringEncoding]; 223 | if (data) { 224 | // Save lyrics data to cache file 225 | [data writeToFile:task.lyricsFilePath atomically:YES]; 226 | // Parse and cache lyrics 227 | ParseLyricsData(data, task.iTunesStoreID, task.lyricsAdamID, @"fetched"); 228 | } else { 229 | taskFailed = YES; 230 | } 231 | } 232 | } 233 | 234 | if (taskFailed) { 235 | task.retryCount++; 236 | if (task.retryCount < 3) { 237 | // Retry if not reached max retry count, re-add to queue 238 | [gLyricsTaskQueue addObject:task]; 239 | #if DEBUG 240 | NSLog(@"Requeuing task for lyrics ID %lld, retry count: %ld", 241 | (long long)task.lyricsAdamID, (long)task.retryCount); 242 | #endif 243 | } else { 244 | // Give up after 3 retries 245 | #if DEBUG 246 | NSLog(@"Giving up on lyrics ID %lld after 3 retries", (long long)task.lyricsAdamID); 247 | [gPendingLyricsIDs removeObject:@(task.lyricsAdamID)]; 248 | #endif 249 | } 250 | } else { 251 | // Task completed successfully 252 | [gPendingLyricsIDs removeObject:@(task.lyricsAdamID)]; 253 | } 254 | 255 | // Process the next task in the queue 256 | ProcessNextTask(); 257 | }); 258 | }]; 259 | } 260 | 261 | // Add a new lyrics fetch task to the queue 262 | static void AddTaskToQueue(NSInteger iTunesStoreID, NSInteger lyricsAdamID, NSURL *lyricURL, NSString *lyricsFilePath) { 263 | // Lazy initialization of queue and set 264 | static dispatch_once_t onceToken; 265 | dispatch_once(&onceToken, ^{ 266 | gLyricsTaskQueue = [NSMutableArray array]; 267 | gPendingLyricsIDs = [NSMutableSet set]; 268 | }); 269 | 270 | // Check if the task is already in the queue 271 | if ([gPendingLyricsIDs containsObject:@(lyricsAdamID)]) { 272 | #if DEBUG 273 | NSLog(@"Task for lyrics ID %lld already in queue", (long long)lyricsAdamID); 274 | #endif 275 | return; 276 | } 277 | 278 | // Create and add new task 279 | LyricsTask *task = [[LyricsTask alloc] init]; 280 | task.iTunesStoreID = iTunesStoreID; 281 | task.lyricsAdamID = lyricsAdamID; 282 | task.retryCount = 0; 283 | task.lyricURL = lyricURL; 284 | task.lyricsFilePath = lyricsFilePath; 285 | 286 | [gLyricsTaskQueue addObject:task]; 287 | [gPendingLyricsIDs addObject:@(lyricsAdamID)]; 288 | 289 | // Start processing if not already running 290 | if (!gIsProcessingQueue) { 291 | ProcessNextTask(); 292 | } 293 | } 294 | 295 | %group AMLyricsPrimary 296 | 297 | %hook ICURLSession 298 | 299 | // Hook to capture session and request context for lyrics fetching 300 | - (void)enqueueDataRequest:(id)arg1 withCompletionHandler:(ICURLSessionCompletionHandler)arg2 { 301 | if (!gSession) { 302 | gSession = self; 303 | } 304 | if (!gRequestContext) { 305 | if ([arg1 isKindOfClass:%c(ICMusicKitURLRequest)]) { 306 | ICMusicKitURLRequest *req = arg1; 307 | gRequestContext = [req requestContext]; 308 | } 309 | } 310 | %orig; 311 | } 312 | 313 | %end 314 | 315 | %hook MRNowPlayingPlayerClient 316 | 317 | // Hook to detect content item changes and trigger lyrics fetching 318 | - (void)sendContentItemChanges:(NSArray *)contentItems { 319 | %orig; 320 | dispatch_async(gLyricsQueue, ^{ 321 | 322 | MRContentItem *item = self.nowPlayingContentItem; 323 | if (!item.metadata.lyricsAvailable) { 324 | return; 325 | } 326 | 327 | NSInteger iTunesStoreID = item.metadata.iTunesStoreIdentifier; 328 | if (iTunesStoreID <= 0) { 329 | return; 330 | } 331 | 332 | NSInteger lyricsAdamID; 333 | NSString *lyricURLString; 334 | NSString *languageCode = [[NSLocale preferredLanguages] firstObject]; 335 | if ([item.metadata respondsToSelector:@selector(lyricsAdamID)]) { 336 | lyricsAdamID = item.metadata.lyricsAdamID; 337 | lyricURLString = [NSString stringWithFormat:@"https://amp-api.music.apple.com/v1/catalog/cn/songs/%lld/syllable-lyrics?l=%@", (long long)lyricsAdamID, languageCode]; 338 | } else { 339 | lyricsAdamID = iTunesStoreID; 340 | lyricURLString = [NSString stringWithFormat:@"https://se2.itunes.apple.com/WebObjects/MZStoreElements2.woa/wa/ttmlLyrics?id=%lld&l=%@", (long long)iTunesStoreID, languageCode]; 341 | } 342 | if (lyricsAdamID <= 0) { 343 | return; 344 | } 345 | 346 | NSString *lyricsRoot = GetLyricsRootPath(); 347 | NSString *lyricsFilePath = [lyricsRoot stringByAppendingPathComponent:[NSString stringWithFormat:@"syllable-lyrics_%lld.xml", (long long)lyricsAdamID]]; 348 | BOOL lyricsCacheExists = [[NSFileManager defaultManager] fileExistsAtPath:lyricsFilePath]; 349 | if (lyricsCacheExists) { 350 | NSData *cachedData = [NSData dataWithContentsOfFile:lyricsFilePath]; 351 | ParseLyricsData(cachedData, iTunesStoreID, lyricsAdamID, @"cached"); 352 | return; 353 | } 354 | 355 | if (!gSession || !gRequestContext) { 356 | return; 357 | } 358 | 359 | NSURL *lyricURL = [NSURL URLWithString:lyricURLString]; 360 | 361 | // Add fetch task to queue 362 | AddTaskToQueue(iTunesStoreID, lyricsAdamID, lyricURL, lyricsFilePath); 363 | }); 364 | } 365 | 366 | %end 367 | 368 | %hook MPNowPlayingInfoCenter 369 | 370 | // Hook to capture reference to now playing info center 371 | - (MPNowPlayingContentItem *)nowPlayingContentItem { 372 | if (!gNowPlayingInfoCenter) { 373 | gNowPlayingInfoCenter = self; 374 | } 375 | return %orig; 376 | } 377 | 378 | %end 379 | 380 | %hook MPNowPlayingContentItem 381 | 382 | %property (nonatomic, strong) NSTimer *amlTimer; 383 | 384 | // Clean up timer on deallocation 385 | - (void)dealloc { 386 | [self.amlTimer invalidate]; 387 | self.amlTimer = nil; 388 | %orig; 389 | } 390 | 391 | // Timer callback for updating lyrics display 392 | %new 393 | - (void)amlTimerFired:(NSTimer *)timer { 394 | #if DEBUG 395 | NSLog(@"→ amlTimerFired: item %@ timer %@", self, timer); 396 | #endif 397 | if (!self.storeID || gNowPlayingInfoCenter.nowPlayingContentItem.storeID != self.storeID) { 398 | [timer invalidate]; 399 | self.amlTimer = nil; 400 | return; 401 | } 402 | double elapsedTime = [self calculatedElapsedTime]; 403 | if (elapsedTime < 0) { 404 | elapsedTime = 0; 405 | } 406 | [self setElapsedTime:elapsedTime playbackRate:self.playbackRate]; 407 | } 408 | 409 | // Hook to update lyrics and schedule next timer when playback time changes 410 | - (void)setElapsedTime:(double)elapsedTime playbackRate:(float)playbackRate { 411 | %orig; 412 | [self.amlTimer invalidate]; 413 | self.amlTimer = nil; 414 | if (!self.storeID) { 415 | return; 416 | } 417 | NSString *title = nil; 418 | MSVLyricsLine *nextLine = nil; 419 | pthread_mutex_lock(&gLyricsCacheMutex); 420 | NSArray *lyricLines = gLyricsCache[@(self.storeID)]; 421 | for (MSVLyricsLine *line in [lyricLines reverseObjectEnumerator]) { 422 | if (elapsedTime >= line.startTime) { 423 | title = line.lyricsText.string; 424 | nextLine = line.nextLine; 425 | break; 426 | } 427 | } 428 | pthread_mutex_unlock(&gLyricsCacheMutex); 429 | if (nextLine.startTime > elapsedTime) { 430 | self.amlTimer = [NSTimer scheduledTimerWithTimeInterval:(nextLine.startTime - elapsedTime) / playbackRate 431 | target:self 432 | selector:@selector(amlTimerFired:) 433 | userInfo:nil 434 | repeats:NO]; 435 | } else { 436 | self.amlTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 437 | target:self 438 | selector:@selector(amlTimerFired:) 439 | userInfo:nil 440 | repeats:NO]; 441 | } 442 | if (title) { 443 | [self setTitle:title]; 444 | } 445 | } 446 | 447 | // Hook to update track artist name display 448 | - (void)setTrackArtistName:(NSString *)trackArtistName { 449 | if (!trackArtistName || trackArtistName.length == 0) { 450 | %orig; 451 | return; 452 | } 453 | %orig([NSString stringWithFormat:@"%@ — %@", self.title, trackArtistName]); 454 | } 455 | 456 | %end 457 | 458 | %end 459 | 460 | %group AMCrashPatcher 461 | 462 | // Patch to prevent crash in VSSubscriptionRegistrationCenter 463 | %hook VSSubscriptionRegistrationCenter 464 | 465 | - (void)registerSubscription:(id)arg1 { 466 | return; 467 | } 468 | 469 | %end 470 | 471 | %end 472 | 473 | // Entry point for the tweak, initialize global state and groups 474 | %ctor { 475 | dlopen("/System/Library/Frameworks/VideoSubscriberAccount.framework/VideoSubscriberAccount", RTLD_NOW); 476 | %init(AMCrashPatcher); 477 | %init(AMLyricsPrimary); 478 | static dispatch_once_t onceToken; 479 | dispatch_once(&onceToken, ^{ 480 | gLyricsCache = [[NSMutableDictionary alloc] init]; 481 | gLyricsQueue = dispatch_queue_create("com.82flex.amlyrics.queue", DISPATCH_QUEUE_SERIAL); 482 | gLyricsTaskQueue = [NSMutableArray array]; 483 | gPendingLyricsIDs = [NSMutableSet set]; 484 | }); 485 | } 486 | --------------------------------------------------------------------------------