diff --git a/Sora.xcodeproj/project.pbxproj b/Sora.xcodeproj/project.pbxproj index 5e1c507..6b33c66 100644 --- a/Sora.xcodeproj/project.pbxproj +++ b/Sora.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B7F4C02D58FFDD0045714A /* Shimmer.swift */; }; 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */; }; 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */; }; + 13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBA0872D60F19C00EFE70A /* VTTSubtitlesLoader.swift */; }; 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBEFD92D5F7D1200D011EE /* String.swift */; }; 13D842522D4523B800EBBFA6 /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 13D842512D4523B800EBBFA6 /* Drops */; }; 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D842542D45267500EBBFA6 /* DropManager.swift */; }; @@ -88,6 +89,7 @@ 13B7F4C02D58FFDD0045714A /* Shimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shimmer.swift; sourceTree = ""; }; 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingManager.swift; sourceTree = ""; }; 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingItem.swift; sourceTree = ""; }; + 13CBA0872D60F19C00EFE70A /* VTTSubtitlesLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VTTSubtitlesLoader.swift; sourceTree = ""; }; 13CBEFD92D5F7D1200D011EE /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 13D842542D45267500EBBFA6 /* DropManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropManager.swift; sourceTree = ""; }; 13D99CF62D4E73C300250A86 /* ModuleAdditionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleAdditionSettingsView.swift; sourceTree = ""; }; @@ -360,6 +362,7 @@ children = ( 13EA2BD22D32D97400C1EBD7 /* Components */, 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */, + 13CBA0872D60F19C00EFE70A /* VTTSubtitlesLoader.swift */, ); path = CustomPlayer; sourceTree = ""; @@ -474,6 +477,7 @@ 133D7C702D2BE2500075467E /* ContentView.swift in Sources */, 13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */, 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */, + 13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */, 13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */, 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, 133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */, diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index d36d618..5400426 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -5,8 +5,8 @@ // Created by Francesco on 20/12/24. // -import SwiftUI import AVKit +import SwiftUI struct CustomVideoPlayer: UIViewControllerRepresentable { let player: AVPlayer @@ -35,15 +35,35 @@ struct CustomMediaPlayer: View { @State private var timeObserverToken: Any? @State private var isVideoLoaded = false @State private var showWatchNextButton = true + @ObservedObject private var subtitlesLoader = VTTSubtitlesLoader() + + @AppStorage("subtitleForegroundColor") private var subtitleForegroundColor: String = "white" + @AppStorage("subtitleBackgroundEnabled") private var subtitleBackgroundEnabled: Bool = true + @AppStorage("subtitleFontSize") private var subtitleFontSize: Double = 20.0 + @AppStorage("subtitleShadowRadius") private var subtitleShadowRadius: Double = 1.0 + + private var subtitleFGColor: Color { + switch subtitleForegroundColor { + case "white": return Color.white + case "yellow": return Color.yellow + case "green": return Color.green + case "purple": return Color.purple + case "blue": return Color.blue + case "red": return Color.red + default: return Color.white + } + } + @Environment(\.presentationMode) var presentationMode let module: ScrapingModule let fullUrl: String let title: String let episodeNumber: Int + let subtitlesURL: String? let onWatchNext: () -> Void - init(module: ScrapingModule, urlString: String, fullUrl: String, title: String, episodeNumber: Int, onWatchNext: @escaping () -> Void) { + init(module: ScrapingModule, urlString: String, fullUrl: String, title: String, episodeNumber: Int, onWatchNext: @escaping () -> Void, subtitlesURL: String?) { guard let url = URL(string: urlString) else { fatalError("Invalid URL string") } @@ -61,6 +81,7 @@ struct CustomMediaPlayer: View { self.title = title self.episodeNumber = episodeNumber self.onWatchNext = onWatchNext + self.subtitlesURL = subtitlesURL ?? "" let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") if lastPlayedTime > 0 { @@ -85,6 +106,10 @@ struct CustomMediaPlayer: View { startUpdatingCurrentTime() setInitialPlayerRate() addPeriodicTimeObserver(fullURL: fullUrl) + + if let url = subtitlesURL, !url.isEmpty { + subtitlesLoader.load(from: url) + } } .edgesIgnoringSafeArea(.all) .overlay( @@ -141,6 +166,20 @@ struct CustomMediaPlayer: View { } } + VStack { + Spacer() + if let currentCue = subtitlesLoader.cues.first(where: { currentTime >= $0.startTime && currentTime <= $0.endTime }) { + Text(currentCue.text) + .font(.system(size: CGFloat(subtitleFontSize))) + .multilineTextAlignment(.center) + .padding(8) + .background(subtitleBackgroundEnabled ? Color.black.opacity(0.6) : Color.clear) + .foregroundColor(subtitleFGColor) + .cornerRadius(5) + .shadow(color: Color.black, radius: CGFloat(subtitleShadowRadius)) + } + } + VStack { Spacer() VStack { @@ -202,6 +241,38 @@ struct CustomMediaPlayer: View { .foregroundColor(.white) .font(.system(size: 15)) } + Menu { + Menu("Subtitle Foreground Color") { + Button("White") { subtitleForegroundColor = "white" } + Button("Yellow") { subtitleForegroundColor = "yellow" } + Button("Green") { subtitleForegroundColor = "green" } + Button("Blue") { subtitleForegroundColor = "blue" } + Button("Red") { subtitleForegroundColor = "red" } + Button("Purple") { subtitleForegroundColor = "purple" } + } + Menu("Subtitle Font Size") { + Button("16") { subtitleFontSize = 16 } + Button("18") { subtitleFontSize = 18 } + Button("20") { subtitleFontSize = 20 } + Button("22") { subtitleFontSize = 22 } + Button("24") { subtitleFontSize = 24 } + } + Menu("Subtitle Shadow Intensity") { + Button("None") { subtitleShadowRadius = 0 } + Button("Low") { subtitleShadowRadius = 1 } + Button("Medium") { subtitleShadowRadius = 3 } + Button("High") { subtitleShadowRadius = 6 } + } + Button(action: { + subtitleBackgroundEnabled.toggle() + }) { + Text(subtitleBackgroundEnabled ? "Disable Background" : "Enable Background") + } + } label: { + Image(systemName: "text.bubble") + .foregroundColor(.white) + .font(.system(size: 15)) + } } } .padding(.trailing, 32) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/VTTSubtitlesLoader.swift b/Sora/Utils/MediaPlayer/CustomPlayer/VTTSubtitlesLoader.swift new file mode 100644 index 0000000..1015c95 --- /dev/null +++ b/Sora/Utils/MediaPlayer/CustomPlayer/VTTSubtitlesLoader.swift @@ -0,0 +1,85 @@ +// +// VTTSubtitlesLoader.swift +// Sora +// +// Created by Francesco on 15/02/25. +// + +import Combine +import Foundation + +struct SubtitleCue: Identifiable { + let id = UUID() + let startTime: Double + let endTime: Double + let text: String +} + +class VTTSubtitlesLoader: ObservableObject { + @Published var cues: [SubtitleCue] = [] + + func load(from urlString: String) { + guard let url = URL(string: urlString) else { return } + URLSession.custom.dataTask(with: url) { data, _, error in + guard let data = data, + let vttContent = String(data: data, encoding: .utf8), + error == nil else { return } + DispatchQueue.main.async { + self.cues = self.parseVTT(content: vttContent) + } + }.resume() + } + + private func parseVTT(content: String) -> [SubtitleCue] { + var cues: [SubtitleCue] = [] + let lines = content.components(separatedBy: .newlines) + var index = 0 + + while index < lines.count { + let line = lines[index].trimmingCharacters(in: .whitespaces) + if line.isEmpty || line == "WEBVTT" { + index += 1 + continue + } + + if !line.contains("-->") { + index += 1 + if index >= lines.count { break } + } + + let timeLine = lines[index] + let times = timeLine.components(separatedBy: "-->") + if times.count < 2 { + index += 1 + continue + } + + let startTime = parseTimecode(times[0].trimmingCharacters(in: .whitespaces)) + let endTime = parseTimecode(times[1].trimmingCharacters(in: .whitespaces)) + index += 1 + var cueText = "" + while index < lines.count && !lines[index].trimmingCharacters(in: .whitespaces).isEmpty { + cueText += lines[index] + "\n" + index += 1 + } + cues.append(SubtitleCue(startTime: startTime, endTime: endTime, text: cueText.trimmingCharacters(in: .whitespacesAndNewlines))) + } + return cues + } + + private func parseTimecode(_ timeString: String) -> Double { + let parts = timeString.components(separatedBy: ":") + var seconds = 0.0 + if parts.count == 3, + let h = Double(parts[0]), + let m = Double(parts[1]), + let s = Double(parts[2].replacingOccurrences(of: ",", with: ".")) { + seconds = h * 3600 + m * 60 + s + } else if parts.count == 2, + let m = Double(parts[0]), + let s = Double(parts[1].replacingOccurrences(of: ",", with: ".")) { + seconds = m * 60 + s + } + return seconds + } +}