From ab80261b1c7df70014adca37badb0d98355cb412 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Sun, 23 Feb 2025 11:01:20 +0100 Subject: [PATCH] =?UTF-8?q?had=20to=20rewrite=20ts=20=F0=9F=98=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CustomPlayer/CustomPlayer.swift | 874 +++++++++++------- Sora/Utils/MediaPlayer/VideoPlayer.swift | 3 +- Sora/Views/HomeView.swift | 8 +- Sora/Views/MediaInfoView/MediaInfoView.swift | 33 +- 4 files changed, 561 insertions(+), 357 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 7d84f32..ac53500 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -1,390 +1,572 @@ // -// ContentView.swift +// CustomPlayer.swift // test2 // -// Created by Francesco on 20/12/24. +// Created by Francesco on 23/02/25. // +import UIKit import AVKit import SwiftUI +import AVFoundation -struct CustomVideoPlayer: UIViewControllerRepresentable { - let player: AVPlayer - - func makeUIViewController(context: Context) -> AVPlayerViewController { - let controller = NormalPlayer() - controller.player = player - controller.showsPlaybackControls = false - player.play() - return controller - } - - func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { - // yes? Like the plural of the famous american rapper ye? -IBHRAD - // low taper fade the meme is massive -cranci - } +class SliderViewModel: ObservableObject { + @Published var sliderValue: Double = 0.0 } -struct CustomMediaPlayer: View { - @State private var player: AVPlayer - @State private var isPlaying = true - @State private var currentTime: Double = 0.0 - @State private var duration: Double = 0.0 - @State private var showControls = false - @State private var inactivityTimer: Timer? - @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 - +class CustomMediaPlayerViewController: UIViewController { let module: ScrapingModule let streamURL: String let fullUrl: String - let title: String + let titleText: String let episodeNumber: Int let episodeImageUrl: String let subtitlesURL: String? let onWatchNext: () -> Void - init(module: ScrapingModule, urlString: String, fullUrl: String, title: String, episodeNumber: Int, onWatchNext: @escaping () -> Void, subtitlesURL: String?, episodeImageUrl: String) { - guard let url = URL(string: urlString) else { - fatalError("Invalid URL string") - } - - var request = URLRequest(url: url) - if urlString.contains("ascdn") { - request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") - } - - let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) - _player = State(initialValue: AVPlayer(playerItem: AVPlayerItem(asset: asset))) + var player: AVPlayer! + var timeObserverToken: Any? + var inactivityTimer: Timer? + var updateTimer: Timer? + + var isPlaying = true + var currentTimeVal: Double = 0.0 + var duration: Double = 0.0 + var isVideoLoaded = false + var showWatchNextButton = true + + var subtitleForegroundColor: String = "white" + var subtitleBackgroundEnabled: Bool = true + var subtitleFontSize: Double = 20.0 + var subtitleShadowRadius: Double = 1.0 + var subtitlesLoader = VTTSubtitlesLoader() + + var playerViewController: AVPlayerViewController! + var controlsContainerView: UIView! + var playPauseButton: UIImageView! + var backwardButton: UIImageView! + var forwardButton: UIImageView! + var subtitleLabel: UILabel! + var dismissButton: UIButton! + var menuButton: UIButton! + var watchNextButton: UIButton! + var blackCoverView: UIView! + var speedButton: UIButton! + + var sliderHostingController: UIHostingController>? + var sliderViewModel = SliderViewModel() + var isSliderEditing = false + + init(module: ScrapingModule, + urlString: String, + fullUrl: String, + title: String, + episodeNumber: Int, + onWatchNext: @escaping () -> Void, + subtitlesURL: String?, + episodeImageUrl: String) { self.module = module self.streamURL = urlString self.fullUrl = fullUrl - self.title = title + self.titleText = title self.episodeNumber = episodeNumber self.episodeImageUrl = episodeImageUrl self.onWatchNext = onWatchNext - self.subtitlesURL = subtitlesURL ?? "" + self.subtitlesURL = subtitlesURL + + super.init(nibName: nil, bundle: nil) + + guard let url = URL(string: urlString) else { + fatalError("Invalid URL string") + } + var request = URLRequest(url: url) + if urlString.contains("ascdn") { + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") + } + let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) + let playerItem = AVPlayerItem(asset: asset) + self.player = AVPlayer(playerItem: playerItem) let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") if lastPlayedTime > 0 { let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1) - self._player.wrappedValue.seek(to: seekTime) + self.player.seek(to: seekTime) } } - var body: some View { - ZStack { - VStack { - ZStack { - CustomVideoPlayer(player: player) - .onAppear { - player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 600), queue: .main) { time in - currentTime = time.seconds - if let itemDuration = player.currentItem?.duration.seconds, itemDuration.isFinite && !itemDuration.isNaN { - duration = itemDuration - isVideoLoaded = true - } - } - startUpdatingCurrentTime() - setInitialPlayerRate() - addPeriodicTimeObserver(fullURL: fullUrl) - - if let url = subtitlesURL, !url.isEmpty { - subtitlesLoader.load(from: url) - } - } - .edgesIgnoringSafeArea(.all) - .overlay( - Group { - if showControls { - Color.black.opacity(0.5) - .edgesIgnoringSafeArea(.all) - HStack(spacing: 20) { - Button(action: { - currentTime = max(currentTime - 10, 0) - player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600)) - }) { - Image(systemName: "gobackward.10") - } - .foregroundColor(.white) - .font(.system(size: 25)) - .contentShape(Rectangle()) - .frame(width: 60, height: 60) - - Button(action: { - if isPlaying { - player.pause() - } else { - player.play() - } - isPlaying.toggle() - }) { - Image(systemName: isPlaying ? "pause.fill" : "play.fill") - } - .foregroundColor(.white) - .font(.system(size: 45)) - .contentShape(Rectangle()) - .frame(width: 80, height: 80) - - Button(action: { - currentTime = min(currentTime + 10, duration) - player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600)) - }) { - Image(systemName: "goforward.10") - } - .foregroundColor(.white) - .font(.system(size: 25)) - .contentShape(Rectangle()) - .frame(width: 60, height: 60) - } - } - } - .animation(.easeInOut(duration: 0.2), value: showControls), - alignment: .center - ) - .onTapGesture { - withAnimation { - showControls.toggle() - } - } - - VStack { - Spacer() - if let currentCue = subtitlesLoader.cues.first(where: { currentTime >= $0.startTime && currentTime <= $0.endTime }) { - Text(currentCue.text.strippedHTML) - .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)) - .padding(.bottom, showControls ? 80 : 0) - } - } - - VStack { - Spacer() - VStack { - HStack(alignment: .bottom) { - if showControls { - VStack(alignment: .leading) { - Text("Episode \(episodeNumber)") - .font(.subheadline) - .foregroundColor(.gray) - Text(title) - .font(.headline) - .foregroundColor(.white) - } - .padding(.horizontal, 32) - } - Spacer() - if duration - currentTime <= duration * 0.10 && currentTime != duration && showWatchNextButton && duration != 0 { - Button(action: { - player.pause() - presentationMode.wrappedValue.dismiss() - onWatchNext() - }) { - HStack { - Image(systemName: "forward.fill") - .foregroundColor(Color.black) - Text("Watch Next") - .font(.headline) - .foregroundColor(Color.black) - } - .padding() - .background(Color.white) - .cornerRadius(32) - } - .padding(.trailing, 10) - .onAppear { - if UserDefaults.standard.bool(forKey: "hideNextButton") { - DispatchQueue.main.asyncAfter(deadline: .now() + 5) { - showWatchNextButton = false - } - } - } - } - if showControls { - Menu { - Menu("Playback Speed") { - ForEach([0.5, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { speed in - Button(action: { - player.rate = Float(speed) - if player.timeControlStatus != .playing { - player.pause() - } - }) { - Text("\(speed, specifier: "%.2f")") - } - } - } - } label: { - Image(systemName: "ellipsis.circle") - .foregroundColor(.white) - .font(.headline) - } - if let url = subtitlesURL, !url.isEmpty { - 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(.headline) - } - } - } - } - .padding(.trailing, 32) - - if showControls { - MusicProgressSlider( - value: $currentTime, - inRange: 0...duration, - activeFillColor: .white, - fillColor: .white.opacity(0.5), - emptyColor: .white.opacity(0.3), - height: 28, - onEditingChanged: { editing in - if !editing && isVideoLoaded { - player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600)) - } - } - ) - .padding(.horizontal, 32) - .padding(.bottom, 6) - .disabled(!isVideoLoaded) - } - } - } - .onAppear { - startUpdatingCurrentTime() - } - .onDisappear { - UserDefaults.standard.set(player.rate, forKey: "lastPlaybackSpeed") - player.pause() - inactivityTimer?.invalidate() - if let timeObserverToken = timeObserverToken { - player.removeTimeObserver(timeObserverToken) - self.timeObserverToken = nil - } - - if let currentItem = player.currentItem, currentItem.duration.seconds > 0 { - let currentTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") - let duration = currentItem.duration.seconds - let progress = currentTime / duration - let item = ContinueWatchingItem( - id: UUID(), - imageUrl: episodeImageUrl, - episodeNumber: episodeNumber, - mediaTitle: title, - progress: progress, - streamUrl: streamURL, - fullUrl: fullUrl, - subtitles: subtitlesURL, - module: module - ) - ContinueWatchingManager.shared.save(item: item) - } - } - } - } - VStack { - if showControls { - HStack { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Image(systemName: "xmark") - .foregroundColor(.white) - .font(.system(size: 20)) - } - .frame(width: 60, height: 60) - .contentShape(Rectangle()) - .padding() - Spacer() - } - Spacer() + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + setupPlayerViewController() + setupControls() + setupSubtitleLabel() + setupDismissButton() + setupSpeedButton() + setupMenuButton() + addTimeObserver() + startUpdateTimer() + + player.play() + + if let url = subtitlesURL, !url.isEmpty { + subtitlesLoader.load(from: url) + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + player.pause() + updateTimer?.invalidate() + inactivityTimer?.invalidate() + if let token = timeObserverToken { + player.removeTimeObserver(token) + timeObserverToken = nil + } + UserDefaults.standard.set(player.rate, forKey: "lastPlaybackSpeed") + if let currentItem = player.currentItem, currentItem.duration.seconds > 0 { + let progress = currentTimeVal / currentItem.duration.seconds + let item = ContinueWatchingItem( + id: UUID(), + imageUrl: episodeImageUrl, + episodeNumber: episodeNumber, + mediaTitle: titleText, + progress: progress, + streamUrl: streamURL, + fullUrl: fullUrl, + subtitles: subtitlesURL, + module: module + ) + ContinueWatchingManager.shared.save(item: item) + } + } + + func setupPlayerViewController() { + playerViewController = AVPlayerViewController() + playerViewController.player = player + playerViewController.showsPlaybackControls = false + addChild(playerViewController) + view.addSubview(playerViewController.view) + playerViewController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + playerViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + playerViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + playerViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + playerViewController.didMove(toParent: self) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleControls)) + view.addGestureRecognizer(tapGesture) + } + + func setupControls() { + controlsContainerView = UIView() + controlsContainerView.backgroundColor = UIColor.black.withAlphaComponent(0.0) + view.addSubview(controlsContainerView) + controlsContainerView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + controlsContainerView.topAnchor.constraint(equalTo: view.topAnchor), + controlsContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + controlsContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + controlsContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + blackCoverView = UIView() + blackCoverView.backgroundColor = UIColor.black.withAlphaComponent(0.4) + blackCoverView.translatesAutoresizingMaskIntoConstraints = false + controlsContainerView.insertSubview(blackCoverView, at: 0) + NSLayoutConstraint.activate([ + blackCoverView.topAnchor.constraint(equalTo: controlsContainerView.topAnchor), + blackCoverView.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor), + blackCoverView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor), + blackCoverView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor) + ]) + + backwardButton = UIImageView(image: UIImage(systemName: "gobackward.10")) + backwardButton.tintColor = .white + backwardButton.contentMode = .scaleAspectFit + backwardButton.isUserInteractionEnabled = true + let backwardTap = UITapGestureRecognizer(target: self, action: #selector(seekBackward)) + backwardButton.addGestureRecognizer(backwardTap) + controlsContainerView.addSubview(backwardButton) + backwardButton.translatesAutoresizingMaskIntoConstraints = false + + playPauseButton = UIImageView(image: UIImage(systemName: "pause.fill")) + playPauseButton.tintColor = .white + playPauseButton.contentMode = .scaleAspectFit + playPauseButton.isUserInteractionEnabled = true + let playPauseTap = UITapGestureRecognizer(target: self, action: #selector(togglePlayPause)) + playPauseButton.addGestureRecognizer(playPauseTap) + controlsContainerView.addSubview(playPauseButton) + playPauseButton.translatesAutoresizingMaskIntoConstraints = false + + forwardButton = UIImageView(image: UIImage(systemName: "goforward.10")) + forwardButton.tintColor = .white + forwardButton.contentMode = .scaleAspectFit + forwardButton.isUserInteractionEnabled = true + let forwardTap = UITapGestureRecognizer(target: self, action: #selector(seekForward)) + forwardButton.addGestureRecognizer(forwardTap) + controlsContainerView.addSubview(forwardButton) + forwardButton.translatesAutoresizingMaskIntoConstraints = false + + let sliderView = MusicProgressSlider( + value: Binding(get: { self.sliderViewModel.sliderValue }, + set: { self.sliderViewModel.sliderValue = $0 }), + inRange: 0...(duration > 0 ? duration : 1.0), + activeFillColor: .white, + fillColor: .white.opacity(0.5), + emptyColor: .white.opacity(0.3), + height: 30, + onEditingChanged: { editing in + self.isSliderEditing = editing + if !editing { + self.player.seek(to: CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600)) } } + ) + + sliderHostingController = UIHostingController(rootView: sliderView) + guard let sliderHostView = sliderHostingController?.view else { return } + sliderHostView.backgroundColor = .clear + sliderHostView.translatesAutoresizingMaskIntoConstraints = false + controlsContainerView.addSubview(sliderHostView) + + NSLayoutConstraint.activate([ + sliderHostView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 32), + sliderHostView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -32), + sliderHostView.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -20), + sliderHostView.heightAnchor.constraint(equalToConstant: 30) + ]) + + watchNextButton = UIButton(type: .system) + watchNextButton.setTitle("Watch Next", for: .normal) + watchNextButton.backgroundColor = .white + watchNextButton.layer.cornerRadius = 32 + watchNextButton.setTitleColor(.black, for: .normal) + watchNextButton.addTarget(self, action: #selector(watchNextTapped), for: .touchUpInside) + watchNextButton.isHidden = true + controlsContainerView.addSubview(watchNextButton) + watchNextButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + playPauseButton.centerXAnchor.constraint(equalTo: controlsContainerView.centerXAnchor), + playPauseButton.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor), + playPauseButton.widthAnchor.constraint(equalToConstant: 50), + playPauseButton.heightAnchor.constraint(equalToConstant: 50), + + backwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), + backwardButton.trailingAnchor.constraint(equalTo: playPauseButton.leadingAnchor, constant: -30), + backwardButton.widthAnchor.constraint(equalToConstant: 40), + backwardButton.heightAnchor.constraint(equalToConstant: 40), + + forwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), + forwardButton.leadingAnchor.constraint(equalTo: playPauseButton.trailingAnchor, constant: 30), + forwardButton.widthAnchor.constraint(equalToConstant: 40), + forwardButton.heightAnchor.constraint(equalToConstant: 40), + + watchNextButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -10), + watchNextButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -80), + watchNextButton.heightAnchor.constraint(equalToConstant: 50), + watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120) + ]) + } + + func setupSubtitleLabel() { + subtitleLabel = UILabel() + subtitleLabel.textAlignment = .center + subtitleLabel.numberOfLines = 0 + subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize)) + updateSubtitleLabelAppearance() + view.addSubview(subtitleLabel) + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + subtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + subtitleLabel.bottomAnchor.constraint(equalTo: sliderHostingController?.view.topAnchor ?? view.bottomAnchor, constant: -10), + subtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 20), + subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -20) + ]) + } + + func setupDismissButton() { + dismissButton = UIButton(type: .system) + dismissButton.setImage(UIImage(systemName: "xmark"), for: .normal) + dismissButton.tintColor = .white + dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) + controlsContainerView.addSubview(dismissButton) + dismissButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + dismissButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 32), + dismissButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + dismissButton.widthAnchor.constraint(equalToConstant: 40), + dismissButton.heightAnchor.constraint(equalToConstant: 40) + ]) + } + + func setupMenuButton() { + menuButton = UIButton(type: .system) + menuButton.setImage(UIImage(systemName: "text.bubble"), for: .normal) + menuButton.tintColor = .white + + if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty { + menuButton.showsMenuAsPrimaryAction = true + menuButton.menu = buildOptionsMenu() + } else { + menuButton.isHidden = true + } + + controlsContainerView.addSubview(menuButton) + menuButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + menuButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50), + menuButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor), + menuButton.widthAnchor.constraint(equalToConstant: 40), + menuButton.heightAnchor.constraint(equalToConstant: 40) + ]) + } + + func setupSpeedButton() { + speedButton = UIButton(type: .system) + speedButton.setImage(UIImage(systemName: "speedometer"), for: .normal) + speedButton.tintColor = .white + + speedButton.showsMenuAsPrimaryAction = true + speedButton.menu = speedChangerMenu() + + controlsContainerView.addSubview(speedButton) + speedButton.translatesAutoresizingMaskIntoConstraints = false + guard let sliderView = sliderHostingController?.view else { return } + NSLayoutConstraint.activate([ + speedButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50), + speedButton.trailingAnchor.constraint(equalTo: sliderView.trailingAnchor, constant: -10), + speedButton.widthAnchor.constraint(equalToConstant: 40), + speedButton.heightAnchor.constraint(equalToConstant: 40) + ]) + } + + func updateSubtitleLabelAppearance() { + subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize)) + subtitleLabel.textColor = subtitleUIColor() + if subtitleBackgroundEnabled { + subtitleLabel.backgroundColor = UIColor.black.withAlphaComponent(0.6) + } else { + subtitleLabel.backgroundColor = .clear + } + subtitleLabel.layer.cornerRadius = 5 + subtitleLabel.clipsToBounds = true + subtitleLabel.layer.shadowColor = UIColor.black.cgColor + subtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius) + subtitleLabel.layer.shadowOpacity = 1.0 + subtitleLabel.layer.shadowOffset = CGSize.zero + } + + func subtitleUIColor() -> UIColor { + switch subtitleForegroundColor { + case "white": return .white + case "yellow": return .yellow + case "green": return .green + case "purple": return .purple + case "blue": return .blue + case "red": return .red + default: return .white } } - private func startUpdatingCurrentTime() { - Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in - let newTime = player.currentTime().seconds - DispatchQueue.main.async { - self.currentTime = newTime - } - } - } - - private func setInitialPlayerRate() { - if UserDefaults.standard.bool(forKey: "rememberPlaySpeed") { - let lastPlayedSpeed = UserDefaults.standard.float(forKey: "lastPlaybackSpeed") - player.rate = lastPlayedSpeed > 0 ? lastPlayedSpeed : 1.0 - } - } - - private func addPeriodicTimeObserver(fullURL: String) { + func addTimeObserver() { let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) - timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in - guard let currentItem = player.currentItem, + timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in + guard let self = self, let currentItem = self.player.currentItem, currentItem.duration.seconds.isFinite else { return } + self.currentTimeVal = time.seconds + self.duration = currentItem.duration.seconds + + if !self.isSliderEditing { + self.sliderViewModel.sliderValue = self.currentTimeVal + } + + UserDefaults.standard.set(self.currentTimeVal, forKey: "lastPlayedTime_\(self.fullUrl)") + UserDefaults.standard.set(self.duration, forKey: "totalTime_\(self.fullUrl)") + + if let currentCue = self.subtitlesLoader.cues.first(where: { self.currentTimeVal >= $0.startTime && self.currentTimeVal <= $0.endTime }) { + self.subtitleLabel.text = currentCue.text.strippedHTML + } else { + self.subtitleLabel.text = "" + } + + if (self.duration - self.currentTimeVal) <= (self.duration * 0.10) + && self.currentTimeVal != self.duration + && self.showWatchNextButton + && self.duration != 0 { + self.watchNextButton.isHidden = false + } else { + self.watchNextButton.isHidden = true + } + DispatchQueue.main.async { - let currentTimeValue = time.seconds - self.currentTime = currentTimeValue - let duration = currentItem.duration.seconds - UserDefaults.standard.set(currentTimeValue, forKey: "lastPlayedTime_\(fullURL)") - UserDefaults.standard.set(duration, forKey: "totalTime_\(fullURL)") + self.sliderHostingController?.rootView = MusicProgressSlider( + value: Binding(get: { self.sliderViewModel.sliderValue }, + set: { self.sliderViewModel.sliderValue = $0 }), + inRange: 0...(self.duration > 0 ? self.duration : 1.0), + activeFillColor: .white, + fillColor: .white.opacity(0.5), + emptyColor: .white.opacity(0.3), + height: 30, + onEditingChanged: { editing in + self.isSliderEditing = editing + if !editing { + self.player.seek(to: CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600)) + } + } + ) } } } + + func startUpdateTimer() { + updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + guard let self = self else { return } + self.currentTimeVal = self.player.currentTime().seconds + } + } + + @objc func toggleControls() { + UIView.animate(withDuration: 0.2) { + self.controlsContainerView.alpha = self.controlsContainerView.alpha == 0 ? 1 : 0 + } + } + + @objc func seekBackward() { + currentTimeVal = max(currentTimeVal - 10, 0) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + } + + @objc func seekForward() { + currentTimeVal = min(currentTimeVal + 10, duration) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + } + + @objc func togglePlayPause() { + if isPlaying { + player.pause() + playPauseButton.image = UIImage(systemName: "play.fill") + } else { + player.play() + playPauseButton.image = UIImage(systemName: "pause.fill") + } + isPlaying.toggle() + } + + @objc func sliderEditingEnded() { + let newTime = sliderViewModel.sliderValue + player.seek(to: CMTime(seconds: newTime, preferredTimescale: 600)) + } + + @objc func dismissTapped() { + dismiss(animated: true, completion: nil) + } + + @objc func watchNextTapped() { + player.pause() + dismiss(animated: true) { [weak self] in + self?.onWatchNext() + } + } + + func speedChangerMenu() -> UIMenu { + let speeds: [Double] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] + let playbackSpeedActions = speeds.map { speed in + UIAction(title: String(format: "%.2f", speed)) { _ in + self.player.rate = Float(speed) + if self.player.timeControlStatus != .playing { + self.player.pause() + } + } + } + return UIMenu(title: "Playback Speed", children: playbackSpeedActions) + } + + func buildOptionsMenu() -> UIMenu { + var menuElements: [UIMenuElement] = [] + + if let subURL = subtitlesURL, !subURL.isEmpty { + let foregroundActions = [ + UIAction(title: "White") { _ in self.subtitleForegroundColor = "white"; self.updateSubtitleLabelAppearance() }, + UIAction(title: "Yellow") { _ in self.subtitleForegroundColor = "yellow"; self.updateSubtitleLabelAppearance() }, + UIAction(title: "Green") { _ in self.subtitleForegroundColor = "green"; self.updateSubtitleLabelAppearance() }, + UIAction(title: "Blue") { _ in self.subtitleForegroundColor = "blue"; self.updateSubtitleLabelAppearance() }, + UIAction(title: "Red") { _ in self.subtitleForegroundColor = "red"; self.updateSubtitleLabelAppearance() }, + UIAction(title: "Purple") { _ in self.subtitleForegroundColor = "purple"; self.updateSubtitleLabelAppearance() } + ] + let colorMenu = UIMenu(title: "Subtitle Colors", options: .displayInline, children: foregroundActions) + + let fontSizeActions = [ + UIAction(title: "16") { _ in self.subtitleFontSize = 16; self.updateSubtitleLabelAppearance() }, + UIAction(title: "18") { _ in self.subtitleFontSize = 18; self.updateSubtitleLabelAppearance() }, + UIAction(title: "20") { _ in self.subtitleFontSize = 20; self.updateSubtitleLabelAppearance() }, + UIAction(title: "22") { _ in self.subtitleFontSize = 22; self.updateSubtitleLabelAppearance() }, + UIAction(title: "24") { _ in self.subtitleFontSize = 24; self.updateSubtitleLabelAppearance() }, + UIAction(title: "Custom") { _ in self.presentCustomFontAlert() } + ] + let fontSizeMenu = UIMenu(title: "Subtitle Font Size", options: .displayInline, children: fontSizeActions) + + let shadowActions = [ + UIAction(title: "None") { _ in self.subtitleShadowRadius = 0; self.updateSubtitleLabelAppearance() }, + UIAction(title: "Low") { _ in self.subtitleShadowRadius = 1; self.updateSubtitleLabelAppearance() }, + UIAction(title: "Medium") { _ in self.subtitleShadowRadius = 3; self.updateSubtitleLabelAppearance() }, + UIAction(title: "High") { _ in self.subtitleShadowRadius = 6; self.updateSubtitleLabelAppearance() } + ] + let shadowMenu = UIMenu(title: "Shadow Intensity", options: .displayInline, children: shadowActions) + + let toggleBackgroundAction = UIAction(title: self.subtitleBackgroundEnabled ? "Disable Background" : "Enable Background") { _ in + self.subtitleBackgroundEnabled.toggle() + self.updateSubtitleLabelAppearance() + } + + menuElements = [colorMenu, fontSizeMenu, shadowMenu, toggleBackgroundAction] + } + + return UIMenu(title: "", children: menuElements) + } + + func presentCustomFontAlert() { + let alert = UIAlertController(title: "Enter Custom Font Size", message: nil, preferredStyle: .alert) + alert.addTextField { textField in + textField.placeholder = "Font Size" + textField.keyboardType = .numberPad + textField.text = String(Int(self.subtitleFontSize)) + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: "Done", style: .default, handler: { _ in + if let text = alert.textFields?.first?.text, let newSize = Double(text) { + self.subtitleFontSize = newSize + self.updateSubtitleLabelAppearance() + } + })) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.present(alert, animated: true, completion: nil) + } + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UserDefaults.standard.bool(forKey: "alwaysLandscape") { + return .landscape + } else { + return .all + } + } + + override var prefersHomeIndicatorAutoHidden: Bool { + return true + } + + override var prefersStatusBarHidden: Bool { + return true + } } + +// yes? Like the plural of the famous american rapper ye? -IBHRAD +// low taper fade the meme is massive -cranci diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 7ba6ee0..48ac522 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -16,6 +16,7 @@ class VideoPlayerViewController: UIViewController { var timeObserverToken: Any? var streamUrl: String? var fullUrl: String = "" + var subtitles: String = "" var episodeNumber: Int = 0 var episodeImageUrl: String = "" @@ -98,7 +99,7 @@ class VideoPlayerViewController: UIViewController { progress: progress, streamUrl: streamUrl, fullUrl: fullUrl, - subtitles: nil, + subtitles: subtitles, module: module ) ContinueWatchingManager.shared.save(item: item) diff --git a/Sora/Views/HomeView.swift b/Sora/Views/HomeView.swift index 6fc3064..b3a57eb 100644 --- a/Sora/Views/HomeView.swift +++ b/Sora/Views/HomeView.swift @@ -52,7 +52,7 @@ struct HomeView: View { ForEach(Array(continueWatchingItems.reversed())) { item in Button(action: { if UserDefaults.standard.string(forKey: "externalPlayer") == "Sora" { - let customMediaPlayer = CustomMediaPlayer( + let customMediaPlayer = CustomMediaPlayerViewController( module: item.module, urlString: item.streamUrl, fullUrl: item.fullUrl, @@ -62,12 +62,11 @@ struct HomeView: View { subtitlesURL: item.subtitles, episodeImageUrl: item.imageUrl ) - let hostingController = UIHostingController(rootView: customMediaPlayer) - hostingController.modalPresentationStyle = .fullScreen + customMediaPlayer.modalPresentationStyle = .fullScreen if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { - rootVC.present(hostingController, animated: true, completion: nil) + rootVC.present(customMediaPlayer, animated: true, completion: nil) } } else { let videoPlayerViewController = VideoPlayerViewController(module: item.module) @@ -76,6 +75,7 @@ struct HomeView: View { videoPlayerViewController.episodeImageUrl = item.imageUrl videoPlayerViewController.episodeNumber = item.episodeNumber videoPlayerViewController.mediaTitle = item.mediaTitle + videoPlayerViewController.subtitles = item.subtitles ?? "" videoPlayerViewController.modalPresentationStyle = .fullScreen if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 9ab27d7..a52b0b5 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -160,6 +160,7 @@ struct MediaInfoView: View { .background(Color.accentColor) .cornerRadius(10) } + .disabled(isFetchingEpisode) Button(action: { libraryManager.toggleBookmark( @@ -210,7 +211,6 @@ struct MediaInfoView: View { EpisodeCell(episode: ep.href, episodeID: ep.number - 1, progress: progress, itemID: itemID ?? 0, onTap: { imageUrl in if !isFetchingEpisode { - isFetchingEpisode = true selectedEpisodeNumber = ep.number selectedEpisodeImage = imageUrl fetchStream(href: ep.href) @@ -385,6 +385,7 @@ struct MediaInfoView: View { func fetchStream(href: String) { DropManager.shared.showDrop(title: "Fetching Stream", subtitle: "", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath")) + isFetchingEpisode = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { Task { do { @@ -399,6 +400,9 @@ struct MediaInfoView: View { } else { self.handleStreamFailure(error: nil) } + DispatchQueue.main.async { + self.isFetchingEpisode = false + } } } else if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true) { result in @@ -407,6 +411,9 @@ struct MediaInfoView: View { } else { self.handleStreamFailure(error: nil) } + DispatchQueue.main.async { + self.isFetchingEpisode = false + } } } else { jsController.fetchStreamUrl(episodeUrl: href, softsub: true) { result in @@ -415,6 +422,9 @@ struct MediaInfoView: View { } else { self.handleStreamFailure(error: nil) } + DispatchQueue.main.async { + self.isFetchingEpisode = false + } } } } else { @@ -425,6 +435,9 @@ struct MediaInfoView: View { } else { self.handleStreamFailure(error: nil) } + DispatchQueue.main.async { + self.isFetchingEpisode = false + } } } else if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: href) { result in @@ -433,6 +446,9 @@ struct MediaInfoView: View { } else { self.handleStreamFailure(error: nil) } + DispatchQueue.main.async { + self.isFetchingEpisode = false + } } } else { jsController.fetchStreamUrl(episodeUrl: href) { result in @@ -441,15 +457,20 @@ struct MediaInfoView: View { } else { self.handleStreamFailure(error: nil) } + DispatchQueue.main.async { + self.isFetchingEpisode = false + } } } } } catch { self.handleStreamFailure(error: error) + DispatchQueue.main.async { + self.isFetchingEpisode = false + } } } } - isFetchingEpisode = false } func handleStreamFailure(error: Error? = nil) { @@ -477,7 +498,7 @@ struct MediaInfoView: View { case "nPlayer": scheme = "nplayer-\(url)" case "Sora": - let customMediaPlayer = CustomMediaPlayer( + let customMediaPlayer = CustomMediaPlayerViewController( module: module, urlString: url, fullUrl: fullURL, @@ -489,13 +510,12 @@ struct MediaInfoView: View { subtitlesURL: subtitles, episodeImageUrl: selectedEpisodeImage ) - let hostingController = UIHostingController(rootView: customMediaPlayer) - hostingController.modalPresentationStyle = .fullScreen + customMediaPlayer.modalPresentationStyle = .fullScreen Logger.shared.log("Opening custom media player with url: \(url)") if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { - rootVC.present(hostingController, animated: true, completion: nil) + rootVC.present(customMediaPlayer, animated: true, completion: nil) } return default: @@ -512,6 +532,7 @@ struct MediaInfoView: View { videoPlayerViewController.episodeNumber = selectedEpisodeNumber videoPlayerViewController.episodeImageUrl = selectedEpisodeImage videoPlayerViewController.mediaTitle = title + videoPlayerViewController.subtitles = subtitles ?? "" videoPlayerViewController.modalPresentationStyle = .fullScreen if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,