Sora/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift
Francesco 5d34c4e946
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run
crash fixed
2025-05-20 14:52:02 +02:00

2415 lines
101 KiB
Swift

//
// CustomPlayer.swift
// test2
//
// Created by Francesco on 23/02/25.
//
import UIKit
import AVKit
import SwiftUI
import MediaPlayer
import AVFoundation
import MarqueeLabel
class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDelegate {
let module: ScrapingModule
let streamURL: String
let fullUrl: String
let titleText: String
let episodeNumber: Int
let episodeImageUrl: String
let subtitlesURL: String?
let onWatchNext: () -> Void
let aniListID: Int
var headers: [String:String]? = nil
private var aniListUpdatedSuccessfully = false
private var aniListUpdateImpossible: Bool = false
private var aniListRetryCount = 0
private let aniListMaxRetries = 6
var player: AVPlayer!
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
var duration: Double = 0.0
var isVideoLoaded = false
private var isHoldPauseEnabled: Bool {
UserDefaults.standard.bool(forKey: "holdForPauseEnabled")
}
private var isSkip85Visible: Bool {
if UserDefaults.standard.object(forKey: "skip85Visible") == nil {
return true
}
return UserDefaults.standard.bool(forKey: "skip85Visible")
}
private var isDoubleTapSkipEnabled: Bool {
if UserDefaults.standard.object(forKey: "doubleTapSeekEnabled") == nil {
return false
}
return UserDefaults.standard.bool(forKey: "doubleTapSeekEnabled")
}
var portraitButtonVisibleConstraints: [NSLayoutConstraint] = []
var portraitButtonHiddenConstraints: [NSLayoutConstraint] = []
var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = []
var landscapeButtonHiddenConstraints: [NSLayoutConstraint] = []
var currentMarqueeConstraints: [NSLayoutConstraint] = []
private var currentMenuButtonTrailing: NSLayoutConstraint!
var subtitleForegroundColor: String = "white"
var subtitleBackgroundEnabled: Bool = true
var subtitleFontSize: Double = 20.0
var subtitleShadowRadius: Double = 1.0
var subtitlesLoader = VTTSubtitlesLoader()
var subtitleStackView: UIStackView!
var subtitleLabels: [UILabel] = []
var subtitlesEnabled: Bool = true {
didSet {
subtitleStackView.isHidden = !subtitlesEnabled
}
}
var marqueeLabel: MarqueeLabel!
var playerViewController: AVPlayerViewController!
var controlsContainerView: UIView!
var playPauseButton: UIImageView!
var backwardButton: UIImageView!
var forwardButton: UIImageView!
var topSubtitleLabel: UILabel!
var dismissButton: UIButton!
var menuButton: UIButton!
var watchNextButton: UIButton!
var watchNextIconButton: UIButton!
var blackCoverView: UIView!
var speedButton: UIButton!
var skip85Button: UIButton!
var qualityButton: UIButton!
var holdSpeedIndicator: UIButton!
private var lockButton: UIButton!
var isHLSStream: Bool = false
var qualities: [(String, String)] = []
var currentQualityURL: URL?
var baseM3U8URL: URL?
var sliderHostingController: UIHostingController<MusicProgressSlider<Double>>?
var sliderViewModel = SliderViewModel()
var isSliderEditing = false
var watchNextButtonNormalConstraints: [NSLayoutConstraint] = []
var watchNextButtonControlsConstraints: [NSLayoutConstraint] = []
var isControlsVisible = false
private var subtitleBottomToSliderConstraint: NSLayoutConstraint?
private var subtitleBottomToSafeAreaConstraint: NSLayoutConstraint?
var subtitleBottomPadding: CGFloat = 10.0 {
didSet {
updateSubtitleLabelConstraints()
}
}
private var wasPlayingBeforeSeek = false
private var malID: Int?
private var skipIntervals: (op: CMTimeRange?, ed: CMTimeRange?) = (nil, nil)
private var skipIntroButton: UIButton!
private var skipOutroButton: UIButton!
private let skipButtonBaseAlpha: CGFloat = 0.9
@Published var segments: [ClosedRange<Double>] = []
private var skipIntroLeading: NSLayoutConstraint!
private var skipOutroLeading: NSLayoutConstraint!
private var originalIntroLeading: CGFloat = 0
private var originalOutroLeading: CGFloat = 0
private var skipIntroDismissedInSession = false
private var skipOutroDismissedInSession = false
private var playerItemKVOContext = 0
private var loadedTimeRangesObservation: NSKeyValueObservation?
private var playerTimeControlStatusObserver: NSKeyValueObservation?
private var controlsLocked = false
private var lockButtonTimer: Timer?
private var isDimmed = false
private var dimButton: UIButton!
private var dimButtonToSlider: NSLayoutConstraint!
private var dimButtonToRight: NSLayoutConstraint!
private var dimButtonTimer: Timer?
private lazy var controlsToHide: [UIView] = [
dismissButton,
playPauseButton,
backwardButton,
forwardButton,
sliderHostingController?.view,
skip85Button,
marqueeLabel,
menuButton,
qualityButton,
speedButton,
watchNextButton,
volumeSliderHostingView
].compactMap { $0 }
private var originalHiddenStates: [UIView: Bool] = [:]
private var volumeObserver: NSKeyValueObservation?
private var audioSession = AVAudioSession.sharedInstance()
private var hiddenVolumeView = MPVolumeView(frame: .zero)
private var systemVolumeSlider: UISlider?
private var volumeValue: Double = 0.0
private var volumeViewModel = VolumeViewModel()
var volumeSliderHostingView: UIView?
private var subtitleDelay: Double = 0.0
var currentPlaybackSpeed: Float = 1.0
init(module: ScrapingModule,
urlString: String,
fullUrl: String,
title: String,
episodeNumber: Int,
onWatchNext: @escaping () -> Void,
subtitlesURL: String?,
aniListID: Int,
episodeImageUrl: String,headers:[String:String]?) {
self.module = module
self.streamURL = urlString
self.fullUrl = fullUrl
self.titleText = title
self.episodeNumber = episodeNumber
self.episodeImageUrl = episodeImageUrl
self.onWatchNext = onWatchNext
self.subtitlesURL = subtitlesURL
self.aniListID = aniListID
self.headers = headers
super.init(nibName: nil, bundle: nil)
guard let url = URL(string: urlString) else {
fatalError("Invalid URL string")
}
var request = URLRequest(url: url)
if let mydict = headers, !mydict.isEmpty {
for (key,value) in mydict {
request.addValue(value, forHTTPHeaderField: key)
}
} else {
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
}
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)
self.player = AVPlayer(playerItem: playerItem)
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)")
if lastPlayedTime > 0 {
let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1)
self.player.seek(to: seekTime)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
setupHoldGesture()
loadSubtitleSettings()
setupPlayerViewController()
setupControls()
addInvisibleControlOverlays()
setupWatchNextButton()
setupSubtitleLabel()
setupDismissButton()
volumeSlider()
setupDimButton()
setupSpeedButton()
setupQualityButton()
setupMenuButton()
setupMarqueeLabel()
setupSkip85Button()
setupSkipButtons()
setupSkipAndDismissGestures()
addTimeObserver()
startUpdateTimer()
setupLockButton()
setupAudioSession()
updateSkipButtonsVisibility()
setupHoldSpeedIndicator()
view.bringSubviewToFront(subtitleStackView)
AniListMutation().fetchMalID(animeId: aniListID) { [weak self] result in
switch result {
case .success(let mal):
self?.malID = mal
self?.fetchSkipTimes(type: "op")
self?.fetchSkipTimes(type: "ed")
case .failure(let error):
Logger.shared.log("Unable to fetch MAL ID: \(error)",type:"Error")
}
}
controlsToHide.forEach { originalHiddenStates[$0] = $0.isHidden }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.checkForHLSStream()
}
if isHoldPauseEnabled {
holdForPause()
}
do {
try audioSession.setActive(true)
} catch {
Logger.shared.log("Error activating audio session: \(error)", type: "Debug")
}
volumeViewModel.value = Double(audioSession.outputVolume)
volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] session, change in
guard let newVol = change.newValue else { return }
DispatchQueue.main.async {
self?.volumeViewModel.value = Double(newVol)
Logger.shared.log("Hardware volume changed, new value: \(newVol)", type: "Debug")
}
}
#if os(iOS) && !targetEnvironment(macCatalyst)
if #available(iOS 16.0, *) {
playerViewController.allowsVideoFrameAnalysis = false
}
#endif
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()
}
hiddenVolumeView.showsRouteButton = false
hiddenVolumeView.isHidden = true
view.addSubview(hiddenVolumeView)
hiddenVolumeView.translatesAutoresizingMaskIntoConstraints = false
hiddenVolumeView.widthAnchor.constraint(equalToConstant: 1).isActive = true
hiddenVolumeView.heightAnchor.constraint(equalToConstant: 1).isActive = true
hiddenVolumeView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
hiddenVolumeView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
if let slider = hiddenVolumeView.subviews.first(where: { $0 is UISlider }) as? UISlider {
systemVolumeSlider = slider
}
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
self.updateMarqueeConstraints()
})
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard let marqueeLabel = marqueeLabel else {
return
}
let availableWidth = marqueeLabel.frame.width
let textWidth = marqueeLabel.intrinsicContentSize.width
if textWidth > availableWidth {
marqueeLabel.lineBreakMode = .byTruncatingTail
} else {
marqueeLabel.lineBreakMode = .byClipping
}
updateMenuButtonConstraints()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
player?.play()
setInitialPlayerRate()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
NotificationCenter.default.addObserver(self, selector: #selector(playerItemDidChange), name: .AVPlayerItemNewAccessLogEntry, object: nil)
skip85Button?.isHidden = !isSkip85Visible
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if let playbackSpeed = player?.rate {
UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed")
}
if let token = timeObserverToken {
player.removeTimeObserver(token)
timeObserverToken = nil
}
loadedTimeRangesObservation?.invalidate()
loadedTimeRangesObservation = nil
updateTimer?.invalidate()
inactivityTimer?.invalidate()
player.pause()
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard context == &playerItemKVOContext else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
if keyPath == "loadedTimeRanges" {
}
}
@objc private func playerItemDidChange() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.qualityButton.isHidden && self.isHLSStream {
self.qualityButton.isHidden = false
self.qualityButton.menu = self.qualitySelectionMenu()
self.updateMenuButtonConstraints()
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
self.view.layoutIfNeeded()
}
}
}
}
private func getSegmentsColor() -> Color {
if let data = UserDefaults.standard.data(forKey: "segmentsColorData"),
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor {
return Color(uiColor)
}
return .yellow
}
func setupPlayerViewController() {
playerViewController = AVPlayerViewController()
playerViewController.player = player
playerViewController.showsPlaybackControls = false
addChild(playerViewController)
view.addSubview(playerViewController.view)
playerViewController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
playerViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
playerViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
playerViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
playerViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
playerViewController.didMove(toParent: self)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleControls))
view.addGestureRecognizer(tapGesture)
}
func setupControls() {
controlsContainerView = UIView()
controlsContainerView.backgroundColor = UIColor.black.withAlphaComponent(0.0)
view.addSubview(controlsContainerView)
controlsContainerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
controlsContainerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
controlsContainerView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
controlsContainerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
controlsContainerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
])
blackCoverView = UIView()
blackCoverView.backgroundColor = UIColor.black.withAlphaComponent(0.4)
blackCoverView.translatesAutoresizingMaskIntoConstraints = false
controlsContainerView.insertSubview(blackCoverView, at: 0)
NSLayoutConstraint.activate([
blackCoverView.topAnchor.constraint(equalTo: view.topAnchor),
blackCoverView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
blackCoverView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
blackCoverView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
backwardButton = UIImageView(image: UIImage(systemName: "gobackward"))
backwardButton.tintColor = .white
backwardButton.contentMode = .scaleAspectFit
backwardButton.isUserInteractionEnabled = true
backwardButton.layer.shadowColor = UIColor.black.cgColor
backwardButton.layer.shadowOffset = CGSize(width: 0, height: 2)
backwardButton.layer.shadowOpacity = 0.6
backwardButton.layer.shadowRadius = 4
backwardButton.layer.masksToBounds = false
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
playPauseButton = UIImageView(image: UIImage(systemName: "pause.fill"))
playPauseButton.tintColor = .white
playPauseButton.contentMode = .scaleAspectFit
playPauseButton.isUserInteractionEnabled = true
playPauseButton.layer.shadowColor = UIColor.black.cgColor
playPauseButton.layer.shadowOffset = CGSize(width: 0, height: 2)
playPauseButton.layer.shadowOpacity = 0.6
playPauseButton.layer.shadowRadius = 4
playPauseButton.layer.masksToBounds = false
let playPauseTap = UITapGestureRecognizer(target: self, action: #selector(togglePlayPause))
playPauseTap.delaysTouchesBegan = false
playPauseTap.delegate = self
playPauseButton.addGestureRecognizer(playPauseTap)
playPauseButton.addGestureRecognizer(playPauseTap)
controlsContainerView.addSubview(playPauseButton)
playPauseButton.translatesAutoresizingMaskIntoConstraints = false
forwardButton = UIImageView(image: UIImage(systemName: "goforward"))
forwardButton.tintColor = .white
forwardButton.contentMode = .scaleAspectFit
forwardButton.isUserInteractionEnabled = true
forwardButton.layer.shadowColor = UIColor.black.cgColor
forwardButton.layer.shadowOffset = CGSize(width: 0, height: 2)
forwardButton.layer.shadowOpacity = 0.6
forwardButton.layer.shadowRadius = 4
forwardButton.layer.masksToBounds = false
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
let segmentsColor = self.getSegmentsColor()
let sliderView = MusicProgressSlider(
value: Binding(
get: { self.sliderViewModel.sliderValue },
set: { self.sliderViewModel.sliderValue = $0 }
),
inRange: 0...(duration > 0 ? duration : 1.0),
activeFillColor: .white,
fillColor: .white.opacity(0.6),
textColor: .white.opacity(0.7),
emptyColor: .white.opacity(0.3),
height: 33,
onEditingChanged: { editing in
if editing {
self.isSliderEditing = true
self.wasPlayingBeforeSeek = (self.player.timeControlStatus == .playing)
self.originalRate = self.player.rate
self.player.pause()
} else {
let target = CMTime(seconds: self.sliderViewModel.sliderValue,
preferredTimescale: 600)
self.player.seek(
to: target,
toleranceBefore: .zero,
toleranceAfter: .zero
) { [weak self] _ in
guard let self = self else { return }
let final = self.player.currentTime().seconds
self.sliderViewModel.sliderValue = final
self.currentTimeVal = final
self.isSliderEditing = false
if self.wasPlayingBeforeSeek {
self.player.playImmediately(atRate: self.originalRate)
}
}
}
},
introSegments: sliderViewModel.introSegments,
outroSegments: sliderViewModel.outroSegments,
introColor: segmentsColor,
outroColor: segmentsColor
)
sliderHostingController = UIHostingController(rootView: sliderView)
guard let sliderHostView = sliderHostingController?.view else { return }
sliderHostView.backgroundColor = .clear
sliderHostView.translatesAutoresizingMaskIntoConstraints = false
controlsContainerView.addSubview(sliderHostView)
NSLayoutConstraint.activate([
sliderHostView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18),
sliderHostView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -18),
sliderHostView.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -20),
sliderHostView.heightAnchor.constraint(equalToConstant: 30)
])
NSLayoutConstraint.activate([
playPauseButton.centerXAnchor.constraint(equalTo: controlsContainerView.centerXAnchor),
playPauseButton.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor),
playPauseButton.widthAnchor.constraint(equalToConstant: 50),
playPauseButton.heightAnchor.constraint(equalToConstant: 50),
backwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor),
backwardButton.trailingAnchor.constraint(equalTo: playPauseButton.leadingAnchor, constant: -50),
backwardButton.widthAnchor.constraint(equalToConstant: 40),
backwardButton.heightAnchor.constraint(equalToConstant: 40),
forwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor),
forwardButton.leadingAnchor.constraint(equalTo: playPauseButton.trailingAnchor, constant: 50),
forwardButton.widthAnchor.constraint(equalToConstant: 40),
forwardButton.heightAnchor.constraint(equalToConstant: 40)
])
}
func holdForPause() {
let holdForPauseGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldForPause(_:)))
holdForPauseGesture.minimumPressDuration = 1
holdForPauseGesture.numberOfTouchesRequired = 2
view.addGestureRecognizer(holdForPauseGesture)
}
func addInvisibleControlOverlays() {
let playPauseOverlay = UIButton(type: .custom)
playPauseOverlay.backgroundColor = .clear
playPauseOverlay.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
view.addSubview(playPauseOverlay)
playPauseOverlay.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
playPauseOverlay.centerXAnchor.constraint(equalTo: playPauseButton.centerXAnchor),
playPauseOverlay.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor),
playPauseOverlay.widthAnchor.constraint(equalTo: playPauseButton.widthAnchor, constant: 20),
playPauseOverlay.heightAnchor.constraint(equalTo: playPauseButton.heightAnchor, constant: 20)
])
}
func setupSkipAndDismissGestures() {
if isDoubleTapSkipEnabled {
let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTapGesture.numberOfTapsRequired = 2
view.addGestureRecognizer(doubleTapGesture)
if let gestures = view.gestureRecognizers {
for gesture in gestures {
if let tapGesture = gesture as? UITapGestureRecognizer, tapGesture.numberOfTapsRequired == 1 {
tapGesture.require(toFail: doubleTapGesture)
}
}
}
}
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
if let introSwipe = skipIntroButton.gestureRecognizers?.first(
where: { $0 is UISwipeGestureRecognizer && ($0 as! UISwipeGestureRecognizer).direction == .left }
),
let outroSwipe = skipOutroButton.gestureRecognizers?.first(
where: { $0 is UISwipeGestureRecognizer && ($0 as! UISwipeGestureRecognizer).direction == .left }
) {
panGesture.require(toFail: introSwipe)
panGesture.require(toFail: outroSwipe)
}
view.addGestureRecognizer(panGesture)
}
func showSkipFeedback(direction: String) {
let diameter: CGFloat = 600
if let existingFeedback = view.viewWithTag(999) {
existingFeedback.layer.removeAllAnimations()
existingFeedback.removeFromSuperview()
}
let circleView = UIView()
circleView.backgroundColor = UIColor.white.withAlphaComponent(0.0)
circleView.layer.cornerRadius = diameter / 2
circleView.clipsToBounds = true
circleView.translatesAutoresizingMaskIntoConstraints = false
circleView.isUserInteractionEnabled = false
circleView.tag = 999
let iconName = (direction == "forward") ? "goforward" : "gobackward"
let imageView = UIImageView(image: UIImage(systemName: iconName))
imageView.tintColor = .black
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.alpha = 0.8
circleView.addSubview(imageView)
if direction == "forward" {
NSLayoutConstraint.activate([
imageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor),
imageView.centerXAnchor.constraint(equalTo: circleView.leadingAnchor, constant: diameter / 4),
imageView.widthAnchor.constraint(equalToConstant: 100),
imageView.heightAnchor.constraint(equalToConstant: 100)
])
} else {
NSLayoutConstraint.activate([
imageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor),
imageView.centerXAnchor.constraint(equalTo: circleView.trailingAnchor, constant: -diameter / 4),
imageView.widthAnchor.constraint(equalToConstant: 100),
imageView.heightAnchor.constraint(equalToConstant: 100)
])
}
view.addSubview(circleView)
if direction == "forward" {
NSLayoutConstraint.activate([
circleView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
circleView.centerXAnchor.constraint(equalTo: view.trailingAnchor),
circleView.widthAnchor.constraint(equalToConstant: diameter),
circleView.heightAnchor.constraint(equalToConstant: diameter)
])
} else {
NSLayoutConstraint.activate([
circleView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
circleView.centerXAnchor.constraint(equalTo: view.leadingAnchor),
circleView.widthAnchor.constraint(equalToConstant: diameter),
circleView.heightAnchor.constraint(equalToConstant: diameter)
])
}
UIView.animate(withDuration: 0.2, animations: {
circleView.backgroundColor = UIColor.white.withAlphaComponent(0.5)
imageView.alpha = 0.8
}) { _ in
UIView.animate(withDuration: 0.5, delay: 0.5, options: [], animations: {
circleView.backgroundColor = UIColor.white.withAlphaComponent(0.0)
imageView.alpha = 0.0
}, completion: { _ in
circleView.removeFromSuperview()
imageView.removeFromSuperview()
})
}
}
func setupSubtitleLabel() {
subtitleStackView = UIStackView()
subtitleStackView.axis = .vertical
subtitleStackView.alignment = .center
subtitleStackView.distribution = .fill
subtitleStackView.spacing = 2
if let subtitleStackView = subtitleStackView {
view.addSubview(subtitleStackView)
subtitleStackView.translatesAutoresizingMaskIntoConstraints = false
subtitleBottomToSliderConstraint = subtitleStackView.bottomAnchor.constraint(
equalTo: sliderHostingController?.view.topAnchor ?? view.bottomAnchor,
constant: -20
)
subtitleBottomToSafeAreaConstraint = subtitleStackView.bottomAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
constant: -subtitleBottomPadding
)
NSLayoutConstraint.activate([
subtitleStackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
subtitleStackView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
subtitleStackView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
])
subtitleBottomToSafeAreaConstraint?.isActive = true
}
for _ in 0..<2 {
let label = UILabel()
label.textAlignment = .center
label.numberOfLines = 0
label.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
subtitleLabels.append(label)
subtitleStackView.addArrangedSubview(label)
}
updateSubtitleLabelAppearance()
}
func updateSubtitleLabelConstraints() {
if isControlsVisible {
subtitleBottomToSliderConstraint?.constant = -20
} else {
subtitleBottomToSafeAreaConstraint?.constant = -subtitleBottomPadding
}
view.setNeedsLayout()
UIView.animate(withDuration: 0.2) {
self.view.layoutIfNeeded()
}
}
func setupDismissButton() {
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold)
let image = UIImage(systemName: "xmark", withConfiguration: config)
dismissButton = UIButton(type: .system)
dismissButton.setImage(image, for: .normal)
dismissButton.tintColor = .white
dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside)
controlsContainerView.addSubview(dismissButton)
dismissButton.translatesAutoresizingMaskIntoConstraints = false
dismissButton.layer.shadowColor = UIColor.black.cgColor
dismissButton.layer.shadowOffset = CGSize(width: 0, height: 2)
dismissButton.layer.shadowOpacity = 0.6
dismissButton.layer.shadowRadius = 4
dismissButton.layer.masksToBounds = false
NSLayoutConstraint.activate([
dismissButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 16),
dismissButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
dismissButton.widthAnchor.constraint(equalToConstant: 40),
dismissButton.heightAnchor.constraint(equalToConstant: 40)
])
}
func setupMarqueeLabel() {
marqueeLabel = MarqueeLabel()
marqueeLabel.text = "\(titleText) • Ep \(episodeNumber)"
marqueeLabel.type = .continuous
marqueeLabel.textColor = .white
marqueeLabel.font = UIFont.systemFont(ofSize: 14, weight: .heavy)
marqueeLabel.speed = .rate(35)
marqueeLabel.fadeLength = 10.0
marqueeLabel.leadingBuffer = 1.0
marqueeLabel.trailingBuffer = 16.0
marqueeLabel.animationDelay = 2.5
marqueeLabel.layer.shadowColor = UIColor.black.cgColor
marqueeLabel.layer.shadowOffset = CGSize(width: 0, height: 2)
marqueeLabel.layer.shadowOpacity = 0.6
marqueeLabel.layer.shadowRadius = 4
marqueeLabel.layer.masksToBounds = false
marqueeLabel.lineBreakMode = .byTruncatingTail
marqueeLabel.textAlignment = .left
controlsContainerView.addSubview(marqueeLabel)
marqueeLabel.translatesAutoresizingMaskIntoConstraints = false
updateMarqueeConstraints()
}
func volumeSlider() {
let container = VolumeSliderContainer(volumeVM: self.volumeViewModel) { newVal in
if let sysSlider = self.systemVolumeSlider {
sysSlider.value = Float(newVal)
}
}
let hostingController = UIHostingController(rootView: container)
hostingController.view.backgroundColor = UIColor.clear
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
controlsContainerView.addSubview(hostingController.view)
addChild(hostingController)
hostingController.didMove(toParent: self)
self.volumeSliderHostingView = hostingController.view
NSLayoutConstraint.activate([
hostingController.view.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16),
hostingController.view.widthAnchor.constraint(equalToConstant: 160),
hostingController.view.heightAnchor.constraint(equalToConstant: 30)
])
}
private func setupHoldSpeedIndicator() {
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let image = UIImage(systemName: "forward.fill", withConfiguration: config)
var speed = UserDefaults.standard.float(forKey: "holdSpeedPlayer")
if speed == 0.0 {
speed = 2.0
}
holdSpeedIndicator = UIButton(type: .system)
holdSpeedIndicator.setTitle(" \(speed)", for: .normal)
holdSpeedIndicator.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
holdSpeedIndicator.setImage(image, for: .normal)
holdSpeedIndicator.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8)
holdSpeedIndicator.tintColor = .white
holdSpeedIndicator.setTitleColor(.white, for: .normal)
holdSpeedIndicator.layer.cornerRadius = 21
holdSpeedIndicator.alpha = 0
holdSpeedIndicator.layer.shadowColor = UIColor.black.cgColor
holdSpeedIndicator.layer.shadowOffset = CGSize(width: 0, height: 2)
holdSpeedIndicator.layer.shadowOpacity = 0.6
holdSpeedIndicator.layer.shadowRadius = 4
holdSpeedIndicator.layer.masksToBounds = false
view.addSubview(holdSpeedIndicator)
holdSpeedIndicator.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
holdSpeedIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
holdSpeedIndicator.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
holdSpeedIndicator.heightAnchor.constraint(equalToConstant: 40),
holdSpeedIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 85)
])
holdSpeedIndicator.isUserInteractionEnabled = false
}
private func updateSkipButtonsVisibility() {
let t = currentTimeVal
let controlsShowing = isControlsVisible
func handle(_ button: UIButton, range: CMTimeRange?) {
guard let r = range else { button.isHidden = true; return }
let inInterval = t >= r.start.seconds && t <= r.end.seconds
let target = controlsShowing ? 0.0 : skipButtonBaseAlpha
if inInterval {
if button.isHidden {
button.alpha = 0
}
button.isHidden = false
UIView.animate(withDuration: 0.25) {
button.alpha = target
}
return
}
guard !button.isHidden else { return }
UIView.animate(withDuration: 0.15, animations: {
button.alpha = 0
}) { _ in
button.isHidden = true
}
}
handle(skipIntroButton, range: skipIntervals.op)
handle(skipOutroButton, range: skipIntervals.ed)
if skipIntroDismissedInSession {
skipIntroButton.isHidden = true
} else {
handle(skipIntroButton, range: skipIntervals.op)
}
if skipOutroDismissedInSession {
skipOutroButton.isHidden = true
} else {
handle(skipOutroButton, range: skipIntervals.ed)
}
}
private func updateSegments() {
sliderViewModel.introSegments.removeAll()
sliderViewModel.outroSegments.removeAll()
if let op = skipIntervals.op {
let start = max(0, op.start.seconds / max(duration, 0.01))
let end = min(1, op.end.seconds / max(duration, 0.01))
if start <= end {
sliderViewModel.introSegments.append(start...end)
}
}
if let ed = skipIntervals.ed {
let start = max(0, ed.start.seconds / max(duration, 0.01))
let end = min(1, ed.end.seconds / max(duration, 0.01))
if start <= end {
sliderViewModel.outroSegments.append(start...end)
}
}
let segmentsColor = self.getSegmentsColor()
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let validDuration = max(self.duration, 0.01)
self.sliderHostingController?.rootView = MusicProgressSlider(
value: Binding(
get: { max(0, min(self.sliderViewModel.sliderValue, validDuration)) },
set: { self.sliderViewModel.sliderValue = max(0, min($0, validDuration)) }
),
inRange: 0...validDuration,
activeFillColor: .white,
fillColor: .white.opacity(0.6),
textColor: .white.opacity(0.7),
emptyColor: .white.opacity(0.3),
height: 33,
onEditingChanged: { editing in
if editing {
self.isSliderEditing = true
self.wasPlayingBeforeSeek = (self.player.timeControlStatus == .playing)
self.originalRate = self.player.rate
self.player.pause()
} else {
let target = CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600)
self.player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
guard let self = self else { return }
let final = self.player.currentTime().seconds
self.sliderViewModel.sliderValue = final
self.currentTimeVal = final
self.isSliderEditing = false
if self.wasPlayingBeforeSeek {
self.player.playImmediately(atRate: self.originalRate)
}
}
}
},
introSegments: self.sliderViewModel.introSegments,
outroSegments: self.sliderViewModel.outroSegments,
introColor: segmentsColor,
outroColor: segmentsColor
)
}
}
private func fetchSkipTimes(type: String) {
guard let mal = malID else { return }
let url = URL(string: "https://api.aniskip.com/v2/skip-times/\(mal)/\(episodeNumber)?types=\(type)&episodeLength=0")!
URLSession.shared.dataTask(with: url) { data, _, _ in
guard let d = data,
let resp = try? JSONDecoder().decode(AniSkipResponse.self, from: d),
resp.found,
let interval = resp.results.first?.interval else { return }
let range = CMTimeRange(
start: CMTime(seconds: interval.startTime, preferredTimescale: 600),
end: CMTime(seconds: interval.endTime, preferredTimescale: 600)
)
DispatchQueue.main.async {
if type == "op" {
self.skipIntervals.op = range
} else {
self.skipIntervals.ed = range
}
if self.duration > 0 {
self.updateSegments()
}
}
}.resume()
}
func setupSkipButtons() {
let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig)
skipIntroButton = UIButton(type: .system)
skipIntroButton.setTitle(" Skip Intro", for: .normal)
skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skipIntroButton.setImage(introImage, for: .normal)
skipIntroButton.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8)
skipIntroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
skipIntroButton.tintColor = .white
skipIntroButton.setTitleColor(.white, for: .normal)
skipIntroButton.layer.cornerRadius = 21
skipIntroButton.alpha = skipButtonBaseAlpha
skipIntroButton.layer.shadowColor = UIColor.black.cgColor
skipIntroButton.layer.shadowOffset = CGSize(width: 0, height: 2)
skipIntroButton.layer.shadowOpacity = 0.6
skipIntroButton.layer.shadowRadius = 4
skipIntroButton.layer.masksToBounds = false
skipIntroButton.addTarget(self, action: #selector(skipIntro), for: .touchUpInside)
view.addSubview(skipIntroButton)
skipIntroButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
skipIntroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor),
skipIntroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5),
skipIntroButton.heightAnchor.constraint(equalToConstant: 40),
skipIntroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104)
])
let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig)
skipOutroButton = UIButton(type: .system)
skipOutroButton.setTitle(" Skip Outro", for: .normal)
skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skipOutroButton.setImage(outroImage, for: .normal)
skipOutroButton.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8)
skipOutroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
skipOutroButton.tintColor = .white
skipOutroButton.setTitleColor(.white, for: .normal)
skipOutroButton.layer.cornerRadius = 21
skipOutroButton.alpha = skipButtonBaseAlpha
skipOutroButton.layer.shadowColor = UIColor.black.cgColor
skipOutroButton.layer.shadowOffset = CGSize(width: 0, height: 2)
skipOutroButton.layer.shadowOpacity = 0.6
skipOutroButton.layer.shadowRadius = 4
skipOutroButton.layer.masksToBounds = false
skipOutroButton.addTarget(self, action: #selector(skipOutro), for: .touchUpInside)
view.addSubview(skipOutroButton)
skipOutroButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
skipOutroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor),
skipOutroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5),
skipOutroButton.heightAnchor.constraint(equalToConstant: 40),
skipOutroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104)
])
}
private func setupDimButton() {
let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular)
dimButton = UIButton(type: .system)
dimButton.setImage(UIImage(systemName: "moon.fill", withConfiguration: cfg), for: .normal)
dimButton.tintColor = .white
dimButton.addTarget(self, action: #selector(dimTapped), for: .touchUpInside)
controlsContainerView.addSubview(dimButton)
dimButton.translatesAutoresizingMaskIntoConstraints = false
dimButton.layer.shadowColor = UIColor.black.cgColor
dimButton.layer.shadowOffset = CGSize(width: 0, height: 2)
dimButton.layer.shadowOpacity = 0.6
dimButton.layer.shadowRadius = 4
dimButton.layer.masksToBounds = false
NSLayoutConstraint.activate([
dimButton.topAnchor.constraint(equalTo: volumeSliderHostingView!.bottomAnchor, constant: 15),
dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor),
dimButton.widthAnchor.constraint(equalToConstant: 24),
dimButton.heightAnchor.constraint(equalToConstant: 24)
])
dimButtonToSlider = dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor)
dimButtonToRight = dimButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16)
dimButtonToSlider.isActive = true
}
private func setupLockButton() {
// copy dim-button styling
let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular)
lockButton = UIButton(type: .system)
lockButton.setImage(
UIImage(systemName: "lock.open.fill", withConfiguration: cfg),
for: .normal
)
lockButton.tintColor = .white
lockButton.layer.shadowColor = UIColor.black.cgColor
lockButton.layer.shadowOffset = CGSize(width: 0, height: 2)
lockButton.layer.shadowOpacity = 0.6
lockButton.layer.shadowRadius = 4
lockButton.layer.masksToBounds = false
lockButton.addTarget(self, action: #selector(lockTapped), for: .touchUpInside)
view.addSubview(lockButton)
lockButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
lockButton.topAnchor.constraint(equalTo: volumeSliderHostingView!.bottomAnchor, constant: 60),
lockButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor),
lockButton.widthAnchor.constraint(equalToConstant: 24),
lockButton.heightAnchor.constraint(equalToConstant: 24),
])
}
func updateMarqueeConstraints() {
UIView.performWithoutAnimation {
NSLayoutConstraint.deactivate(currentMarqueeConstraints)
let leftSpacing: CGFloat = 2
let rightSpacing: CGFloat = 6
let trailingAnchor: NSLayoutXAxisAnchor = (volumeSliderHostingView?.isHidden == false)
? volumeSliderHostingView!.leadingAnchor
: view.safeAreaLayoutGuide.trailingAnchor
currentMarqueeConstraints = [
marqueeLabel.leadingAnchor.constraint(
equalTo: dismissButton.trailingAnchor, constant: leftSpacing),
marqueeLabel.trailingAnchor.constraint(
equalTo: trailingAnchor, constant: -rightSpacing - 10),
marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor)
]
NSLayoutConstraint.activate(currentMarqueeConstraints)
view.layoutIfNeeded()
}
}
func setupMenuButton() {
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold)
let image = UIImage(systemName: "text.bubble", withConfiguration: config)
menuButton = UIButton(type: .system)
menuButton.setImage(image, for: .normal)
menuButton.tintColor = .white
if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty {
menuButton.showsMenuAsPrimaryAction = true
menuButton.menu = buildOptionsMenu()
} else {
menuButton.isHidden = true
}
dismissButton.layer.shadowColor = UIColor.black.cgColor
dismissButton.layer.shadowOffset = CGSize(width: 0, height: 2)
dismissButton.layer.shadowOpacity = 0.6
dismissButton.layer.shadowRadius = 4
dismissButton.layer.masksToBounds = false
controlsContainerView.addSubview(menuButton)
menuButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
menuButton.topAnchor.constraint(equalTo: qualityButton.topAnchor),
menuButton.widthAnchor.constraint(equalToConstant: 40),
menuButton.heightAnchor.constraint(equalToConstant: 40),
])
currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -6)
}
func setupSpeedButton() {
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold)
let image = UIImage(systemName: "speedometer", withConfiguration: config)
speedButton = UIButton(type: .system)
speedButton.setImage(image, for: .normal)
speedButton.tintColor = .white
speedButton.showsMenuAsPrimaryAction = true
speedButton.menu = speedChangerMenu()
speedButton.layer.shadowColor = UIColor.black.cgColor
speedButton.layer.shadowOffset = CGSize(width: 0, height: 2)
speedButton.layer.shadowOpacity = 0.6
speedButton.layer.shadowRadius = 4
speedButton.layer.masksToBounds = false
controlsContainerView.addSubview(speedButton)
speedButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
speedButton.topAnchor.constraint(equalTo: watchNextButton.topAnchor),
speedButton.trailingAnchor.constraint(equalTo: watchNextButton.leadingAnchor, constant: 18),
speedButton.widthAnchor.constraint(equalToConstant: 40),
speedButton.heightAnchor.constraint(equalToConstant: 40)
])
}
func setupWatchNextButton() {
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold)
let image = UIImage(systemName: "forward.end", withConfiguration: config)
watchNextButton = UIButton(type: .system)
watchNextButton.setImage(image, for: .normal)
watchNextButton.backgroundColor = .clear
watchNextButton.tintColor = .white
watchNextButton.setTitleColor(.white, for: .normal)
// The shadow:
watchNextButton.layer.shadowColor = UIColor.black.cgColor
watchNextButton.layer.shadowOffset = CGSize(width: 0, height: 2)
watchNextButton.layer.shadowOpacity = 0.6
watchNextButton.layer.shadowRadius = 4
watchNextButton.layer.masksToBounds = false
watchNextButton.addTarget(self, action: #selector(watchNextTapped), for: .touchUpInside)
controlsContainerView.addSubview(watchNextButton)
watchNextButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor, constant: 20),
watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5),
watchNextButton.heightAnchor.constraint(equalToConstant: 40),
watchNextButton.widthAnchor.constraint(equalToConstant: 80)
])
}
func setupSkip85Button() {
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
let image = UIImage(systemName: "goforward", withConfiguration: config)
skip85Button = UIButton(type: .system)
skip85Button.setTitle(" Skip 85s", for: .normal)
skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
skip85Button.setImage(image, for: .normal)
skip85Button.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8)
skip85Button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
skip85Button.tintColor = .white
skip85Button.setTitleColor(.white, for: .normal)
skip85Button.layer.cornerRadius = 21
skip85Button.alpha = 0.7
skip85Button.layer.shadowColor = UIColor.black.cgColor
skip85Button.layer.shadowOffset = CGSize(width: 0, height: 2)
skip85Button.layer.shadowOpacity = 0.6
skip85Button.layer.shadowRadius = 4
skip85Button.layer.masksToBounds = false
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: -5),
skip85Button.heightAnchor.constraint(equalToConstant: 40),
skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 97)
])
skip85Button.isHidden = !isSkip85Visible
}
private func setupQualityButton() {
let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold)
let image = UIImage(systemName: "4k.tv", withConfiguration: config)
qualityButton = UIButton(type: .system)
qualityButton.setImage(image, for: .normal)
qualityButton.tintColor = .white
qualityButton.showsMenuAsPrimaryAction = true
qualityButton.menu = qualitySelectionMenu()
qualityButton.isHidden = true
qualityButton.layer.shadowColor = UIColor.black.cgColor
qualityButton.layer.shadowOffset = CGSize(width: 0, height: 2)
qualityButton.layer.shadowOpacity = 0.6
qualityButton.layer.shadowRadius = 4
qualityButton.layer.masksToBounds = false
controlsContainerView.addSubview(qualityButton)
qualityButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
qualityButton.topAnchor.constraint(equalTo: speedButton.topAnchor),
qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -6),
qualityButton.widthAnchor.constraint(equalToConstant: 40),
qualityButton.heightAnchor.constraint(equalToConstant: 40)
])
}
func updateSubtitleLabelAppearance() {
for subtitleLabel in subtitleLabels {
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
subtitleLabel.textColor = subtitleUIColor()
subtitleLabel.backgroundColor = subtitleBackgroundEnabled
? UIColor.black.withAlphaComponent(0.6)
: .clear
subtitleLabel.layer.cornerRadius = 5
subtitleLabel.clipsToBounds = true
subtitleLabel.layer.shadowColor = UIColor.black.cgColor
subtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius)
subtitleLabel.layer.shadowOpacity = 1.0
subtitleLabel.layer.shadowOffset = .zero
}
}
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,
currentItem.duration.seconds.isFinite else { return }
let currentDuration = currentItem.duration.seconds
if currentDuration.isNaN || currentDuration <= 0 { return }
self.currentTimeVal = time.seconds
self.duration = currentDuration
self.updateSegments()
if !self.isSliderEditing {
self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration))
}
self.updateSkipButtonsVisibility()
UserDefaults.standard.set(self.currentTimeVal, forKey: "lastPlayedTime_\(self.fullUrl)")
UserDefaults.standard.set(self.duration, forKey: "totalTime_\(self.fullUrl)")
if self.subtitlesEnabled {
let adjustedTime = self.currentTimeVal - self.subtitleDelay
let cues = self.subtitlesLoader.cues.filter { adjustedTime >= $0.startTime && adjustedTime <= $0.endTime }
if cues.count > 0 {
self.subtitleLabels[0].text = cues[0].text.strippedHTML
self.subtitleLabels[0].isHidden = false
} else {
self.subtitleLabels[0].text = ""
self.subtitleLabels[0].isHidden = !self.subtitlesEnabled
}
if cues.count > 1 {
self.subtitleLabels[1].text = cues[1].text.strippedHTML
self.subtitleLabels[1].isHidden = false
} else {
self.subtitleLabels[1].text = ""
self.subtitleLabels[1].isHidden = true
}
} else {
self.subtitleLabels[0].text = ""
self.subtitleLabels[0].isHidden = true
self.subtitleLabels[1].text = ""
self.subtitleLabels[1].isHidden = true
}
let segmentsColor = self.getSegmentsColor()
DispatchQueue.main.async {
if let currentItem = self.player.currentItem, currentItem.duration.seconds > 0 {
let progress = min(max(self.currentTimeVal / self.duration, 0), 1.0)
let item = ContinueWatchingItem(
id: UUID(),
imageUrl: self.episodeImageUrl,
episodeNumber: self.episodeNumber,
mediaTitle: self.titleText,
progress: progress,
streamUrl: self.streamURL,
fullUrl: self.fullUrl,
subtitles: self.subtitlesURL,
aniListID: self.aniListID,
module: self.module,
headers: self.headers
)
ContinueWatchingManager.shared.save(item: item)
}
let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration
if remainingPercentage < 0.1 &&
self.aniListID != 0 &&
!self.aniListUpdatedSuccessfully &&
!self.aniListUpdateImpossible
{
self.tryAniListUpdate()
}
self.sliderHostingController?.rootView = MusicProgressSlider(
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.6),
textColor: .white.opacity(0.7),
emptyColor: .white.opacity(0.3),
height: 33,
onEditingChanged: { editing in
if editing {
self.isSliderEditing = true
self.wasPlayingBeforeSeek = (self.player.timeControlStatus == .playing)
self.originalRate = self.player.rate
self.player.pause()
} else {
let target = CMTime(seconds: self.sliderViewModel.sliderValue,
preferredTimescale: 600)
self.player.seek(
to: target,
toleranceBefore: .zero,
toleranceAfter: .zero
) { [weak self] _ in
guard let self = self else { return }
let final = self.player.currentTime().seconds
self.sliderViewModel.sliderValue = final
self.currentTimeVal = final
self.isSliderEditing = false
if self.wasPlayingBeforeSeek {
self.player.playImmediately(atRate: self.originalRate)
}
}
}
},
introSegments: self.sliderViewModel.introSegments,
outroSegments: self.sliderViewModel.outroSegments,
introColor: segmentsColor,
outroColor: segmentsColor
)
}
}
}
func startUpdateTimer() {
updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self = self else { return }
self.currentTimeVal = self.player.currentTime().seconds
}
}
func updateMenuButtonConstraints() {
currentMenuButtonTrailing.isActive = false
let anchor: NSLayoutXAxisAnchor
if (!qualityButton.isHidden) {
anchor = qualityButton.leadingAnchor
} else if (!speedButton.isHidden) {
anchor = speedButton.leadingAnchor
} else {
anchor = controlsContainerView.trailingAnchor
}
currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: anchor, constant: -6)
currentMenuButtonTrailing.isActive = true
}
@objc func toggleControls() {
if controlsLocked {
lockButton.alpha = 1.0
lockButtonTimer?.invalidate()
lockButtonTimer = Timer.scheduledTimer(
withTimeInterval: 3.0,
repeats: false
) { [weak self] _ in
UIView.animate(withDuration: 0.3) {
self?.lockButton.alpha = 0
}
}
updateSkipButtonsVisibility()
return
}
if isDimmed {
// show the dim button
dimButton.isHidden = false
dimButton.alpha = 1.0
dimButtonTimer?.invalidate()
dimButtonTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
UIView.animate(withDuration: 0.3) {
self?.dimButton.alpha = 0
}
}
updateSkipButtonsVisibility()
return
}
isControlsVisible.toggle()
UIView.animate(withDuration: 0.2) {
let alpha: CGFloat = self.isControlsVisible ? 1.0 : 0.0
self.controlsContainerView.alpha = alpha
self.skip85Button.alpha = alpha
self.lockButton.alpha = alpha // Fade lock button with controls
self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible
self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible
self.view.layoutIfNeeded()
}
updateSkipButtonsVisibility()
}
@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)) { [weak self] finished in
guard self != nil else { return }
}
animateButtonRotation(backwardButton, clockwise: false)
}
}
@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)) { [weak self] finished in
guard self != nil else { return }
}
animateButtonRotation(forwardButton)
}
}
@objc func seekBackward() {
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)) { [weak self] finished in
guard self != nil else { return }
}
animateButtonRotation(backwardButton, clockwise: false)
}
@objc func seekForward() {
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)) { [weak self] finished in
guard self != nil else { return } }
animateButtonRotation(forwardButton)
}
@objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
let tapLocation = gesture.location(in: view)
if tapLocation.x < view.bounds.width / 2 {
seekBackward()
showSkipFeedback(direction: "backward")
} else {
seekForward()
showSkipFeedback(direction: "forward")
}
}
@objc func handleSwipeDown(_ gesture: UISwipeGestureRecognizer) {
dismiss(animated: true, completion: nil)
}
@objc func togglePlayPause() {
if isPlaying {
currentPlaybackSpeed = player.rate
player.pause()
isPlaying = false
playPauseButton.image = UIImage(systemName: "play.fill")
DispatchQueue.main.async {
if !self.isControlsVisible {
self.isControlsVisible = true
UIView.animate(withDuration: 0.1, animations: {
self.controlsContainerView.alpha = 1.0
self.skip85Button.alpha = 0.8
})
self.updateSkipButtonsVisibility()
}
}
} else {
player.play()
player.rate = currentPlaybackSpeed
isPlaying = true
playPauseButton.image = UIImage(systemName: "pause.fill")
}
}
@objc private func lockTapped() {
controlsLocked.toggle()
isControlsVisible = !controlsLocked
lockButtonTimer?.invalidate()
if controlsLocked {
UIView.animate(withDuration: 0.25) {
self.controlsContainerView.alpha = 0
self.dimButton.alpha = 0
for v in self.controlsToHide { v.alpha = 0 }
self.skipIntroButton.alpha = 0
self.skipOutroButton.alpha = 0
self.skip85Button.alpha = 0
self.lockButton.alpha = 0
self.subtitleBottomToSafeAreaConstraint?.isActive = true
self.subtitleBottomToSliderConstraint?.isActive = false
self.view.layoutIfNeeded()
}
lockButton.setImage(UIImage(systemName: "lock.fill"), for: .normal)
} else {
UIView.animate(withDuration: 0.25) {
self.controlsContainerView.alpha = 1
self.dimButton.alpha = 1
for v in self.controlsToHide { v.alpha = 1 }
self.subtitleBottomToSafeAreaConstraint?.isActive = false
self.subtitleBottomToSliderConstraint?.isActive = true
self.view.layoutIfNeeded()
}
lockButton.setImage(UIImage(systemName: "lock.open.fill"), for: .normal)
updateSkipButtonsVisibility()
}
}
@objc private func skipIntro() {
if let range = skipIntervals.op {
player.seek(to: range.end)
skipIntroButton.isHidden = true
}
}
@objc private func skipOutro() {
if let range = skipIntervals.ed {
player.seek(to: range.end)
skipOutroButton.isHidden = true
}
}
@objc func dismissTapped() {
dismiss(animated: true, completion: nil)
}
@objc func watchNextTapped() {
player.pause()
dismiss(animated: true) { [weak self] in
self?.onWatchNext()
}
}
@objc func skip85Tapped() {
currentTimeVal = min(currentTimeVal + 85, duration)
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
}
@objc private func handleHoldForPause(_ gesture: UILongPressGestureRecognizer) {
guard isHoldPauseEnabled else { return }
if gesture.state == .began {
togglePlayPause()
}
}
@objc private func dimTapped() {
isDimmed.toggle()
isControlsVisible = !isDimmed
dimButtonTimer?.invalidate()
UIView.animate(withDuration: 0.25) {
self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.4
// fade all controls (and lock button) in or out
for v in self.controlsToHide { v.alpha = self.isDimmed ? 0 : 1 }
self.dimButton.alpha = self.isDimmed ? 0 : 1
self.lockButton.alpha = self.isDimmed ? 0 : 1
// switch subtitle constraints just like toggleControls()
self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible
self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible
self.view.layoutIfNeeded()
}
// slide the dim-icon over
dimButtonToSlider.isActive = !isDimmed
dimButtonToRight.isActive = isDimmed
}
func speedChangerMenu() -> UIMenu {
let speeds: [Double] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
let playbackSpeedActions = speeds.map { speed in
UIAction(title: String(format: "%.2f", speed)) { _ in
self.player.rate = Float(speed)
if self.player.timeControlStatus != .playing {
self.player.pause()
}
}
}
return UIMenu(title: "Playback Speed", children: playbackSpeedActions)
}
private func tryAniListUpdate() {
let aniListMutation = AniListMutation()
aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { [weak self] result in
guard let self = self else { return }
switch result {
case .success:
self.aniListUpdatedSuccessfully = true
Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General")
case .failure(let error):
let errorString = error.localizedDescription.lowercased()
Logger.shared.log("AniList progress update failed: \(errorString)", type: "Error")
if errorString.contains("access token not found") {
Logger.shared.log("AniList update will NOT retry due to missing token.", type: "Error")
self.aniListUpdateImpossible = true
} else {
if self.aniListRetryCount < self.aniListMaxRetries {
self.aniListRetryCount += 1
let delaySeconds = 5.0
Logger.shared.log("AniList update will retry in \(delaySeconds)s (attempt \(self.aniListRetryCount)).", type: "Debug")
DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) {
self.tryAniListUpdate()
}
} else {
Logger.shared.log("AniList update reached max retries. No more attempts.", type: "Error")
}
}
}
}
}
private func animateButtonRotation(_ button: UIView, clockwise: Bool = true) {
if button.layer.animation(forKey: "rotate360") != nil {
return
}
button.superview?.layoutIfNeeded()
button.layer.shouldRasterize = true
button.layer.rasterizationScale = UIScreen.main.scale
button.layer.allowsEdgeAntialiasing = true
let rotation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = CGFloat.pi * 2 * (clockwise ? 1 : -1)
rotation.duration = 0.43
rotation.timingFunction = CAMediaTimingFunction(name: .linear)
button.layer.add(rotation, forKey: "rotate360")
DispatchQueue.main.asyncAfter(deadline: .now() + rotation.duration) {
button.layer.shouldRasterize = false
}
}
private func parseM3U8(url: URL, completion: @escaping () -> Void) {
var request = URLRequest(url: url)
if let mydict = headers, !mydict.isEmpty
{
for (key,value) in mydict
{
request.addValue(value, forHTTPHeaderField: key)
}
}
else
{
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
}
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 {
Logger.shared.log("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: .whitespacesAndNewline)
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)
if let mydict = headers, !mydict.isEmpty
{
for (key,value) in mydict
{
request.addValue(value, forHTTPHeaderField: key)
}
}
else
{
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
}
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 last = UserDefaults.standard.string(forKey: "lastSelectedQuality"),
self.qualities.contains(where: { $0.1 == last }) {
self.switchToQuality(urlString: last)
}
self.qualityButton.isHidden = false
self.qualityButton.menu = self.qualitySelectionMenu()
self.updateMenuButtonConstraints()
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
self.view.layoutIfNeeded()
}
}
} else {
isHLSStream = false
qualityButton.isHidden = true
updateMenuButtonConstraints()
}
}
func buildOptionsMenu() -> UIMenu {
var menuElements: [UIMenuElement] = []
if let subURL = subtitlesURL, !subURL.isEmpty {
let subtitlesToggleAction = UIAction(title: "Toggle Subtitles") { [weak self] _ in
guard let self = self else { return }
self.subtitlesEnabled.toggle()
}
let foregroundActions = [
UIAction(title: "White") { _ in
SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "white" }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "Yellow") { _ in
SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "yellow" }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "Green") { _ in
SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "green" }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "Blue") { _ in
SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "blue" }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "Red") { _ in
SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "red" }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "Purple") { _ in
SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "purple" }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
}
]
let colorMenu = UIMenu(title: "Subtitle Color", children: foregroundActions)
let fontSizeActions = [
UIAction(title: "16") { _ in
SubtitleSettingsManager.shared.update { settings in settings.fontSize = 16 }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "18") { _ in
SubtitleSettingsManager.shared.update { settings in settings.fontSize = 18 }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "20") { _ in
SubtitleSettingsManager.shared.update { settings in settings.fontSize = 20 }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "22") { _ in
SubtitleSettingsManager.shared.update { settings in settings.fontSize = 22 }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "24") { _ in
SubtitleSettingsManager.shared.update { settings in settings.fontSize = 24 }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "Custom") { _ in self.presentCustomFontAlert() }
]
let fontSizeMenu = UIMenu(title: "Font Size", children: fontSizeActions)
let shadowActions = [
UIAction(title: "None") { _ in
SubtitleSettingsManager.shared.update { settings in settings.shadowRadius = 0 }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "Low") { _ in
SubtitleSettingsManager.shared.update { settings in settings.shadowRadius = 1 }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "Medium") { _ in
SubtitleSettingsManager.shared.update { settings in settings.shadowRadius = 3 }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
},
UIAction(title: "High") { _ in
SubtitleSettingsManager.shared.update { settings in settings.shadowRadius = 6 }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
}
]
let shadowMenu = UIMenu(title: "Shadow Intensity", children: shadowActions)
let backgroundActions = [
UIAction(title: "Toggle") { _ in
SubtitleSettingsManager.shared.update { settings in settings.backgroundEnabled.toggle() }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
}
]
let backgroundMenu = UIMenu(title: "Background", children: backgroundActions)
let paddingActions = [
UIAction(title: "10p") { _ in
SubtitleSettingsManager.shared.update { settings in settings.bottomPadding = 10 }
self.loadSubtitleSettings()
},
UIAction(title: "20p") { _ in
SubtitleSettingsManager.shared.update { settings in settings.bottomPadding = 20 }
self.loadSubtitleSettings()
},
UIAction(title: "30p") { _ in
SubtitleSettingsManager.shared.update { settings in settings.bottomPadding = 30 }
self.loadSubtitleSettings()
},
UIAction(title: "Custom") { _ in self.presentCustomPaddingAlert() }
]
let paddingMenu = UIMenu(title: "Bottom Padding", children: paddingActions)
let delayActions = [
UIAction(title: "-0.5s") { [weak self] _ in
guard let self = self else { return }
self.adjustSubtitleDelay(by: -0.5)
},
UIAction(title: "-0.2s") { [weak self] _ in
guard let self = self else { return }
self.adjustSubtitleDelay(by: -0.2)
},
UIAction(title: "+0.2s") { [weak self] _ in
guard let self = self else { return }
self.adjustSubtitleDelay(by: 0.2)
},
UIAction(title: "+0.5s") { [weak self] _ in
guard let self = self else { return }
self.adjustSubtitleDelay(by: 0.5)
},
UIAction(title: "Custom...") { [weak self] _ in
guard let self = self else { return }
self.presentCustomDelayAlert()
}
]
let resetDelayAction = UIAction(title: "Reset Delay") { [weak self] _ in
guard let self = self else { return }
SubtitleSettingsManager.shared.update { settings in settings.subtitleDelay = 0.0 }
self.subtitleDelay = 0.0
self.loadSubtitleSettings()
}
let delayMenu = UIMenu(title: "Subtitle Delay", children: delayActions + [resetDelayAction])
let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [
subtitlesToggleAction, colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu, delayMenu
])
menuElements = [subtitleOptionsMenu]
}
return UIMenu(title: "", children: menuElements)
}
func adjustSubtitleDelay(by amount: Double) {
let newValue = subtitleDelay + amount
let roundedValue = Double(round(newValue * 10) / 10)
SubtitleSettingsManager.shared.update { settings in settings.subtitleDelay = roundedValue }
self.subtitleDelay = roundedValue
self.loadSubtitleSettings()
}
func presentCustomDelayAlert() {
let alert = UIAlertController(title: "Enter Custom Delay", message: nil, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "Delay in seconds"
textField.keyboardType = .decimalPad
textField.text = String(format: "%.1f", self.subtitleDelay)
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Done", style: .default) { _ in
if let text = alert.textFields?.first?.text, let newDelay = Double(text) {
SubtitleSettingsManager.shared.update { settings in settings.subtitleDelay = newDelay }
self.subtitleDelay = newDelay
self.loadSubtitleSettings()
}
})
present(alert, animated: true)
}
func presentCustomPaddingAlert() {
let alert = UIAlertController(title: "Enter Custom Padding", message: nil, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "Padding Value"
textField.keyboardType = .numberPad
textField.text = String(Int(self.subtitleBottomPadding))
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Done", style: .default, handler: { _ in
if let text = alert.textFields?.first?.text, let intValue = Int(text) {
let newSize = CGFloat(intValue)
SubtitleSettingsManager.shared.update { settings in settings.bottomPadding = newSize }
self.loadSubtitleSettings()
}
}))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.present(alert, animated: true, completion: nil)
}
}
func presentCustomFontAlert() {
let alert = UIAlertController(title: "Enter Custom Font Size", message: nil, preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "Font Size"
textField.keyboardType = .numberPad
textField.text = String(Int(self.subtitleFontSize))
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Done", style: .default, handler: { _ in
if let text = alert.textFields?.first?.text, let newSize = Double(text) {
SubtitleSettingsManager.shared.update { settings in settings.fontSize = newSize }
self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
}
}))
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.present(alert, animated: true, completion: nil)
}
}
func loadSubtitleSettings() {
let settings = SubtitleSettingsManager.shared.settings
self.subtitleForegroundColor = settings.foregroundColor
self.subtitleFontSize = settings.fontSize
self.subtitleShadowRadius = settings.shadowRadius
self.subtitleBackgroundEnabled = settings.backgroundEnabled
self.subtitleBottomPadding = settings.bottomPadding
self.subtitleDelay = settings.subtitleDelay
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UserDefaults.standard.bool(forKey: "alwaysLandscape") {
return .landscape
} else {
return .all
}
}
override var prefersHomeIndicatorAutoHidden: Bool {
return true
}
override var prefersStatusBarHidden: Bool {
return true
}
func setupAudioSession() {
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, mode: .moviePlayback, options: .mixWithOthers)
try audioSession.setActive(true)
try audioSession.overrideOutputAudioPort(.speaker)
} catch {
Logger.shared.log("Didn't set up AVAudioSession: \(error)", type: "Debug")
}
volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] session, change in
guard let newVol = change.newValue else { return }
if let oldVol = self?.volumeViewModel.value, abs(Double(newVol) - oldVol) < 0.02 {
return
}
DispatchQueue.main.async {
self?.volumeViewModel.value = Double(newVol)
Logger.shared.log("Hardware volume changed, new value: \(newVol)", type: "Debug")
}
}
}
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
}
}
@objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: view)
switch gesture.state {
case .ended:
if translation.y > 100 {
dismiss(animated: true, completion: nil)
}
default:
break
}
}
private func beginHoldSpeed() {
guard let player = player else { return }
originalRate = player.rate
let holdSpeed = UserDefaults.standard.float(forKey: "holdSpeedPlayer")
let speed = holdSpeed > 0 ? holdSpeed : 2.0
player.rate = speed
UIView.animate(withDuration: 0.1) {
self.holdSpeedIndicator.alpha = 0.8
}
}
private func endHoldSpeed() {
player?.rate = originalRate
UIView.animate(withDuration: 0.2) {
self.holdSpeedIndicator.alpha = 0
}
}
private func setInitialPlayerRate() {
if UserDefaults.standard.bool(forKey: "rememberPlaySpeed") {
let lastPlayedSpeed = UserDefaults.standard.float(forKey: "lastPlaybackSpeed")
player?.rate = lastPlayedSpeed > 0 ? lastPlayedSpeed : 1.0
}
}
func setupTimeControlStatusObservation() {
playerTimeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
guard self != nil else { return }
if player.timeControlStatus == .paused,
let reason = player.reasonForWaitingToPlay {
Logger.shared.log("Paused reason: \(reason)", type: "Error")
if reason == .toMinimizeStalls {
player.play()
}
}
}
}
struct VolumeSliderContainer: View {
@ObservedObject var volumeVM: VolumeViewModel
var updateSystemSlider: ((Double) -> Void)? = nil
var body: some View {
VolumeSlider(
value: Binding(
get: { volumeVM.value },
set: { newVal in
volumeVM.value = newVal
updateSystemSlider?(newVal)
}
),
inRange: 0...1,
activeFillColor: .white,
fillColor: .white.opacity(0.6),
emptyColor: .white.opacity(0.3),
height: 10,
onEditingChanged: { _ in }
)
.shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2)
}
}
func subtitleUIColor() -> UIColor {
switch subtitleForegroundColor {
case "white": return .white
case "yellow": return .yellow
case "green": return .green
case "purple": return .purple
case "blue": return .blue
case "red": return .red
default: return .white
}
}
}
// 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