huge things (#50)

This commit is contained in:
cranci 2025-03-19 17:16:03 +01:00 committed by GitHub
commit 77797d9d42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 989 additions and 214 deletions

View file

@ -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)
}
}

View file

@ -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<MusicProgressSlider<Double>>?
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..<resolutionEndRange.lowerBound])
if let heightStr = resolutionPart.components(separatedBy: "x").last,
let height = Int(heightStr) {
let nextLine = lines[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
let qualityName = getQualityName(for: height)
var qualityURL = nextLine
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
if let baseURL = self.baseM3U8URL {
let baseURLString = baseURL.deletingLastPathComponent().absoluteString
qualityURL = URL(string: nextLine, relativeTo: baseURL)?.absoluteString ?? baseURLString + "/" + nextLine
}
}
if !qualities.contains(where: { $0.0 == qualityName }) {
qualities.append((qualityName, qualityURL))
}
}
}
}
}
DispatchQueue.main.async {
let autoQuality = qualities.first
var sortedQualities = qualities.dropFirst().sorted { first, second in
let firstHeight = Int(first.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
let secondHeight = Int(second.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
return firstHeight > 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

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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)

View file

@ -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<Int> = 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..<seasons.count, id: \.self) { index in
Button(action: { selectedSeason = index }) {
Text("Season \(index + 1)")
}
}
} label: {
Text("Season \(selectedSeason + 1)")
.font(.system(size: 14))
.foregroundColor(.accentColor)
}
}
}
}
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..<i {
let href = episodeLinks[idx].href
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(href)")
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)")
}
refreshTrigger.toggle()
Logger.shared.log("Marked \(ep.number) episodes watched within anime \"\(title)\".", type: "General")
if isGroupedBySeasons {
let seasons = groupedEpisodes()
if !seasons.isEmpty, selectedSeason < seasons.count {
ForEach(seasons[selectedSeason]) { ep in
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: 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..<i {
let href = episodeLinks[idx].href
UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(href)")
UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)")
}
refreshTrigger.toggle()
Logger.shared.log("Marked \(ep.number - 1) episodes watched within anime \"\(title)\".", type: "General")
}
)
.id(refreshTrigger)
.disabled(isFetchingEpisode)
}
}
}
} else {
@ -370,6 +431,25 @@ struct MediaInfoView: View {
return ranges
}
private func groupedEpisodes() -> [[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?

View file

@ -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..<columnsCount*4, id: \.self) { _ in
SearchSkeletonCell(cellWidth: cellWidth)
}
}
.padding(.top)
@ -101,16 +128,17 @@ struct SearchView: View {
.frame(maxWidth: .infinity)
.padding(.top)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 16) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) {
ForEach(searchItems) { item in
NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: selectedModule!)) {
VStack {
KFImage(URL(string: item.imageUrl))
.resizable()
.aspectRatio(2/3, contentMode: .fit)
.aspectRatio(contentMode: .fill)
.frame(height: cellWidth * 3 / 2)
.frame(maxWidth: cellWidth)
.cornerRadius(10)
.frame(width: 150, height: 225)
.clipped()
Text(item.title)
.font(.subheadline)
.foregroundColor(Color.primary)
@ -119,6 +147,12 @@ struct SearchView: View {
}
}
}
.onAppear {
updateOrientation()
}
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
updateOrientation()
}
}
.padding(.top)
.padding()
@ -219,6 +253,20 @@ struct SearchView: View {
}
}
}
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 SearchBar: View {

View file

@ -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)

View file

@ -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")

View file

@ -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 = "<group>"; };
13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = "<group>"; };
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -361,6 +361,15 @@
path = DetailsView;
sourceTree = "<group>";
};
1384DCDF2D89BE870094797A /* Helpers */ = {
isa = PBXGroup;
children = (
13CBA0872D60F19C00EFE70A /* VTTSubtitlesLoader.swift */,
13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
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 = "<group>";
@ -437,7 +445,7 @@
isa = PBXGroup;
children = (
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */,
13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */,
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */,
);
path = Components;
sourceTree = "<group>";
@ -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 */,