Skip to content

Commit 635fa2f

Browse files
committed
Auto load timecoded lyrics from LRC or metadata
1 parent 24214d2 commit 635fa2f

File tree

5 files changed

+151
-0
lines changed

5 files changed

+151
-0
lines changed

iina.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@
317317
B4E446F725CB54EF0069F06E /* Mustache in Frameworks */ = {isa = PBXBuildFile; productRef = B4E446F625CB54EF0069F06E /* Mustache */; };
318318
B4E4470125CE3F930069F06E /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B4E4470025CE3F930069F06E /* Sparkle */; };
319319
C789872F1E34EF170005769F /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C78987311E34EF170005769F /* InfoPlist.strings */; };
320+
CE9628082EB3985000FF6CBF /* LyricsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9628072EB3985000FF6CBF /* LyricsManager.swift */; };
320321
D17504A82918F13E005C3DD0 /* OSCToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17504A72918F13E005C3DD0 /* OSCToolbarButton.swift */; };
321322
D17B857B2C94C8B5002A8EDE /* movist-v2-default-input.conf in Copy Configs */ = {isa = PBXBuildFile; fileRef = D1F68E742C6ABC22003D1208 /* movist-v2-default-input.conf */; };
322323
D1A4D98A2B1495270009AB4E /* LegacyMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A4D9892B1495270009AB4E /* LegacyMigration.swift */; };
@@ -1781,6 +1782,7 @@
17811782
C7DBA5EB1F07C5FD00C2B416 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InitialWindowController.strings; sourceTree = "<group>"; };
17821783
C7DC79CE1EC63821002DE23B /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/HistoryWindowController.strings; sourceTree = "<group>"; };
17831784
C7E90B522087EC5700A58B6B /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/SubChooseViewController.strings; sourceTree = "<group>"; };
1785+
CE9628072EB3985000FF6CBF /* LyricsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsManager.swift; sourceTree = "<group>"; };
17841786
D17504A72918F13E005C3DD0 /* OSCToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSCToolbarButton.swift; sourceTree = "<group>"; };
17851787
D1A4D9892B1495270009AB4E /* LegacyMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyMigration.swift; sourceTree = "<group>"; };
17861788
D1B4E24D2A3AFC9100E36F1D /* MiniPlayerWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniPlayerWindow.swift; sourceTree = "<group>"; };
@@ -2472,6 +2474,7 @@
24722474
348F71952DE2A814006C587D /* MPVCommandWrappers.swift */,
24732475
84C6D3611EAF8D63009BF721 /* HistoryController.swift */,
24742476
84FBCB361EEACDDD0076C77C /* FFmpegController.h */,
2477+
CE9628072EB3985000FF6CBF /* LyricsManager.swift */,
24752478
84FBCB371EEACDDD0076C77C /* FFmpegController.m */,
24762479
842904E11F0EC01600478376 /* AutoFileMatcher.swift */,
24772480
846121BC1F35FCA500ABB39C /* DraggingDetect.swift */,
@@ -3224,6 +3227,7 @@
32243227
84AABE941DBFAF1A00D138FD /* FontPickerWindowController.swift in Sources */,
32253228
844C59E71F7C143D008D1B00 /* CacheManager.swift in Sources */,
32263229
E34EAA83251A36CE00057F27 /* JavascriptAPIFile.swift in Sources */,
3230+
CE9628082EB3985000FF6CBF /* LyricsManager.swift in Sources */,
32273231
E32712B024EAA14500359DAB /* ScreenshootOSDView.swift in Sources */,
32283232
E3BA79EE2131443A00529D99 /* OpenURLWindowController.swift in Sources */,
32293233
E3FF5AC02938582F0019CE45 /* JavascriptDevTool.swift in Sources */,

iina/FFmpegController.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,11 @@
6666

6767
+ (nullable NSDictionary *)probeVideoInfoForFile:(nonnull NSString *)file;
6868

69+
/// Extract lyrics from media file metadata.
70+
///
71+
/// This method will open the file and search for lyrics in the metadata.
72+
/// - Parameter url: URL of the file to extract lyrics from.
73+
/// - Returns: The lyrics as a string, or null if no lyrics were found.
74+
+ (nullable NSString *)extractLyricsFromURL:(nonnull NSURL *)url;
75+
6976
@end

iina/FFmpegController.m

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,4 +809,36 @@ + (void)logFrame:(const AVCodec *)pCodec
809809
}
810810
#endif
811811

812+
// MARK: - Extracting Lyrics
813+
814+
+ (NSString *)extractLyricsFromURL:(nonnull NSURL *)url
815+
{
816+
AVFormatContext *pFormatCtx = NULL;
817+
818+
@try {
819+
int ret = avformat_open_input(&pFormatCtx, url.fileSystemRepresentation, NULL, NULL);
820+
if (ret < 0) {
821+
LOG_ERROR(@"Failed to open file %@ when searching for lyrics: %s (%d)", url, av_err2str(ret), ret);
822+
return NULL;
823+
}
824+
825+
AVDictionary *metadata = pFormatCtx->metadata;
826+
827+
AVDictionaryEntry *tag = av_dict_get(metadata, "lyrics", NULL, AV_DICT_IGNORE_SUFFIX);
828+
if (tag && tag->value && strlen(tag->value) > 0) {
829+
NSString *lyrics = [NSString stringWithCString:tag->value encoding:NSUTF8StringEncoding];
830+
if (lyrics && lyrics.length > 0) {
831+
LOG_DEBUG(@"Found lyrics in metadata tag '%s' for file: %@", tag->key, url);
832+
return lyrics;
833+
}
834+
}
835+
836+
LOG_DEBUG(@"No lyrics found in metadata for file: %@", url);
837+
return NULL;
838+
}
839+
@finally {
840+
avformat_close_input(&pFormatCtx);
841+
}
842+
}
843+
812844
@end

iina/LyricsManager.swift

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//
2+
// LyricsManager.swift
3+
// iina
4+
//
5+
// Copyright © 2025 lhc. All rights reserved.
6+
//
7+
8+
import Foundation
9+
10+
/// Manager for handling automatic loading of lyrics from various sources
11+
class LyricsManager {
12+
13+
private weak var player: PlayerCore?
14+
15+
init(player: PlayerCore) {
16+
self.player = player
17+
}
18+
19+
/// Attempts to automatically load lyrics for the current media file
20+
/// - Parameter url: The URL of the media file
21+
func autoLoadLyrics(for url: URL) {
22+
guard url.isFileURL else { return }
23+
24+
let pathWithoutExtension = url.deletingPathExtension().path
25+
26+
// First, try to load .lrc file with the same name
27+
let lrcPath = pathWithoutExtension + ".lrc"
28+
if FileManager.default.fileExists(atPath: lrcPath) {
29+
Logger.log("Found LRC file: \(lrcPath)", subsystem: player?.subsystem ?? Logger.Subsystem(rawValue: "lyrics"))
30+
loadLRCFile(at: URL(fileURLWithPath: lrcPath))
31+
return
32+
}
33+
34+
// If no .lrc file found, try to extract lyrics from metadata
35+
extractAndLoadLyricsFromMetadata(for: url)
36+
}
37+
38+
/// Loads an external .lrc file as subtitles
39+
/// - Parameter url: The URL of the .lrc file
40+
private func loadLRCFile(at url: URL) {
41+
guard let player = player else { return }
42+
43+
Logger.log("Loading LRC file as subtitles: \(url.path)", subsystem: player.subsystem)
44+
player.loadExternalSubFile(url)
45+
}
46+
47+
/// Extracts lyrics from metadata and loads them as subtitles
48+
/// - Parameter url: The URL of the media file
49+
private func extractAndLoadLyricsFromMetadata(for url: URL) {
50+
guard let player = player else { return }
51+
52+
guard let lyrics = FFmpegController.extractLyrics(from: url) else {
53+
Logger.log("No lyrics found in metadata for: \(url.path)", level: .verbose, subsystem: player.subsystem)
54+
return
55+
}
56+
57+
guard isValidLRC(lyrics) else {
58+
Logger.log("Extracted lyrics are not in valid LRC format", level: .warning, subsystem: player.subsystem)
59+
return
60+
}
61+
62+
Logger.log("Found valid lyrics in metadata, creating temporary LRC file", subsystem: player.subsystem)
63+
64+
let tempDir = Utility.tempDirURL
65+
let tempFileName = url.deletingPathExtension().lastPathComponent + ".lrc"
66+
let tempLRCURL = tempDir.appendingPathComponent(tempFileName)
67+
68+
do {
69+
try lyrics.write(to: tempLRCURL, atomically: true, encoding: .utf8)
70+
Logger.log("Created temporary LRC file: \(tempLRCURL.path)", subsystem: player.subsystem)
71+
} catch {
72+
Logger.log("Failed to create temporary LRC file: \(error.localizedDescription)", level: .error, subsystem: player.subsystem)
73+
}
74+
75+
loadLRCFile(at: tempLRCURL)
76+
77+
do {
78+
try FileManager.default.removeItem(at: tempLRCURL)
79+
Logger.log("Cleaned up temporary LRC file", level: .verbose, subsystem: player.subsystem)
80+
} catch {
81+
Logger.log("Failed to create temporary LRC file: \(error.localizedDescription)", level: .error, subsystem: player.subsystem)
82+
}
83+
}
84+
85+
/// Validates if the provided string is in valid LRC format according to MPV
86+
/// - Parameter content: The string content to validate
87+
/// - Returns: True if the content contains valid LRC timestamps
88+
private func isValidLRC(_ content: String) -> Bool {
89+
// Empty lines are skipped by MPV, but whitespace is not
90+
guard let firstLine = content.split(separator: "\n", omittingEmptySubsequences: true).first else {
91+
return false
92+
}
93+
94+
// [mm:ss.xx] or [mm:ss.xxx] or [mm:ss]
95+
let timestampPattern = #"^\[\d{1,2}:\d{2}(?:\.\d{2,3})?\]"#
96+
// [tag:data]
97+
let tagPattern = #"^\[[^:\]]+:[^\]]*\]"#
98+
99+
let isTimestamp = firstLine.range(of: timestampPattern, options: .regularExpression) != nil
100+
let isTag = firstLine.range(of: tagPattern, options: .regularExpression) != nil
101+
return isTimestamp || isTag
102+
}
103+
}

iina/PlayerCore.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ class PlayerCore: NSObject {
204204
return controller
205205
}()
206206

207+
lazy var lyricsManager: LyricsManager = LyricsManager(player: self)
208+
207209
lazy var info: PlaybackInfo = PlaybackInfo(self)
208210

209211
var syncUITimer: Timer?
@@ -2042,6 +2044,9 @@ class PlayerCore: NSObject {
20422044
if Preference.bool(for: .recordRecentFiles) && Preference.bool(for: .trackAllFilesInRecentOpenMenu) {
20432045
AppDelegate.shared.noteNewRecentDocumentURL(url)
20442046
}
2047+
2048+
// Auto-load lyrics if available
2049+
lyricsManager.autoLoadLyrics(for: url)
20452050
}
20462051
postNotification(.iinaFileLoaded)
20472052
events.emit(.fileLoaded, data: info.currentURL?.absoluteString ?? "")

0 commit comments

Comments
 (0)