mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 21:38:15 +00:00
add native player controls and captions
This commit is contained in:
parent
75e76e14d4
commit
419ffae415
9 changed files with 1096 additions and 4 deletions
|
|
@ -4,6 +4,9 @@ final class NativePlayerViewController: UIViewController {
|
|||
private let request: NativePlaybackRequest
|
||||
private var backend: NativePlaybackBackend
|
||||
private var startupTimer: Timer?
|
||||
private var controlsTimer: Timer?
|
||||
private var progressTimer: Timer?
|
||||
private var isScrubbing = false
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
private let loadingView: UIActivityIndicatorView = {
|
||||
|
|
@ -25,6 +28,49 @@ final class NativePlayerViewController: UIViewController {
|
|||
return button
|
||||
}()
|
||||
|
||||
private let controlsContainer: UIVisualEffectView = {
|
||||
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.layer.cornerRadius = 12
|
||||
view.clipsToBounds = true
|
||||
return view
|
||||
}()
|
||||
|
||||
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")
|
||||
private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds")
|
||||
private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")
|
||||
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions")
|
||||
|
||||
private let elapsedLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.textColor = .white
|
||||
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
|
||||
label.text = "0:00"
|
||||
return label
|
||||
}()
|
||||
|
||||
private let remainingLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.textColor = .white
|
||||
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
|
||||
label.textAlignment = .right
|
||||
label.text = "-0:00"
|
||||
return label
|
||||
}()
|
||||
|
||||
private let scrubber: UISlider = {
|
||||
let slider = UISlider()
|
||||
slider.translatesAutoresizingMaskIntoConstraints = false
|
||||
slider.minimumValue = 0
|
||||
slider.maximumValue = 1
|
||||
slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1)
|
||||
slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3)
|
||||
slider.thumbTintColor = .white
|
||||
return slider
|
||||
}()
|
||||
|
||||
private let failureLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
@ -74,6 +120,8 @@ final class NativePlayerViewController: UIViewController {
|
|||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
startupTimer?.invalidate()
|
||||
controlsTimer?.invalidate()
|
||||
progressTimer?.invalidate()
|
||||
backend.stop()
|
||||
onDismiss?()
|
||||
}
|
||||
|
|
@ -86,6 +134,9 @@ final class NativePlayerViewController: UIViewController {
|
|||
self?.startupTimer?.invalidate()
|
||||
self?.loadingView.stopAnimating()
|
||||
self?.loadingView.isHidden = true
|
||||
self?.startProgressUpdates()
|
||||
self?.refreshControls()
|
||||
self?.scheduleControlsHide()
|
||||
}
|
||||
}
|
||||
backend.onFailure = { [weak self] error in
|
||||
|
|
@ -94,6 +145,16 @@ final class NativePlayerViewController: UIViewController {
|
|||
self?.showFailure(error)
|
||||
}
|
||||
}
|
||||
backend.onStateChange = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.refreshControls()
|
||||
}
|
||||
}
|
||||
backend.onSubtitleTracksChange = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.refreshControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startStartupTimer() {
|
||||
|
|
@ -108,8 +169,38 @@ final class NativePlayerViewController: UIViewController {
|
|||
view.addSubview(backend.view)
|
||||
view.addSubview(loadingView)
|
||||
view.addSubview(failureLabel)
|
||||
view.addSubview(controlsContainer)
|
||||
view.addSubview(closeButton)
|
||||
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
|
||||
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
|
||||
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
|
||||
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
|
||||
captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside)
|
||||
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
|
||||
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
|
||||
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))
|
||||
tap.cancelsTouchesInView = false
|
||||
view.addGestureRecognizer(tap)
|
||||
|
||||
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])
|
||||
controlRow.translatesAutoresizingMaskIntoConstraints = false
|
||||
controlRow.axis = .horizontal
|
||||
controlRow.alignment = .center
|
||||
controlRow.distribution = .equalCentering
|
||||
controlRow.spacing = 18
|
||||
|
||||
let timeRow = UIStackView(arrangedSubviews: [elapsedLabel, remainingLabel])
|
||||
timeRow.translatesAutoresizingMaskIntoConstraints = false
|
||||
timeRow.axis = .horizontal
|
||||
timeRow.distribution = .fillEqually
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [scrubber, timeRow, controlRow])
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
controlsContainer.contentView.addSubview(stack)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
|
|
@ -127,7 +218,25 @@ final class NativePlayerViewController: UIViewController {
|
|||
closeButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
||||
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12)
|
||||
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
|
||||
|
||||
controlsContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18),
|
||||
controlsContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18),
|
||||
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),
|
||||
|
||||
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 16),
|
||||
stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -16),
|
||||
stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 14),
|
||||
stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -14),
|
||||
|
||||
jumpBackButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
jumpBackButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
playPauseButton.widthAnchor.constraint(equalToConstant: 54),
|
||||
playPauseButton.heightAnchor.constraint(equalToConstant: 54),
|
||||
jumpForwardButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
jumpForwardButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
captionsButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
captionsButton.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
}
|
||||
|
||||
|
|
@ -144,4 +253,123 @@ final class NativePlayerViewController: UIViewController {
|
|||
@objc private func close() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func togglePlayPause() {
|
||||
backend.togglePlayPause()
|
||||
revealControls()
|
||||
}
|
||||
|
||||
@objc private func jumpBack() {
|
||||
backend.jump(by: -15)
|
||||
revealControls()
|
||||
}
|
||||
|
||||
@objc private func jumpForward() {
|
||||
backend.jump(by: 15)
|
||||
revealControls()
|
||||
}
|
||||
|
||||
@objc private func scrubbingStarted() {
|
||||
isScrubbing = true
|
||||
controlsTimer?.invalidate()
|
||||
}
|
||||
|
||||
@objc private func scrubberChanged() {
|
||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)
|
||||
}
|
||||
|
||||
@objc private func scrubbingEnded() {
|
||||
backend.seek(to: scrubber.value)
|
||||
isScrubbing = false
|
||||
revealControls()
|
||||
}
|
||||
|
||||
@objc private func toggleControlsVisibility() {
|
||||
if controlsContainer.alpha < 1 {
|
||||
revealControls()
|
||||
} else if backend.isPlaying {
|
||||
hideControls()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showCaptions() {
|
||||
revealControls()
|
||||
let alert = UIAlertController(title: "Captions", message: nil, preferredStyle: .actionSheet)
|
||||
SubtitleOptionMapper.options(from: backend.subtitleTracks).forEach { track in
|
||||
let prefix = track.id == backend.selectedSubtitleTrackID ? "Selected: " : ""
|
||||
alert.addAction(UIAlertAction(title: "\(prefix)\(track.name)", style: .default) { [weak self] _ in
|
||||
self?.backend.selectSubtitleTrack(id: track.id)
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "Delay -0.5s", style: .default) { [weak self] _ in
|
||||
self?.backend.adjustSubtitleDelay(by: -0.5)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default) { [weak self] _ in
|
||||
self?.backend.adjustSubtitleDelay(by: 0.5)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Current Delay: \(String(format: "%.1fs", backend.subtitleDelay))", style: .default))
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
if let popover = alert.popoverPresentationController {
|
||||
popover.sourceView = captionsButton
|
||||
popover.sourceRect = captionsButton.bounds
|
||||
}
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func startProgressUpdates() {
|
||||
progressTimer?.invalidate()
|
||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
self?.refreshControls()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshControls() {
|
||||
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
|
||||
scrubber.isEnabled = backend.isSeekable
|
||||
jumpBackButton.isEnabled = backend.isSeekable
|
||||
jumpForwardButton.isEnabled = backend.isSeekable
|
||||
captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty
|
||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
|
||||
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
|
||||
if !isScrubbing {
|
||||
scrubber.value = backend.position
|
||||
}
|
||||
[scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 }
|
||||
}
|
||||
|
||||
private func revealControls() {
|
||||
UIView.animate(withDuration: 0.18) {
|
||||
self.controlsContainer.alpha = 1
|
||||
self.closeButton.alpha = 1
|
||||
}
|
||||
scheduleControlsHide()
|
||||
}
|
||||
|
||||
private func hideControls() {
|
||||
UIView.animate(withDuration: 0.24) {
|
||||
self.controlsContainer.alpha = 0
|
||||
self.closeButton.alpha = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleControlsHide() {
|
||||
controlsTimer?.invalidate()
|
||||
guard backend.isPlaying else {
|
||||
return
|
||||
}
|
||||
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
|
||||
self?.hideControls()
|
||||
}
|
||||
}
|
||||
|
||||
private static func iconButton(systemName: String, label: String) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setImage(UIImage(systemName: systemName), for: .normal)
|
||||
button.tintColor = .white
|
||||
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
button.layer.cornerRadius = 22
|
||||
button.accessibilityLabel = label
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue