mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
Improve native player control experience
This commit is contained in:
parent
dbe9f1ca26
commit
ddfa22f004
4 changed files with 531 additions and 59 deletions
|
|
@ -39,3 +39,4 @@
|
|||
{"id":"int-c9b3bcd7","kind":"field_change","created_at":"2026-05-25T17:48:09.142384Z","actor":"dirtydishes","issue_id":"dreamio-ejh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by preserving known external subtitle display names for generic VLC subtitle tracks and expanding language-code aliases."}}
|
||||
{"id":"int-12bf46aa","kind":"field_change","created_at":"2026-05-25T18:31:50.873069Z","actor":"dirtydishes","issue_id":"dreamio-kdf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files."}}
|
||||
{"id":"int-fc9ecdb1","kind":"field_change","created_at":"2026-05-27T01:50:32.02792Z","actor":"dirtydishes","issue_id":"dreamio-ee1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed baseline UX audit in docs/native-player-ux-audit.md"}}
|
||||
{"id":"int-c8a14c48","kind":"field_change","created_at":"2026-05-27T01:56:02.08139Z","actor":"dirtydishes","issue_id":"dreamio-060","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native player Liquid Glass UX improvements and validated simulator build."}}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
{"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-060","title":"Improve native player controls experience","description":"Implement Liquid Glass-inspired native player UI improvements, touch target updates, scrubbing feedback, gestures, loading and failure states, menu polish, accessibility, and validation.","acceptance_criteria":"Native player controls are modernized; touch targets and scrubbing improve; gestures, loading/failure affordances, menu labels, visual polish, device adaptation, and accessibility are implemented; build validation is run.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:51:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:56:02Z","started_at":"2026-05-27T01:51:57Z","closed_at":"2026-05-27T01:56:02Z","close_reason":"Implemented native player Liquid Glass UX improvements and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-ee1","title":"Audit native player UX baseline","description":"Audit the existing native player controls and document current user experience strengths, gaps, and implementation constraints before making UI changes.","acceptance_criteria":"Current NativePlayerViewController controls are reviewed; backend constraints are summarized; UX improvement opportunities are documented.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:49:46Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:50:32Z","started_at":"2026-05-27T01:49:48Z","closed_at":"2026-05-27T01:50:32Z","close_reason":"Completed baseline UX audit in docs/native-player-ux-audit.md","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-p8p","title":"Recreate OpenSubtitles language turn doc with template","description":"Rebuild the OpenSubtitles caption-track turn document using the new lavender template and contained Clean SSR diff shells.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T05:16:17Z","created_by":"dirtydishes","updated_at":"2026-05-26T05:19:00Z","started_at":"2026-05-26T05:16:21Z","closed_at":"2026-05-26T05:19:00Z","close_reason":"Recreated the OpenSubtitles language turn document using the new lavender template and contained Clean SSR diff shells.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-h28","title":"Clean up turn document template guidance","description":"Add a reusable turn document template and tighten repository instructions so future turn docs use contained clean SSR diffs instead of raw generated diff blobs.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T05:07:57Z","created_by":"dirtydishes","updated_at":"2026-05-26T05:11:37Z","started_at":"2026-05-26T05:08:01Z","closed_at":"2026-05-26T05:11:37Z","close_reason":"Added the reusable turn document template and updated repository instructions for clean SSR diff rendering.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,35 @@ final class NativePlayerViewController: UIViewController {
|
|||
private var attachedSubtitleURLs: Set<URL>
|
||||
private var audioMenuSignature: String?
|
||||
private var captionsMenuSignature: String?
|
||||
private var controlsMaximumWidthConstraint: NSLayoutConstraint?
|
||||
private let bottomScrimLayer = CAGradientLayer()
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
private let loadingContainer: UIVisualEffectView = {
|
||||
let view = NativePlayerViewController.glassPanel(cornerRadius: 24)
|
||||
view.isHidden = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private let loadingStack: UIStackView = {
|
||||
let stack = UIStackView()
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
stack.axis = .horizontal
|
||||
stack.alignment = .center
|
||||
stack.spacing = 12
|
||||
return stack
|
||||
}()
|
||||
|
||||
private let loadingTextLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.text = "Opening stream…"
|
||||
label.textColor = .white
|
||||
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
return label
|
||||
}()
|
||||
|
||||
private let loadingView: UIActivityIndicatorView = {
|
||||
let view = UIActivityIndicatorView(style: .large)
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
@ -26,22 +53,16 @@ final class NativePlayerViewController: UIViewController {
|
|||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
button.tintColor = .white
|
||||
button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
|
||||
button.layer.cornerRadius = 18
|
||||
button.backgroundColor = UIColor.white.withAlphaComponent(0.14)
|
||||
button.layer.cornerRadius = 22
|
||||
button.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColor
|
||||
button.layer.borderWidth = 1
|
||||
button.accessibilityLabel = "Close"
|
||||
button.accessibilityHint = "Closes native playback and returns to Stremio."
|
||||
return button
|
||||
}()
|
||||
|
||||
private let controlsContainer: UIVisualEffectView = {
|
||||
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.layer.cornerRadius = 22
|
||||
view.clipsToBounds = true
|
||||
view.backgroundColor = UIColor.white.withAlphaComponent(0.08)
|
||||
view.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor
|
||||
view.layer.borderWidth = 1
|
||||
return view
|
||||
}()
|
||||
private let controlsContainer = NativePlayerViewController.glassPanel(cornerRadius: 26)
|
||||
|
||||
private let tapSurfaceView: UIView = {
|
||||
let view = UIView()
|
||||
|
|
@ -50,11 +71,13 @@ final class NativePlayerViewController: UIViewController {
|
|||
return view
|
||||
}()
|
||||
|
||||
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")
|
||||
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause", pointSize: 24)
|
||||
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 audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Tracks")
|
||||
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions")
|
||||
private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Track")
|
||||
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Subtitles")
|
||||
|
||||
private let centerPlayPauseButton = NativePlayerViewController.iconButton(systemName: "play.fill", label: "Toggle Playback", pointSize: 34)
|
||||
|
||||
private let elapsedLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
|
|
@ -88,17 +111,50 @@ final class NativePlayerViewController: UIViewController {
|
|||
return slider
|
||||
}()
|
||||
|
||||
private let failureLabel: UILabel = {
|
||||
private let scrubTimeBubble: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.textColor = .white
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
label.font = .preferredFont(forTextStyle: .body)
|
||||
label.isHidden = true
|
||||
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .bold)
|
||||
label.backgroundColor = UIColor.black.withAlphaComponent(0.56)
|
||||
label.layer.cornerRadius = 14
|
||||
label.clipsToBounds = true
|
||||
label.alpha = 0
|
||||
return label
|
||||
}()
|
||||
|
||||
private let failureContainer: UIVisualEffectView = {
|
||||
let view = NativePlayerViewController.glassPanel(cornerRadius: 28)
|
||||
view.isHidden = true
|
||||
return view
|
||||
}()
|
||||
|
||||
private let failureTitleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.text = "Playback could not start"
|
||||
label.textColor = .white
|
||||
label.textAlignment = .center
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
return label
|
||||
}()
|
||||
|
||||
private let failureDetailLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.textColor = UIColor.white.withAlphaComponent(0.82)
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
label.font = .preferredFont(forTextStyle: .subheadline)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
return label
|
||||
}()
|
||||
|
||||
private let retryButton: UIButton = NativePlayerViewController.textButton(title: "Retry")
|
||||
private let failureCloseButton: UIButton = NativePlayerViewController.textButton(title: "Close")
|
||||
|
||||
init(
|
||||
request: NativePlaybackRequest,
|
||||
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
|
||||
|
|
@ -136,9 +192,14 @@ final class NativePlayerViewController: UIViewController {
|
|||
view.backgroundColor = .black
|
||||
configureBackend()
|
||||
configureLayout()
|
||||
startStartupTimer()
|
||||
backend.play(request: request)
|
||||
addSubtitleCandidates(request.subtitleCandidates)
|
||||
configureAccessibility()
|
||||
startPlayback()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
bottomScrimLayer.frame = view.bounds
|
||||
updateLayoutForCurrentSize()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
@ -212,7 +273,7 @@ final class NativePlayerViewController: UIViewController {
|
|||
DispatchQueue.main.async {
|
||||
self?.startupTimer?.invalidate()
|
||||
self?.loadingView.stopAnimating()
|
||||
self?.loadingView.isHidden = true
|
||||
self?.loadingContainer.isHidden = true
|
||||
self?.startProgressUpdates()
|
||||
self?.refreshControls()
|
||||
self?.scheduleControlsHide()
|
||||
|
|
@ -241,6 +302,16 @@ final class NativePlayerViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
private func startPlayback() {
|
||||
loadingContainer.isHidden = false
|
||||
loadingView.startAnimating()
|
||||
failureContainer.isHidden = true
|
||||
startStartupTimer()
|
||||
backend.play(request: request)
|
||||
addSubtitleCandidates(request.subtitleCandidates)
|
||||
revealControls()
|
||||
}
|
||||
|
||||
private func startStartupTimer() {
|
||||
startupTimer?.invalidate()
|
||||
startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in
|
||||
|
|
@ -251,18 +322,30 @@ final class NativePlayerViewController: UIViewController {
|
|||
|
||||
private func configureLayout() {
|
||||
view.addSubview(backend.view)
|
||||
configureBottomScrim()
|
||||
view.addSubview(tapSurfaceView)
|
||||
view.addSubview(loadingView)
|
||||
view.addSubview(failureLabel)
|
||||
view.addSubview(loadingContainer)
|
||||
view.addSubview(failureContainer)
|
||||
view.addSubview(centerPlayPauseButton)
|
||||
view.addSubview(controlsContainer)
|
||||
view.addSubview(scrubTimeBubble)
|
||||
view.addSubview(closeButton)
|
||||
loadingStack.addArrangedSubview(loadingView)
|
||||
loadingStack.addArrangedSubview(loadingTextLabel)
|
||||
loadingContainer.contentView.addSubview(loadingStack)
|
||||
configureFailureCard()
|
||||
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
|
||||
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
|
||||
centerPlayPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
|
||||
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
|
||||
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
|
||||
retryButton.addTarget(self, action: #selector(retryPlayback), for: .touchUpInside)
|
||||
failureCloseButton.addTarget(self, action: #selector(close), for: .touchUpInside)
|
||||
audioButton.showsMenuAsPrimaryAction = true
|
||||
captionsButton.showsMenuAsPrimaryAction = true
|
||||
playPauseButton.layer.cornerRadius = 24
|
||||
playPauseButton.layer.cornerRadius = 28
|
||||
centerPlayPauseButton.layer.cornerRadius = 34
|
||||
centerPlayPauseButton.alpha = 0
|
||||
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])
|
||||
|
|
@ -270,6 +353,14 @@ final class NativePlayerViewController: UIViewController {
|
|||
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))
|
||||
tap.cancelsTouchesInView = false
|
||||
tapSurfaceView.addGestureRecognizer(tap)
|
||||
let leftDoubleTap = UITapGestureRecognizer(target: self, action: #selector(handleLeftDoubleTap))
|
||||
leftDoubleTap.numberOfTapsRequired = 2
|
||||
let rightDoubleTap = UITapGestureRecognizer(target: self, action: #selector(handleRightDoubleTap))
|
||||
rightDoubleTap.numberOfTapsRequired = 2
|
||||
tap.require(toFail: leftDoubleTap)
|
||||
tap.require(toFail: rightDoubleTap)
|
||||
tapSurfaceView.addGestureRecognizer(leftDoubleTap)
|
||||
tapSurfaceView.addGestureRecognizer(rightDoubleTap)
|
||||
|
||||
let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])
|
||||
timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
@ -297,6 +388,8 @@ final class NativePlayerViewController: UIViewController {
|
|||
stack.spacing = 6
|
||||
controlsContainer.contentView.addSubview(stack)
|
||||
|
||||
controlsMaximumWidthConstraint = controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
|
|
@ -308,21 +401,31 @@ final class NativePlayerViewController: UIViewController {
|
|||
tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
loadingContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
loadingContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
loadingStack.leadingAnchor.constraint(equalTo: loadingContainer.contentView.leadingAnchor, constant: 18),
|
||||
loadingStack.trailingAnchor.constraint(equalTo: loadingContainer.contentView.trailingAnchor, constant: -18),
|
||||
loadingStack.topAnchor.constraint(equalTo: loadingContainer.contentView.topAnchor, constant: 14),
|
||||
loadingStack.bottomAnchor.constraint(equalTo: loadingContainer.contentView.bottomAnchor, constant: -14),
|
||||
|
||||
failureLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 28),
|
||||
failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),
|
||||
failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
failureContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
|
||||
failureContainer.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
|
||||
failureContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -40),
|
||||
failureContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 420),
|
||||
|
||||
closeButton.widthAnchor.constraint(equalToConstant: 36),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: 36),
|
||||
centerPlayPauseButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
centerPlayPauseButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
centerPlayPauseButton.widthAnchor.constraint(equalToConstant: 68),
|
||||
centerPlayPauseButton.heightAnchor.constraint(equalToConstant: 68),
|
||||
|
||||
closeButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
|
||||
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
|
||||
|
||||
controlsContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
|
||||
controlsContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -24),
|
||||
controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430),
|
||||
controlsMaximumWidthConstraint!,
|
||||
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
|
||||
|
||||
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),
|
||||
|
|
@ -334,64 +437,108 @@ final class NativePlayerViewController: UIViewController {
|
|||
remainingLabel.widthAnchor.constraint(equalToConstant: 42),
|
||||
scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
|
||||
|
||||
jumpBackButton.widthAnchor.constraint(equalToConstant: 36),
|
||||
jumpBackButton.heightAnchor.constraint(equalToConstant: 36),
|
||||
playPauseButton.widthAnchor.constraint(equalToConstant: 42),
|
||||
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
|
||||
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
|
||||
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
|
||||
audioButton.widthAnchor.constraint(equalToConstant: 36),
|
||||
audioButton.heightAnchor.constraint(equalToConstant: 36),
|
||||
scrubTimeBubble.centerXAnchor.constraint(equalTo: controlsContainer.centerXAnchor),
|
||||
scrubTimeBubble.bottomAnchor.constraint(equalTo: controlsContainer.topAnchor, constant: -8),
|
||||
scrubTimeBubble.widthAnchor.constraint(greaterThanOrEqualToConstant: 64),
|
||||
scrubTimeBubble.heightAnchor.constraint(equalToConstant: 28),
|
||||
|
||||
jumpBackButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
jumpBackButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
playPauseButton.widthAnchor.constraint(equalToConstant: 56),
|
||||
playPauseButton.heightAnchor.constraint(equalToConstant: 56),
|
||||
jumpForwardButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
jumpForwardButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
audioButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
audioButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
playbackCluster.centerXAnchor.constraint(equalTo: controlRow.centerXAnchor),
|
||||
captionsButton.widthAnchor.constraint(equalToConstant: 36),
|
||||
captionsButton.heightAnchor.constraint(equalToConstant: 36)
|
||||
captionsButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
captionsButton.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
}
|
||||
|
||||
private func showFailure(_ error: Error) {
|
||||
controlsTimer?.invalidate()
|
||||
loadingView.stopAnimating()
|
||||
loadingView.isHidden = true
|
||||
failureLabel.text = "Native playback could not start.\n\(error.localizedDescription)"
|
||||
failureLabel.isHidden = false
|
||||
loadingContainer.isHidden = true
|
||||
controlsContainer.alpha = 0
|
||||
controlsContainer.isUserInteractionEnabled = false
|
||||
failureDetailLabel.text = error.localizedDescription
|
||||
failureContainer.isHidden = false
|
||||
closeButton.alpha = 1
|
||||
closeButton.isUserInteractionEnabled = true
|
||||
#if DEBUG
|
||||
print("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))")
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc private func retryPlayback() {
|
||||
backend.stop()
|
||||
progressTimer?.invalidate()
|
||||
audioMenuSignature = nil
|
||||
captionsMenuSignature = nil
|
||||
startPlayback()
|
||||
}
|
||||
|
||||
@objc private func close() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func togglePlayPause() {
|
||||
backend.togglePlayPause()
|
||||
flashCenterPlayPauseIcon()
|
||||
revealControls()
|
||||
}
|
||||
|
||||
@objc private func jumpBack() {
|
||||
backend.jump(by: -15)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
revealControls()
|
||||
}
|
||||
|
||||
@objc private func jumpForward() {
|
||||
backend.jump(by: 15)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
revealControls()
|
||||
}
|
||||
|
||||
@objc private func scrubbingStarted() {
|
||||
isScrubbing = true
|
||||
controlsTimer?.invalidate()
|
||||
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 20), for: .normal)
|
||||
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 22), for: .highlighted)
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
updateScrubPreview()
|
||||
UIView.animate(withDuration: 0.16) {
|
||||
self.scrubTimeBubble.alpha = 1
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func scrubberChanged() {
|
||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)
|
||||
updateScrubPreview()
|
||||
}
|
||||
|
||||
@objc private func scrubbingEnded() {
|
||||
backend.seek(to: scrubber.value)
|
||||
isScrubbing = false
|
||||
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 14), for: .normal)
|
||||
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 18), for: .highlighted)
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
UIView.animate(withDuration: 0.18) {
|
||||
self.scrubTimeBubble.alpha = 0
|
||||
}
|
||||
revealControls()
|
||||
}
|
||||
|
||||
@objc private func handleLeftDoubleTap(_ recognizer: UITapGestureRecognizer) {
|
||||
guard recognizer.location(in: tapSurfaceView).x < tapSurfaceView.bounds.midX else { return }
|
||||
jumpBack()
|
||||
}
|
||||
|
||||
@objc private func handleRightDoubleTap(_ recognizer: UITapGestureRecognizer) {
|
||||
guard recognizer.location(in: tapSurfaceView).x >= tapSurfaceView.bounds.midX else { return }
|
||||
jumpForward()
|
||||
}
|
||||
|
||||
@objc private func toggleControlsVisibility() {
|
||||
if controlsContainer.alpha < 1 {
|
||||
revealControls()
|
||||
|
|
@ -428,7 +575,7 @@ final class NativePlayerViewController: UIViewController {
|
|||
}
|
||||
|
||||
let delayActions = UIMenu(
|
||||
title: "Delay",
|
||||
title: "Subtitle Delay",
|
||||
options: .displayInline,
|
||||
children: [
|
||||
UIAction(title: "Decrease 0.5s") { [weak self] _ in
|
||||
|
|
@ -441,6 +588,12 @@ final class NativePlayerViewController: UIViewController {
|
|||
self?.captionsMenuSignature = nil
|
||||
self?.refreshControls()
|
||||
},
|
||||
UIAction(title: "Reset Delay") { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.backend.adjustSubtitleDelay(by: -self.backend.subtitleDelay)
|
||||
self.captionsMenuSignature = nil
|
||||
self.refreshControls()
|
||||
},
|
||||
UIAction(
|
||||
title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",
|
||||
attributes: .disabled
|
||||
|
|
@ -448,7 +601,7 @@ final class NativePlayerViewController: UIViewController {
|
|||
]
|
||||
)
|
||||
|
||||
return UIMenu(title: "Captions", children: trackActions + [delayActions])
|
||||
return UIMenu(title: "Subtitles", children: trackActions + [delayActions])
|
||||
}
|
||||
|
||||
private func audioMenu() -> UIMenu {
|
||||
|
|
@ -478,7 +631,7 @@ final class NativePlayerViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
return UIMenu(title: "Audio", children: trackActions)
|
||||
return UIMenu(title: "Audio Track", children: trackActions)
|
||||
}
|
||||
|
||||
private func startProgressUpdates() {
|
||||
|
|
@ -499,6 +652,7 @@ final class NativePlayerViewController: UIViewController {
|
|||
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
|
||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
|
||||
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
|
||||
scrubber.accessibilityValue = "\(elapsedLabel.text ?? "0:00") elapsed, \(remainingLabel.text ?? "-0:00") remaining"
|
||||
if !isScrubbing {
|
||||
scrubber.value = backend.position
|
||||
}
|
||||
|
|
@ -514,6 +668,7 @@ final class NativePlayerViewController: UIViewController {
|
|||
let hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1
|
||||
audioButton.isEnabled = hasSelectableTrack
|
||||
audioButton.alpha = hasSelectableTrack ? 1 : 0.45
|
||||
audioButton.accessibilityHint = hasSelectableTrack ? "Opens available audio tracks." : "Only one audio track is available."
|
||||
guard signature != audioMenuSignature else {
|
||||
return
|
||||
}
|
||||
|
|
@ -534,6 +689,8 @@ final class NativePlayerViewController: UIViewController {
|
|||
)
|
||||
let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }
|
||||
captionsButton.isEnabled = hasSelectableTrack
|
||||
captionsButton.alpha = hasSelectableTrack ? 1 : 0.45
|
||||
captionsButton.accessibilityHint = hasSelectableTrack ? "Opens subtitle tracks and delay controls." : "No subtitle tracks are available yet."
|
||||
guard signature != captionsMenuSignature else {
|
||||
return
|
||||
}
|
||||
|
|
@ -567,17 +724,25 @@ final class NativePlayerViewController: UIViewController {
|
|||
private func revealControls() {
|
||||
controlsContainer.isUserInteractionEnabled = true
|
||||
closeButton.isUserInteractionEnabled = true
|
||||
UIView.animate(withDuration: 0.18) {
|
||||
let animations = {
|
||||
self.controlsContainer.alpha = 1
|
||||
self.closeButton.alpha = 1
|
||||
self.controlsContainer.transform = .identity
|
||||
}
|
||||
if UIAccessibility.isReduceMotionEnabled {
|
||||
animations()
|
||||
} else {
|
||||
controlsContainer.transform = CGAffineTransform(translationX: 0, y: 8).scaledBy(x: 0.98, y: 0.98)
|
||||
UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut], animations: animations)
|
||||
}
|
||||
scheduleControlsHide()
|
||||
}
|
||||
|
||||
private func hideControls() {
|
||||
guard !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else { return }
|
||||
controlsContainer.isUserInteractionEnabled = false
|
||||
closeButton.isUserInteractionEnabled = false
|
||||
UIView.animate(withDuration: 0.24) {
|
||||
UIView.animate(withDuration: 0.28, delay: 0, options: [.curveEaseInOut]) {
|
||||
self.controlsContainer.alpha = 0
|
||||
self.closeButton.alpha = 0
|
||||
}
|
||||
|
|
@ -585,27 +750,143 @@ final class NativePlayerViewController: UIViewController {
|
|||
|
||||
private func scheduleControlsHide() {
|
||||
controlsTimer?.invalidate()
|
||||
guard backend.isPlaying else {
|
||||
guard backend.isPlaying, !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else {
|
||||
return
|
||||
}
|
||||
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
|
||||
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.5, repeats: false) { [weak self] _ in
|
||||
self?.hideControls()
|
||||
}
|
||||
}
|
||||
|
||||
private static func iconButton(systemName: String, label: String) -> UIButton {
|
||||
private func configureBottomScrim() {
|
||||
bottomScrimLayer.colors = [
|
||||
UIColor.clear.cgColor,
|
||||
UIColor.black.withAlphaComponent(0.12).cgColor,
|
||||
UIColor.black.withAlphaComponent(0.48).cgColor
|
||||
]
|
||||
bottomScrimLayer.locations = [0, 0.58, 1]
|
||||
view.layer.insertSublayer(bottomScrimLayer, above: backend.view.layer)
|
||||
}
|
||||
|
||||
private func configureFailureCard() {
|
||||
let buttonRow = UIStackView(arrangedSubviews: [failureCloseButton, retryButton])
|
||||
buttonRow.translatesAutoresizingMaskIntoConstraints = false
|
||||
buttonRow.axis = .horizontal
|
||||
buttonRow.distribution = .fillEqually
|
||||
buttonRow.spacing = 10
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [failureTitleLabel, failureDetailLabel, buttonRow])
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
stack.axis = .vertical
|
||||
stack.alignment = .fill
|
||||
stack.spacing = 14
|
||||
failureContainer.contentView.addSubview(stack)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: failureContainer.contentView.leadingAnchor, constant: 20),
|
||||
stack.trailingAnchor.constraint(equalTo: failureContainer.contentView.trailingAnchor, constant: -20),
|
||||
stack.topAnchor.constraint(equalTo: failureContainer.contentView.topAnchor, constant: 20),
|
||||
stack.bottomAnchor.constraint(equalTo: failureContainer.contentView.bottomAnchor, constant: -20),
|
||||
retryButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||||
failureCloseButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
|
||||
])
|
||||
}
|
||||
|
||||
private func configureAccessibility() {
|
||||
scrubber.accessibilityLabel = "Playback Position"
|
||||
scrubber.accessibilityHint = "Adjusts the playback position."
|
||||
playPauseButton.accessibilityHint = "Toggles playback."
|
||||
jumpBackButton.accessibilityHint = "Rewinds 15 seconds when seeking is available."
|
||||
jumpForwardButton.accessibilityHint = "Skips ahead 15 seconds when seeking is available."
|
||||
view.accessibilityCustomActions = [
|
||||
UIAccessibilityCustomAction(name: "Jump Back 15 Seconds", target: self, selector: #selector(accessibilityJumpBack)),
|
||||
UIAccessibilityCustomAction(name: "Jump Forward 15 Seconds", target: self, selector: #selector(accessibilityJumpForward))
|
||||
]
|
||||
}
|
||||
|
||||
@objc private func accessibilityJumpBack() -> Bool {
|
||||
jumpBack()
|
||||
return true
|
||||
}
|
||||
|
||||
@objc private func accessibilityJumpForward() -> Bool {
|
||||
jumpForward()
|
||||
return true
|
||||
}
|
||||
|
||||
private func updateLayoutForCurrentSize() {
|
||||
controlsMaximumWidthConstraint?.constant = traitCollection.horizontalSizeClass == .regular ? 560 : 430
|
||||
}
|
||||
|
||||
private func updateScrubPreview() {
|
||||
let target = TimeInterval(scrubber.value) * backend.duration
|
||||
let label = PlaybackTimeFormatter.label(for: target)
|
||||
elapsedLabel.text = label
|
||||
scrubTimeBubble.text = " \(label) "
|
||||
}
|
||||
|
||||
private func flashCenterPlayPauseIcon() {
|
||||
centerPlayPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
|
||||
guard !UIAccessibility.isReduceMotionEnabled else { return }
|
||||
centerPlayPauseButton.transform = CGAffineTransform(scaleX: 0.86, y: 0.86)
|
||||
UIView.animate(withDuration: 0.16, animations: {
|
||||
self.centerPlayPauseButton.alpha = 1
|
||||
self.centerPlayPauseButton.transform = .identity
|
||||
}) { _ in
|
||||
UIView.animate(withDuration: 0.22, delay: 0.28, options: [.curveEaseOut]) {
|
||||
self.centerPlayPauseButton.alpha = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func glassPanel(cornerRadius: CGFloat) -> UIVisualEffectView {
|
||||
let effect: UIVisualEffect
|
||||
if #available(iOS 26.0, *) {
|
||||
let glassEffect = UIGlassEffect()
|
||||
glassEffect.tintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 0.16)
|
||||
glassEffect.isInteractive = true
|
||||
effect = glassEffect
|
||||
} else {
|
||||
effect = UIBlurEffect(style: .systemUltraThinMaterialDark)
|
||||
}
|
||||
let view = UIVisualEffectView(effect: effect)
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.layer.cornerRadius = cornerRadius
|
||||
view.clipsToBounds = true
|
||||
view.backgroundColor = UIColor.white.withAlphaComponent(0.09)
|
||||
view.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColor
|
||||
view.layer.borderWidth = 1
|
||||
return view
|
||||
}
|
||||
|
||||
private static func iconButton(systemName: String, label: String, pointSize: CGFloat = 20) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setImage(UIImage(systemName: systemName), for: .normal)
|
||||
let configuration = UIImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold)
|
||||
button.setImage(UIImage(systemName: systemName, withConfiguration: configuration), for: .normal)
|
||||
button.tintColor = .white
|
||||
button.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||
button.layer.cornerRadius = 18
|
||||
button.backgroundColor = UIColor.white.withAlphaComponent(0.14)
|
||||
button.layer.cornerRadius = 22
|
||||
button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor
|
||||
button.layer.borderWidth = 1
|
||||
button.accessibilityLabel = label
|
||||
return button
|
||||
}
|
||||
|
||||
private static func textButton(title: String) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
|
||||
button.titleLabel?.adjustsFontForContentSizeCategory = true
|
||||
button.backgroundColor = UIColor.white.withAlphaComponent(0.16)
|
||||
button.layer.cornerRadius = 14
|
||||
button.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor
|
||||
button.layer.borderWidth = 1
|
||||
return button
|
||||
}
|
||||
|
||||
private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = UIScreen.main.scale
|
||||
|
|
|
|||
189
docs/turns/2026-05-26-native-player-liquid-glass-ux.html
Normal file
189
docs/turns/2026-05-26-native-player-liquid-glass-ux.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue