| 
 | 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 | +}  | 
0 commit comments