├── .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 |
--------------------------------------------------------------------------------