mirror of
https://github.com/cranci1/Sora.git
synced 2026-04-14 13:30:23 +00:00
huge things (#50)
This commit is contained in:
commit
77797d9d42
14 changed files with 989 additions and 214 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 */,
|
||||
|
|
|
|||
Loading…
Reference in a new issue