diff --git a/Sora/Utils/Extensions/String.swift b/Sora/Utils/Extensions/String.swift index 660329a..10b3b5a 100644 --- a/Sora/Utils/Extensions/String.swift +++ b/Sora/Utils/Extensions/String.swift @@ -17,4 +17,8 @@ extension String { let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) return attributedString?.string ?? self } + + var trimmed: String { + return self.trimmingCharacters(in: .whitespacesAndNewlines) + } } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 612039f..b9d00aa 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -28,6 +28,8 @@ class CustomMediaPlayerViewController: UIViewController { var timeObserverToken: Any? var inactivityTimer: Timer? var updateTimer: Timer? + var originalRate: Float = 1.0 + var holdGesture: UILongPressGestureRecognizer? var isPlaying = true var currentTimeVal: Double = 0.0 @@ -35,6 +37,13 @@ class CustomMediaPlayerViewController: UIViewController { var isVideoLoaded = false var showWatchNextButton = true + var watchNextButtonTimer: Timer? + var isWatchNextRepositioned: Bool = false + var isWatchNextVisible: Bool = false + var lastDuration: Double = 0.0 + var watchNextButtonAppearedAt: Double? + + var subtitleForegroundColor: String = "white" var subtitleBackgroundEnabled: Bool = true var subtitleFontSize: Double = 20.0 @@ -52,6 +61,13 @@ class CustomMediaPlayerViewController: UIViewController { var watchNextButton: UIButton! var blackCoverView: UIView! var speedButton: UIButton! + var skip85Button: UIButton! + var qualityButton: UIButton! + + var isHLSStream: Bool = false + var qualities: [(String, String)] = [] + var currentQualityURL: URL? + var baseM3U8URL: URL? var sliderHostingController: UIHostingController>? var sliderViewModel = SliderViewModel() @@ -115,29 +131,54 @@ class CustomMediaPlayerViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .black - // Load persistent subtitle settings on launch + setupHoldGesture() + setInitialPlayerRate() loadSubtitleSettings() - setupPlayerViewController() setupControls() setupSubtitleLabel() setupDismissButton() setupMenuButton() setupSpeedButton() + setupQualityButton() + setupSkip85Button() setupWatchNextButton() addTimeObserver() startUpdateTimer() setupAudioSession() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.checkForHLSStream() + } + player.play() if let url = subtitlesURL, !url.isEmpty { subtitlesLoader.load(from: url) } + + DispatchQueue.main.async { + self.isControlsVisible = true + NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) + NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) + self.watchNextButton.alpha = 1.0 + self.view.layoutIfNeeded() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + NotificationCenter.default.addObserver(self, selector: #selector(playerItemDidChange), name: .AVPlayerItemNewAccessLogEntry, object: nil) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemNewAccessLogEntry, object: nil) + + if let playbackSpeed = player?.rate { + UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed") + } player.pause() updateTimer?.invalidate() inactivityTimer?.invalidate() @@ -163,6 +204,15 @@ class CustomMediaPlayerViewController: UIViewController { } } + @objc private func playerItemDidChange() { + DispatchQueue.main.async { [weak self] in + if let self = self, self.qualityButton.isHidden && self.isHLSStream { + self.qualityButton.isHidden = false + self.qualityButton.menu = self.qualitySelectionMenu() + } + } + } + func setupPlayerViewController() { playerViewController = AVPlayerViewController() playerViewController.player = player @@ -205,12 +255,20 @@ class CustomMediaPlayerViewController: UIViewController { blackCoverView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - backwardButton = UIImageView(image: UIImage(systemName: "gobackward.10")) + backwardButton = UIImageView(image: UIImage(systemName: "gobackward")) backwardButton.tintColor = .white backwardButton.contentMode = .scaleAspectFit backwardButton.isUserInteractionEnabled = true + let backwardTap = UITapGestureRecognizer(target: self, action: #selector(seekBackward)) + backwardTap.numberOfTapsRequired = 1 backwardButton.addGestureRecognizer(backwardTap) + + let backwardLongPress = UILongPressGestureRecognizer(target: self, action: #selector(seekBackwardLongPress(_:))) + backwardLongPress.minimumPressDuration = 0.5 + backwardButton.addGestureRecognizer(backwardLongPress) + backwardTap.require(toFail: backwardLongPress) + controlsContainerView.addSubview(backwardButton) backwardButton.translatesAutoresizingMaskIntoConstraints = false @@ -223,12 +281,21 @@ class CustomMediaPlayerViewController: UIViewController { controlsContainerView.addSubview(playPauseButton) playPauseButton.translatesAutoresizingMaskIntoConstraints = false - forwardButton = UIImageView(image: UIImage(systemName: "goforward.10")) + forwardButton = UIImageView(image: UIImage(systemName: "goforward")) forwardButton.tintColor = .white forwardButton.contentMode = .scaleAspectFit forwardButton.isUserInteractionEnabled = true + let forwardTap = UITapGestureRecognizer(target: self, action: #selector(seekForward)) + forwardTap.numberOfTapsRequired = 1 forwardButton.addGestureRecognizer(forwardTap) + + let forwardLongPress = UILongPressGestureRecognizer(target: self, action: #selector(seekForwardLongPress(_:))) + forwardLongPress.minimumPressDuration = 0.5 + forwardButton.addGestureRecognizer(forwardLongPress) + + forwardTap.require(toFail: forwardLongPress) + controlsContainerView.addSubview(forwardButton) forwardButton.translatesAutoresizingMaskIntoConstraints = false @@ -255,8 +322,8 @@ class CustomMediaPlayerViewController: UIViewController { controlsContainerView.addSubview(sliderHostView) NSLayoutConstraint.activate([ - sliderHostView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 26), - sliderHostView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -26), + sliderHostView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + sliderHostView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -18), sliderHostView.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -20), sliderHostView.heightAnchor.constraint(equalToConstant: 30) ]) @@ -354,44 +421,97 @@ class CustomMediaPlayerViewController: UIViewController { controlsContainerView.addSubview(speedButton) speedButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - speedButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50), - speedButton.trailingAnchor.constraint(equalTo: menuButton.leadingAnchor), - speedButton.widthAnchor.constraint(equalToConstant: 40), - speedButton.heightAnchor.constraint(equalToConstant: 40) - ]) + + guard let sliderView = sliderHostingController?.view else { return } + + if menuButton.isHidden { + NSLayoutConstraint.activate([ + speedButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50), + speedButton.trailingAnchor.constraint(equalTo: sliderView.trailingAnchor), + speedButton.widthAnchor.constraint(equalToConstant: 40), + speedButton.heightAnchor.constraint(equalToConstant: 40) + ]) + } else { + NSLayoutConstraint.activate([ + speedButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50), + speedButton.trailingAnchor.constraint(equalTo: menuButton.leadingAnchor), + speedButton.widthAnchor.constraint(equalToConstant: 40), + speedButton.heightAnchor.constraint(equalToConstant: 40) + ]) + } } func setupWatchNextButton() { watchNextButton = UIButton(type: .system) - watchNextButton.setTitle("Watch Next", for: .normal) + watchNextButton.setTitle("Play Next", for: .normal) watchNextButton.setImage(UIImage(systemName: "forward.fill"), for: .normal) watchNextButton.tintColor = .black watchNextButton.backgroundColor = .white watchNextButton.layer.cornerRadius = 25 watchNextButton.setTitleColor(.black, for: .normal) watchNextButton.addTarget(self, action: #selector(watchNextTapped), for: .touchUpInside) + watchNextButton.alpha = 0.0 watchNextButton.isHidden = true - watchNextButton.alpha = 0.8 view.addSubview(watchNextButton) watchNextButton.translatesAutoresizingMaskIntoConstraints = false watchNextButtonNormalConstraints = [ watchNextButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), - watchNextButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -40), + watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.centerYAnchor), watchNextButton.heightAnchor.constraint(equalToConstant: 50), watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120) ] watchNextButtonControlsConstraints = [ - watchNextButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor), - watchNextButton.bottomAnchor.constraint(equalTo: speedButton.bottomAnchor, constant: -5), + watchNextButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor), + watchNextButton.bottomAnchor.constraint(equalTo: skip85Button.bottomAnchor), watchNextButton.heightAnchor.constraint(equalToConstant: 50), watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120) ] - NSLayoutConstraint.activate(watchNextButtonControlsConstraints) + NSLayoutConstraint.activate(watchNextButtonNormalConstraints) + } + + func setupSkip85Button() { + skip85Button = UIButton(type: .system) + skip85Button.setTitle("Skip 85s", for: .normal) + skip85Button.setImage(UIImage(systemName: "goforward"), for: .normal) + skip85Button.tintColor = .black + skip85Button.backgroundColor = .white + skip85Button.layer.cornerRadius = 25 + skip85Button.setTitleColor(.black, for: .normal) + skip85Button.alpha = 0.8 + skip85Button.addTarget(self, action: #selector(skip85Tapped), for: .touchUpInside) + + view.addSubview(skip85Button) + skip85Button.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + skip85Button.leadingAnchor.constraint(equalTo: sliderHostingController!.view.leadingAnchor), + skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -3), + skip85Button.heightAnchor.constraint(equalToConstant: 50), + skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 120) + ]) + } + + private func setupQualityButton() { + qualityButton = UIButton(type: .system) + qualityButton.setImage(UIImage(systemName: "4k.tv"), for: .normal) + qualityButton.tintColor = .white + qualityButton.showsMenuAsPrimaryAction = true + qualityButton.menu = qualitySelectionMenu() + qualityButton.isHidden = true + + controlsContainerView.addSubview(qualityButton) + qualityButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + qualityButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50), + qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor), + qualityButton.widthAnchor.constraint(equalToConstant: 40), + qualityButton.heightAnchor.constraint(equalToConstant: 40) + ]) } func updateSubtitleLabelAppearance() { @@ -421,13 +541,18 @@ class CustomMediaPlayerViewController: UIViewController { func addTimeObserver() { let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in - guard let self = self, let currentItem = self.player.currentItem, + guard let self = self, + let currentItem = self.player.currentItem, currentItem.duration.seconds.isFinite else { return } + + let currentDuration = currentItem.duration.seconds + if currentDuration.isNaN || currentDuration <= 0 { return } + self.currentTimeVal = time.seconds - self.duration = currentItem.duration.seconds + self.duration = currentDuration if !self.isSliderEditing { - self.sliderViewModel.sliderValue = self.currentTimeVal + self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration)) } UserDefaults.standard.set(self.currentTimeVal, forKey: "lastPlayedTime_\(self.fullUrl)") @@ -439,26 +564,11 @@ class CustomMediaPlayerViewController: UIViewController { self.subtitleLabel.text = "" } - if (self.duration - self.currentTimeVal) <= (self.duration * 0.10) - && self.currentTimeVal != self.duration - && self.showWatchNextButton - && self.duration != 0 { - - if UserDefaults.standard.bool(forKey: "hideNextButton") { - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - self.watchNextButton.isHidden = true - } - } else { - self.watchNextButton.isHidden = false - } - } else { - self.watchNextButton.isHidden = true - } - + // ORIGINAL PROGRESS BAR CODE: DispatchQueue.main.async { self.sliderHostingController?.rootView = MusicProgressSlider( - value: Binding(get: { self.sliderViewModel.sliderValue }, - set: { self.sliderViewModel.sliderValue = $0 }), + value: Binding(get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) }, + set: { self.sliderViewModel.sliderValue = max(0, min($0, self.duration)) }), inRange: 0...(self.duration > 0 ? self.duration : 1.0), activeFillColor: .white, fillColor: .white.opacity(0.5), @@ -467,13 +577,88 @@ class CustomMediaPlayerViewController: UIViewController { onEditingChanged: { editing in self.isSliderEditing = editing if !editing { - self.player.seek(to: CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600)) + let seekTime = CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600) + self.player.seek(to: seekTime) } } ) } + + // Watch Next Button Logic: + let hideNext = UserDefaults.standard.bool(forKey: "hideNextButton") + let isNearEnd = (self.duration - self.currentTimeVal) <= (self.duration * 0.10) + && self.currentTimeVal != self.duration + && self.showWatchNextButton + && self.duration != 0 + + if isNearEnd { + // First appearance: show the button in its normal position. + if !self.isWatchNextVisible { + self.isWatchNextVisible = true + self.watchNextButtonAppearedAt = self.currentTimeVal + + // Choose constraints based on current controls visibility. + if self.isControlsVisible { + NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) + NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) + } else { + NSLayoutConstraint.deactivate(self.watchNextButtonControlsConstraints) + NSLayoutConstraint.activate(self.watchNextButtonNormalConstraints) + } + // Soft fade-in. + self.watchNextButton.isHidden = false + self.watchNextButton.alpha = 0.0 + UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: { + self.watchNextButton.alpha = 0.8 + }, completion: nil) + } + + // When 5 seconds have elapsed from when the button first appeared: + if let appearedAt = self.watchNextButtonAppearedAt, + (self.currentTimeVal - appearedAt) >= 5, + !self.isWatchNextRepositioned { + // Fade out the button first (even if controls are visible). + UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: { + self.watchNextButton.alpha = 0.0 + }, completion: { _ in + self.watchNextButton.isHidden = true + // Then lock it to the controls-attached constraints. + NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) + NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) + self.isWatchNextRepositioned = true + }) + } + } else { + // Not near end: reset the watch-next button state. + self.watchNextButtonAppearedAt = nil + self.isWatchNextVisible = false + self.isWatchNextRepositioned = false + UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: { + self.watchNextButton.alpha = 0.0 + }, completion: { _ in + self.watchNextButton.isHidden = true + }) + } } } + + + + func repositionWatchNextButton() { + self.isWatchNextRepositioned = true + // Update constraints so the button is now attached next to the playback controls. + UIView.animate(withDuration: 0.3, animations: { + NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) + NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) + self.view.layoutIfNeeded() + self.watchNextButton.alpha = 0.0 + }, completion: { _ in + self.watchNextButton.isHidden = true + }) + self.watchNextButtonTimer?.invalidate() + self.watchNextButtonTimer = nil + } + func startUpdateTimer() { updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in @@ -484,31 +669,67 @@ class CustomMediaPlayerViewController: UIViewController { @objc func toggleControls() { isControlsVisible.toggle() - - UIView.animate(withDuration: 0.2) { + UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: { self.controlsContainerView.alpha = self.isControlsVisible ? 1 : 0 + self.skip85Button.alpha = self.isControlsVisible ? 0.8 : 0 if self.isControlsVisible { + // Always use the controls-attached constraints. NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) - self.watchNextButton.alpha = 1.0 + if self.isWatchNextRepositioned || self.isWatchNextVisible { + self.watchNextButton.isHidden = false + UIView.animate(withDuration: 0.5, animations: { + self.watchNextButton.alpha = 0.8 + }) + } } else { - NSLayoutConstraint.deactivate(self.watchNextButtonControlsConstraints) - NSLayoutConstraint.activate(self.watchNextButtonNormalConstraints) - self.watchNextButton.alpha = 0.8 + // When controls are hidden: + if !self.isWatchNextRepositioned && self.isWatchNextVisible { + NSLayoutConstraint.deactivate(self.watchNextButtonControlsConstraints) + NSLayoutConstraint.activate(self.watchNextButtonNormalConstraints) + } + if self.isWatchNextRepositioned { + UIView.animate(withDuration: 0.5, animations: { + self.watchNextButton.alpha = 0.0 + }, completion: { _ in + self.watchNextButton.isHidden = true + }) + } } - self.view.layoutIfNeeded() + }) + } + + @objc func seekBackwardLongPress(_ gesture: UILongPressGestureRecognizer) { + if gesture.state == .began { + let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold") + let finalSkip = holdValue > 0 ? holdValue : 30 + currentTimeVal = max(currentTimeVal - finalSkip, 0) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + } + } + + @objc func seekForwardLongPress(_ gesture: UILongPressGestureRecognizer) { + if gesture.state == .began { + let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold") + let finalSkip = holdValue > 0 ? holdValue : 30 + currentTimeVal = min(currentTimeVal + finalSkip, duration) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) } } @objc func seekBackward() { - currentTimeVal = max(currentTimeVal - 10, 0) + let skipValue = UserDefaults.standard.double(forKey: "skipIncrement") + let finalSkip = skipValue > 0 ? skipValue : 10 + currentTimeVal = max(currentTimeVal - finalSkip, 0) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) } @objc func seekForward() { - currentTimeVal = min(currentTimeVal + 10, duration) + let skipValue = UserDefaults.standard.double(forKey: "skipIncrement") + let finalSkip = skipValue > 0 ? skipValue : 10 + currentTimeVal = min(currentTimeVal + finalSkip, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) } @@ -539,6 +760,11 @@ class CustomMediaPlayerViewController: UIViewController { } } + @objc func skip85Tapped() { + currentTimeVal = min(currentTimeVal + 85, duration) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + } + 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 @@ -552,6 +778,173 @@ class CustomMediaPlayerViewController: UIViewController { return UIMenu(title: "Playback Speed", children: playbackSpeedActions) } + private func parseM3U8(url: URL, completion: @escaping () -> Void) { + var request = URLRequest(url: url) + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") + request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + + URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + guard let self = self, let data = data, let content = String(data: data, encoding: .utf8) else { + print("Failed to load m3u8 file") + DispatchQueue.main.async { + self?.qualities = [] + completion() + } + return + } + + let lines = content.components(separatedBy: .newlines) + var qualities: [(String, String)] = [] + + qualities.append(("Auto (Recommended)", url.absoluteString)) + + func getQualityName(for height: Int) -> String { + switch height { + case 1080...: return "\(height)p (FHD)" + case 720..<1080: return "\(height)p (HD)" + case 480..<720: return "\(height)p (SD)" + default: return "\(height)p" + } + } + + for (index, line) in lines.enumerated() { + if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { + if let resolutionRange = line.range(of: "RESOLUTION="), + let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") ?? line[resolutionRange.upperBound...].range(of: "\n") { + + let resolutionPart = String(line[resolutionRange.upperBound.. secondHeight + } + + if let auto = autoQuality { + sortedQualities.insert(auto, at: 0) + } + + self.qualities = sortedQualities + completion() + } + }.resume() + } + + private func switchToQuality(urlString: String) { + guard let url = URL(string: urlString), currentQualityURL?.absoluteString != urlString else { return } + + let currentTime = player.currentTime() + let wasPlaying = player.rate > 0 + + var request = URLRequest(url: url) + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") + request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + + let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) + let playerItem = AVPlayerItem(asset: asset) + + player.replaceCurrentItem(with: playerItem) + + player.seek(to: currentTime) + if wasPlaying { + player.play() + } + + currentQualityURL = url + + UserDefaults.standard.set(urlString, forKey: "lastSelectedQuality") + qualityButton.menu = qualitySelectionMenu() + + if let selectedQuality = qualities.first(where: { $0.1 == urlString })?.0 { + DropManager.shared.showDrop(title: "Quality: \(selectedQuality)", subtitle: "", duration: 0.5, icon: UIImage(systemName: "eye")) + } + } + + private func qualitySelectionMenu() -> UIMenu { + var menuItems: [UIMenuElement] = [] + + if isHLSStream { + if qualities.isEmpty { + let loadingAction = UIAction(title: "Loading qualities...", attributes: .disabled) { _ in } + menuItems.append(loadingAction) + } else { + var menuTitle = "Video Quality" + if let currentURL = currentQualityURL?.absoluteString, + let selectedQuality = qualities.first(where: { $0.1 == currentURL })?.0 { + menuTitle = "Quality: \(selectedQuality)" + } + + for (name, urlString) in qualities { + let isCurrentQuality = currentQualityURL?.absoluteString == urlString + + let action = UIAction( + title: name, + state: isCurrentQuality ? .on : .off, + handler: { [weak self] _ in + self?.switchToQuality(urlString: urlString) + } + ) + menuItems.append(action) + } + + return UIMenu(title: menuTitle, children: menuItems) + } + } else { + let unavailableAction = UIAction(title: "Quality selection unavailable", attributes: .disabled) { _ in } + menuItems.append(unavailableAction) + } + + return UIMenu(title: "Video Quality", children: menuItems) + } + + private func checkForHLSStream() { + guard let url = URL(string: streamURL) else { return } + + if url.absoluteString.contains(".m3u8") { + isHLSStream = true + baseM3U8URL = url + currentQualityURL = url + + parseM3U8(url: url) { [weak self] in + guard let self = self else { return } + + if let lastSelectedQuality = UserDefaults.standard.string(forKey: "lastSelectedQuality"), + self.qualities.contains(where: { $0.1 == lastSelectedQuality }) { + self.switchToQuality(urlString: lastSelectedQuality) + } + + self.qualityButton.isHidden = false + self.qualityButton.menu = self.qualitySelectionMenu() + } + } else { + isHLSStream = false + qualityButton.isHidden = true + } + } + func buildOptionsMenu() -> UIMenu { var menuElements: [UIMenuElement] = [] @@ -754,8 +1147,46 @@ class CustomMediaPlayerViewController: UIViewController { Logger.shared.log("Failed to set up AVAudioSession: \(error)") } } + + private func setupHoldGesture() { + holdGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldGesture(_:))) + holdGesture?.minimumPressDuration = 0.5 + if let holdGesture = holdGesture { + view.addGestureRecognizer(holdGesture) + } + } + + @objc private func handleHoldGesture(_ gesture: UILongPressGestureRecognizer) { + switch gesture.state { + case .began: + beginHoldSpeed() + case .ended, .cancelled: + endHoldSpeed() + default: + break + } + } + + private func beginHoldSpeed() { + guard let player = player else { return } + originalRate = player.rate + let holdSpeed = UserDefaults.standard.float(forKey: "holdSpeedPlayer") + player.rate = holdSpeed > 0 ? holdSpeed : 2.0 + } + + private func endHoldSpeed() { + player?.rate = originalRate + } + + private func setInitialPlayerRate() { + if UserDefaults.standard.bool(forKey: "rememberPlaySpeed") { + let lastPlayedSpeed = UserDefaults.standard.float(forKey: "lastPlaybackSpeed") + player?.rate = lastPlayedSpeed > 0 ? lastPlayedSpeed : 1.0 + } + } } // yes? Like the plural of the famous american rapper ye? -IBHRAD // low taper fade the meme is massive -cranci // cranci still doesnt have a job -seiike +// guys watch Clannad already - ibro diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/SubtitleSettingsManager.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift similarity index 100% rename from Sora/Utils/MediaPlayer/CustomPlayer/SubtitleSettingsManager.swift rename to Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift new file mode 100644 index 0000000..37b98d4 --- /dev/null +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift @@ -0,0 +1,168 @@ +// +// 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] = [] + + enum SubtitleFormat { + case vtt + case srt + case unknown + } + + func load(from urlString: String) { + guard let url = URL(string: urlString) else { return } + + let format = determineSubtitleFormat(from: url) + + URLSession.custom.dataTask(with: url) { data, _, error in + guard let data = data, + let content = String(data: data, encoding: .utf8), + error == nil else { return } + + DispatchQueue.main.async { + switch format { + case .vtt: + self.cues = self.parseVTT(content: content) + case .srt: + self.cues = self.parseSRT(content: content) + case .unknown: + if content.trimmed.hasPrefix("WEBVTT") { + self.cues = self.parseVTT(content: content) + } else { + self.cues = self.parseSRT(content: content) + } + } + } + }.resume() + } + + private func determineSubtitleFormat(from url: URL) -> SubtitleFormat { + let fileExtension = url.pathExtension.lowercased() + switch fileExtension { + case "vtt", "webvtt": + return .vtt + case "srt": + return .srt + default: + return .unknown + } + } + + 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 adjustedStartTime = max(startTime - 0.5, 0) + let endTime = parseTimecode(times[1].trimmingCharacters(in: .whitespaces)) + let adjusteEndTime = max(endTime - 0.5, 0) + index += 1 + var cueText = "" + while index < lines.count && !lines[index].trimmingCharacters(in: .whitespaces).isEmpty { + cueText += lines[index] + "\n" + index += 1 + } + cues.append(SubtitleCue(startTime: adjustedStartTime, endTime: adjusteEndTime, text: cueText.trimmingCharacters(in: .whitespacesAndNewlines))) + } + return cues + } + + private func parseSRT(content: String) -> [SubtitleCue] { + var cues: [SubtitleCue] = [] + let normalizedContent = content.replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + let blocks = normalizedContent.components(separatedBy: "\n\n") + + for block in blocks { + let lines = block.components(separatedBy: "\n").filter { !$0.isEmpty } + guard lines.count >= 2 else { continue } + + let timeLine = lines[1] + let times = timeLine.components(separatedBy: "-->") + + guard times.count >= 2 else { continue } + + let startTime = parseSRTTimecode(times[0].trimmingCharacters(in: .whitespaces)) + let adjustedStartTime = max(startTime - 0.5, 0) + let endTime = parseSRTTimecode(times[1].trimmingCharacters(in: .whitespaces)) + let adjustedEndTime = max(endTime - 0.5, 0) + + var textLines = [String]() + if lines.count > 2 { + textLines = Array(lines[2...]) + } + let text = textLines.joined(separator: "\n") + + cues.append(SubtitleCue(startTime: adjustedStartTime, endTime: adjustedEndTime, text: text)) + } + + 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 + } + + private func parseSRTTimecode(_ timeString: String) -> Double { + let parts = timeString.components(separatedBy: ":") + guard parts.count == 3 else { return 0 } + + let secondsParts = parts[2].components(separatedBy: ",") + guard secondsParts.count == 2, + let hours = Double(parts[0]), + let minutes = Double(parts[1]), + let seconds = Double(secondsParts[0]), + let milliseconds = Double(secondsParts[1]) else { + return 0 + } + + return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000 + } +} diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/VTTSubtitlesLoader.swift b/Sora/Utils/MediaPlayer/CustomPlayer/VTTSubtitlesLoader.swift deleted file mode 100644 index c58214a..0000000 --- a/Sora/Utils/MediaPlayer/CustomPlayer/VTTSubtitlesLoader.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// 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 adjustedStartTime = max(startTime - 0.5, 0) - let endTime = parseTimecode(times[1].trimmingCharacters(in: .whitespaces)) - let adjusteEndTime = max(endTime - 0.5, 0) - index += 1 - var cueText = "" - while index < lines.count && !lines[index].trimmingCharacters(in: .whitespaces).isEmpty { - cueText += lines[index] + "\n" - index += 1 - } - cues.append(SubtitleCue(startTime: adjustedStartTime, endTime: adjusteEndTime, 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 - } -} diff --git a/Sora/Utils/SkeletonCells/SkeletonCell.swift b/Sora/Utils/SkeletonCells/SkeletonCell.swift index adf330a..342b2bf 100644 --- a/Sora/Utils/SkeletonCells/SkeletonCell.swift +++ b/Sora/Utils/SkeletonCells/SkeletonCell.swift @@ -8,17 +8,19 @@ import SwiftUI struct HomeSkeletonCell: View { + let cellWidth: CGFloat + var body: some View { VStack { RoundedRectangle(cornerRadius: 10) .fill(Color.gray.opacity(0.3)) - .frame(width: 130, height: 195) + .frame(width: cellWidth, height: cellWidth * 1.5) .cornerRadius(10) .shimmering() RoundedRectangle(cornerRadius: 5) .fill(Color.gray.opacity(0.3)) - .frame(width: 130, height: 20) + .frame(width: cellWidth, height: 20) .padding(.top, 4) .shimmering() } @@ -26,15 +28,17 @@ struct HomeSkeletonCell: View { } struct SearchSkeletonCell: View { + let cellWidth: CGFloat + var body: some View { VStack(alignment: .leading, spacing: 8) { RoundedRectangle(cornerRadius: 10) .fill(Color.gray.opacity(0.3)) - .frame(width: 150, height: 225) + .frame(width: cellWidth, height: cellWidth * 1.5) .shimmering() RoundedRectangle(cornerRadius: 5) .fill(Color.gray.opacity(0.3)) - .frame(width: 150, height: 20) + .frame(width: cellWidth, height: 20) .shimmering() } } diff --git a/Sora/Views/LibraryView/LibraryManager.swift b/Sora/Views/LibraryView/LibraryManager.swift index 19712fb..285524e 100644 --- a/Sora/Views/LibraryView/LibraryManager.swift +++ b/Sora/Views/LibraryView/LibraryManager.swift @@ -35,9 +35,17 @@ class LibraryManager: ObservableObject { loadBookmarks() } + func removeBookmark(item: LibraryItem) { + if let index = bookmarks.firstIndex(where: { $0.id == item.id }) { + bookmarks.remove(at: index) + Logger.shared.log("Removed series \(item.id) from bookmarks.",type: "Debug") + saveBookmarks() + } + } + private func loadBookmarks() { guard let data = UserDefaults.standard.data(forKey: bookmarksKey) else { - Logger.shared.log("No bookmarks data found in UserDefaults.", type: "Error") + Logger.shared.log("No bookmarks data found in UserDefaults.", type: "Debug") return } diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 7bd8130..877f38b 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -12,7 +12,13 @@ struct LibraryView: View { @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var moduleManager: ModuleManager + @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 + @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 + + @Environment(\.verticalSizeClass) var verticalSizeClass + @State private var continueWatchingItems: [ContinueWatchingItem] = [] + @State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape private let columns = [ GridItem(.adaptive(minimum: 150), spacing: 12) @@ -21,6 +27,8 @@ struct LibraryView: View { var body: some View { NavigationView { ScrollView { + let columnsCount = determineColumns() + VStack(alignment: .leading, spacing: 12) { Text("Continue Watching") .font(.title2) @@ -67,22 +75,27 @@ struct LibraryView: View { .padding() .frame(maxWidth: .infinity) } else { - LazyVGrid(columns: columns, spacing: 12) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnsCount), spacing: 12) { + let totalSpacing: CGFloat = 16 * CGFloat(columnsCount + 1) + let availableWidth = UIScreen.main.bounds.width - totalSpacing + let cellWidth = availableWidth / CGFloat(columnsCount) + ForEach(libraryManager.bookmarks) { item in if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) { NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: module)) { - VStack { + VStack(alignment: .leading) { ZStack { KFImage(URL(string: item.imageUrl)) .placeholder { RoundedRectangle(cornerRadius: 10) .fill(Color.gray.opacity(0.3)) - .frame(width: 150, height: 225) + .aspectRatio(2/3, contentMode: .fit) .shimmering() } .resizable() - .aspectRatio(2/3, contentMode: .fill) - .frame(width: 150, height: 225) + .aspectRatio(contentMode: .fill) + .frame(height: cellWidth * 3 / 2) + .frame(maxWidth: cellWidth) .cornerRadius(10) .clipped() .overlay( @@ -94,18 +107,30 @@ struct LibraryView: View { alignment: .topLeading ) } - Text(item.title) .font(.subheadline) .foregroundColor(.primary) - .lineLimit(2) + .lineLimit(1) .multilineTextAlignment(.leading) } } + .contextMenu { + Button(role: .destructive, action: { + libraryManager.removeBookmark(item: item) + }) { + Label("Remove from Bookmarks", systemImage: "trash") + } + } } } } .padding(.horizontal, 20) + .onAppear { + updateOrientation() + } + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + updateOrientation() + } } } .padding(.vertical, 20) @@ -135,6 +160,20 @@ struct LibraryView: View { ContinueWatchingManager.shared.remove(item: item) continueWatchingItems.removeAll { $0.id == item.id } } + + private func updateOrientation() { + DispatchQueue.main.async { + isLandscape = UIDevice.current.orientation.isLandscape + } + } + + private func determineColumns() -> Int { + if UIDevice.current.userInterfaceIdiom == .pad { + return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait + } else { + return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait + } + } } struct ContinueWatchingSection: View { @@ -147,7 +186,7 @@ struct ContinueWatchingSection: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(Array(items.reversed())) { item in - ContinueWatchingCell(item: item,markAsWatched: { + ContinueWatchingCell(item: item, markAsWatched: { markAsWatched(item) }, removeItem: { removeItem(item) @@ -166,6 +205,8 @@ struct ContinueWatchingCell: View { var markAsWatched: () -> Void var removeItem: () -> Void + @State private var currentProgress: Double = 0.0 + var body: some View { Button(action: { if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" { @@ -232,7 +273,7 @@ struct ContinueWatchingCell: View { .blur(radius: 3) .frame(height: 30) - ProgressView(value: item.progress) + ProgressView(value: currentProgress) .progressViewStyle(LinearProgressViewStyle(tint: .white)) .padding(.horizontal, 8) .scaleEffect(x: 1, y: 1.5, anchor: .center) @@ -263,5 +304,22 @@ struct ContinueWatchingCell: View { Label("Remove Item", systemImage: "trash") } } + .onAppear { + updateProgress() + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + updateProgress() + } + } + + private func updateProgress() { + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)") + let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)") + + if totalTime > 0 { + currentProgress = lastPlayedTime / totalTime + } else { + currentProgress = item.progress + } } } diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index b284a47..282fadd 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -80,11 +80,15 @@ struct EpisodeCell: View { } } .onAppear { + updateProgress() + if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil || UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") { fetchEpisodeDetails() } - currentProgress = progress + } + .onChange(of: progress) { newProgress in + updateProgress() } .onTapGesture { onTap(episodeImageUrl) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 8b47f57..02a8499 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -38,6 +38,7 @@ struct MediaInfoView: View { @State private var selectedEpisodeNumber: Int = 0 @State private var selectedEpisodeImage: String = "" + @State private var selectedSeason: Int = 0 @AppStorage("externalPlayer") private var externalPlayer: String = "Default" @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 @@ -48,6 +49,10 @@ struct MediaInfoView: View { @State private var selectedRange: Range = 0..<100 + private var isGroupedBySeasons: Bool { + return groupedEpisodes().count > 1 + } + var body: some View { Group { if isLoading { @@ -65,9 +70,10 @@ struct MediaInfoView: View { .shimmering() } .resizable() - .aspectRatio(2/3, contentMode: .fit) - .cornerRadius(10) + .aspectRatio(contentMode: .fill) .frame(width: 150, height: 225) + .clipped() + .cornerRadius(10) VStack(alignment: .leading, spacing: 4) { Text(title) @@ -189,12 +195,10 @@ struct MediaInfoView: View { Spacer() - if episodeLinks.count > episodeChunkSize { + if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize { Menu { ForEach(generateRanges(), id: \.self) { range in - Button(action: { - selectedRange = range - }) { + Button(action: { selectedRange = range }) { Text("\(range.lowerBound + 1)-\(range.upperBound)") } } @@ -203,44 +207,101 @@ struct MediaInfoView: View { .font(.system(size: 14)) .foregroundColor(.accentColor) } + } else if isGroupedBySeasons { + let seasons = groupedEpisodes() + if seasons.count > 1 { + Menu { + ForEach(0.. 0 ? lastPlayedTime / totalTime : 0 - - EpisodeCell( - episodeIndex: i, - episode: ep.href, - episodeID: ep.number - 1, - progress: progress, - itemID: itemID ?? 0, - onTap: { imageUrl in - if !isFetchingEpisode { - selectedEpisodeNumber = ep.number - selectedEpisodeImage = imageUrl - fetchStream(href: ep.href) - AnalyticsManager.shared.sendEvent( - event: "watch", - additionalData: ["title": title, "episode": ep.number] - ) - } - }, - onMarkAllPrevious: { - for idx in 0.. 0 ? lastPlayedTime / totalTime : 0 + + EpisodeCell( + episodeIndex: selectedSeason, + episode: ep.href, + episodeID: ep.number - 1, + progress: progress, + itemID: itemID ?? 0, + onTap: { imageUrl in + if !isFetchingEpisode { + selectedEpisodeNumber = ep.number + selectedEpisodeImage = imageUrl + fetchStream(href: ep.href) + AnalyticsManager.shared.sendEvent( + event: "watch", + additionalData: ["title": title, "episode": ep.number] + ) + } + }, + onMarkAllPrevious: { + for ep2 in seasons[selectedSeason] { + let href = ep2.href + UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(href)") + UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)") + } + refreshTrigger.toggle() + Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General") + } + ) + .id(refreshTrigger) + .disabled(isFetchingEpisode) } - ) - .id(refreshTrigger) - .disabled(isFetchingEpisode) + } else { + Text("No episodes available") + } + } else { + ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in + let ep = episodeLinks[i] + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") + let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") + let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 + + EpisodeCell( + episodeIndex: i, + episode: ep.href, + episodeID: ep.number - 1, + progress: progress, + itemID: itemID ?? 0, + onTap: { imageUrl in + if !isFetchingEpisode { + selectedEpisodeNumber = ep.number + selectedEpisodeImage = imageUrl + fetchStream(href: ep.href) + AnalyticsManager.shared.sendEvent( + event: "watch", + additionalData: ["title": title, "episode": ep.number] + ) + } + }, + onMarkAllPrevious: { + for idx in 0.. [[EpisodeLink]] { + guard !episodeLinks.isEmpty else { return [] } + var groups: [[EpisodeLink]] = [] + var currentGroup: [EpisodeLink] = [episodeLinks[0]] + + for ep in episodeLinks.dropFirst() { + if let last = currentGroup.last, ep.number < last.number { + groups.append(currentGroup) + currentGroup = [ep] + } else { + currentGroup.append(ep) + } + } + + groups.append(currentGroup) + return groups + } + + func fetchDetails() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { Task { @@ -511,13 +591,6 @@ struct MediaInfoView: View { func playStream(url: String, fullURL: String, subtitles: String? = nil) { DispatchQueue.main.async { - guard let streamURL = URL(string: url) else { - Logger.shared.log("Invalid stream URL: \(url)", type: "Error") - handleStreamFailure() - return - } - let subtitleFileURL = subtitles != nil ? URL(string: subtitles!) : nil - let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora" var scheme: String? diff --git a/Sora/Views/SearchView.swift b/Sora/Views/SearchView.swift index 914b585..85802ba 100644 --- a/Sora/Views/SearchView.swift +++ b/Sora/Views/SearchView.swift @@ -17,14 +17,19 @@ struct SearchItem: Identifiable { struct SearchView: View { @AppStorage("selectedModuleId") private var selectedModuleId: String? + @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 + @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 + @StateObject private var jsController = JSController() @EnvironmentObject var moduleManager: ModuleManager + @Environment(\.verticalSizeClass) var verticalSizeClass @State private var searchItems: [SearchItem] = [] @State private var selectedSearchItem: SearchItem? @State private var isSearching = false @State private var searchText = "" @State private var hasNoResults = false + @State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape private var selectedModule: ScrapingModule? { guard let id = selectedModuleId else { return nil } @@ -39,9 +44,31 @@ struct SearchView: View { "Almost there..." ] + private var columnsCount: Int { + if UIDevice.current.userInterfaceIdiom == .pad { + let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height + return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait + } else { + return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait + } + } + + private var cellWidth: CGFloat { + let keyWindow = UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) } + .first + let safeAreaInsets = keyWindow?.safeAreaInsets ?? .zero + let safeWidth = UIScreen.main.bounds.width - safeAreaInsets.left - safeAreaInsets.right + let totalSpacing: CGFloat = 16 * CGFloat(columnsCount + 1) + let availableWidth = safeWidth - totalSpacing + return availableWidth / CGFloat(columnsCount) + } + var body: some View { NavigationView { ScrollView { + let columnsCount = determineColumns() + VStack(spacing: 0) { HStack { SearchBar(text: $searchText, onSearchButtonClicked: performSearch) @@ -79,9 +106,9 @@ struct SearchView: View { if !searchText.isEmpty { if isSearching { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 16) { - ForEach(0..<2, id: \.self) { _ in - SearchSkeletonCell() + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) { + ForEach(0.. Int { + if UIDevice.current.userInterfaceIdiom == .pad { + return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait + } else { + return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait + } + } } struct SearchBar: View { diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index c9bbfbd..ed0f83e 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -14,6 +14,9 @@ struct SettingsViewGeneral: View { @AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false @AppStorage("multiThreads") private var multiThreadsEnabled: Bool = false @AppStorage("metadataProviders") private var metadataProviders: String = "AniList" + @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 + @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 + private let metadataProvidersList = ["AniList"] @EnvironmentObject var settings: Settings @@ -76,6 +79,43 @@ struct SettingsViewGeneral: View { // .tint(.accentColor) //} + Section(header: Text("Media Grid Layout"), footer: Text("Adjust the number of media items per row in portrait and landscape modes.")) { + HStack { + if UIDevice.current.userInterfaceIdiom == .pad { + Picker("Portrait Columns", selection: $mediaColumnsPortrait) { + ForEach(1..<6) { i in + Text("\(i)").tag(i) + } + } + .pickerStyle(MenuPickerStyle()) + } else { + Picker("Portrait Columns", selection: $mediaColumnsPortrait) { + ForEach(1..<5) { i in + Text("\(i)").tag(i) + } + } + .pickerStyle(MenuPickerStyle()) + } + } + HStack { + if UIDevice.current.userInterfaceIdiom == .pad { + Picker("Landscape Columns", selection: $mediaColumnsLandscape) { + ForEach(2..<9) { i in + Text("\(i)").tag(i) + } + } + .pickerStyle(MenuPickerStyle()) + } else { + Picker("Landscape Columns", selection: $mediaColumnsLandscape) { + ForEach(2..<6) { i in + Text("\(i)").tag(i) + } + } + .pickerStyle(MenuPickerStyle()) + } + } + } + Section(header: Text("Modules"), footer: Text("Note that the modules will be replaced only if there is a different version string inside the JSON file.")) { Toggle("Refresh Modules on Launch", isOn: $refreshModulesOnLaunch) .tint(.accentColor) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 7eadefe..c37ecd2 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -13,12 +13,14 @@ struct SettingsViewPlayer: View { @AppStorage("hideNextButton") private var isHideNextButton = false @AppStorage("rememberPlaySpeed") private var isRememberPlaySpeed = false @AppStorage("holdSpeedPlayer") private var holdSpeedPlayer: Double = 2.0 + @AppStorage("skipIncrement") private var skipIncrement: Double = 10.0 + @AppStorage("skipIncrementHold") private var skipIncrementHold: Double = 30.0 private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"] var body: some View { Form { - Section(header: Text("Media Player"), footer: Text("Some features are limited to the Sora and Default player, such as ForceLandscape and holdSpeed")) { + Section(header: Text("Media Player"), footer: Text("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.")) { HStack { Text("Media Player") Spacer() @@ -56,7 +58,21 @@ struct SettingsViewPlayer: View { } } } - + Section(header: Text("Skip Settings")) { + // Normal skip + HStack { + Text("Tap Skip:") + Spacer() + Stepper("\(Int(skipIncrement))s", value: $skipIncrement, in: 5...300, step: 5) + } + + // Long-press skip + HStack { + Text("Long press Skip:") + Spacer() + Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5) + } + } SubtitleSettingsSection() } .navigationTitle("Player") diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index d5494e1..738eb75 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -58,9 +58,9 @@ 13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */; }; 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; }; 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; }; - 13EA2BD72D32D97400C1EBD7 /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */; }; 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; + 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -115,9 +115,9 @@ 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = ""; }; 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; - 13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; + 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -361,6 +361,15 @@ path = DetailsView; sourceTree = ""; }; + 1384DCDF2D89BE870094797A /* Helpers */ = { + isa = PBXGroup; + children = ( + 13CBA0872D60F19C00EFE70A /* VTTSubtitlesLoader.swift */, + 13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */, + ); + path = Helpers; + sourceTree = ""; + }; 138AA1B52D2D66EC0021F9DF /* EpisodeCell */ = { isa = PBXGroup; children = ( @@ -425,10 +434,9 @@ 13EA2BD02D32D97400C1EBD7 /* CustomPlayer */ = { isa = PBXGroup; children = ( + 1384DCDF2D89BE870094797A /* Helpers */, 13EA2BD22D32D97400C1EBD7 /* Components */, 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */, - 13CBA0872D60F19C00EFE70A /* VTTSubtitlesLoader.swift */, - 13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */, ); path = CustomPlayer; sourceTree = ""; @@ -437,7 +445,7 @@ isa = PBXGroup; children = ( 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */, - 13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */, + 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */, ); path = Components; sourceTree = ""; @@ -532,7 +540,6 @@ 1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, - 13EA2BD72D32D97400C1EBD7 /* MusicProgressSlider.swift in Sources */, 1334FF542D787217007E289F /* TMDBRequest.swift in Sources */, 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */, 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, @@ -556,6 +563,7 @@ 1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */, 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */, 133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */, + 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */, 133D7C942D2BE2640075467E /* JSController.swift in Sources */, 133D7C922D2BE2640075467E /* URLSession.swift in Sources */, 133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,