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-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-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-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-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-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-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-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-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}
|
{"_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 attachedSubtitleURLs: Set<URL>
|
||||||
private var audioMenuSignature: String?
|
private var audioMenuSignature: String?
|
||||||
private var captionsMenuSignature: String?
|
private var captionsMenuSignature: String?
|
||||||
|
private var controlsMaximumWidthConstraint: NSLayoutConstraint?
|
||||||
|
private let bottomScrimLayer = CAGradientLayer()
|
||||||
var onDismiss: (() -> Void)?
|
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 = {
|
private let loadingView: UIActivityIndicatorView = {
|
||||||
let view = UIActivityIndicatorView(style: .large)
|
let view = UIActivityIndicatorView(style: .large)
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
@ -26,22 +53,16 @@ final class NativePlayerViewController: UIViewController {
|
||||||
button.translatesAutoresizingMaskIntoConstraints = false
|
button.translatesAutoresizingMaskIntoConstraints = false
|
||||||
button.setImage(UIImage(systemName: "xmark"), for: .normal)
|
button.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||||
button.tintColor = .white
|
button.tintColor = .white
|
||||||
button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
|
button.backgroundColor = UIColor.white.withAlphaComponent(0.14)
|
||||||
button.layer.cornerRadius = 18
|
button.layer.cornerRadius = 22
|
||||||
|
button.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColor
|
||||||
|
button.layer.borderWidth = 1
|
||||||
button.accessibilityLabel = "Close"
|
button.accessibilityLabel = "Close"
|
||||||
|
button.accessibilityHint = "Closes native playback and returns to Stremio."
|
||||||
return button
|
return button
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let controlsContainer: UIVisualEffectView = {
|
private let controlsContainer = NativePlayerViewController.glassPanel(cornerRadius: 26)
|
||||||
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 tapSurfaceView: UIView = {
|
private let tapSurfaceView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
|
|
@ -50,11 +71,13 @@ final class NativePlayerViewController: UIViewController {
|
||||||
return view
|
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 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 jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")
|
||||||
private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Tracks")
|
private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Track")
|
||||||
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions")
|
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 = {
|
private let elapsedLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
|
|
@ -88,17 +111,50 @@ final class NativePlayerViewController: UIViewController {
|
||||||
return slider
|
return slider
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let failureLabel: UILabel = {
|
private let scrubTimeBubble: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
label.textColor = .white
|
label.textColor = .white
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.numberOfLines = 0
|
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .bold)
|
||||||
label.font = .preferredFont(forTextStyle: .body)
|
label.backgroundColor = UIColor.black.withAlphaComponent(0.56)
|
||||||
label.isHidden = true
|
label.layer.cornerRadius = 14
|
||||||
|
label.clipsToBounds = true
|
||||||
|
label.alpha = 0
|
||||||
return label
|
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(
|
init(
|
||||||
request: NativePlaybackRequest,
|
request: NativePlaybackRequest,
|
||||||
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
|
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
|
||||||
|
|
@ -136,9 +192,14 @@ final class NativePlayerViewController: UIViewController {
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
configureBackend()
|
configureBackend()
|
||||||
configureLayout()
|
configureLayout()
|
||||||
startStartupTimer()
|
configureAccessibility()
|
||||||
backend.play(request: request)
|
startPlayback()
|
||||||
addSubtitleCandidates(request.subtitleCandidates)
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
bottomScrimLayer.frame = view.bounds
|
||||||
|
updateLayoutForCurrentSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|
@ -212,7 +273,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self?.startupTimer?.invalidate()
|
self?.startupTimer?.invalidate()
|
||||||
self?.loadingView.stopAnimating()
|
self?.loadingView.stopAnimating()
|
||||||
self?.loadingView.isHidden = true
|
self?.loadingContainer.isHidden = true
|
||||||
self?.startProgressUpdates()
|
self?.startProgressUpdates()
|
||||||
self?.refreshControls()
|
self?.refreshControls()
|
||||||
self?.scheduleControlsHide()
|
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() {
|
private func startStartupTimer() {
|
||||||
startupTimer?.invalidate()
|
startupTimer?.invalidate()
|
||||||
startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in
|
startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in
|
||||||
|
|
@ -251,18 +322,30 @@ final class NativePlayerViewController: UIViewController {
|
||||||
|
|
||||||
private func configureLayout() {
|
private func configureLayout() {
|
||||||
view.addSubview(backend.view)
|
view.addSubview(backend.view)
|
||||||
|
configureBottomScrim()
|
||||||
view.addSubview(tapSurfaceView)
|
view.addSubview(tapSurfaceView)
|
||||||
view.addSubview(loadingView)
|
view.addSubview(loadingContainer)
|
||||||
view.addSubview(failureLabel)
|
view.addSubview(failureContainer)
|
||||||
|
view.addSubview(centerPlayPauseButton)
|
||||||
view.addSubview(controlsContainer)
|
view.addSubview(controlsContainer)
|
||||||
|
view.addSubview(scrubTimeBubble)
|
||||||
view.addSubview(closeButton)
|
view.addSubview(closeButton)
|
||||||
|
loadingStack.addArrangedSubview(loadingView)
|
||||||
|
loadingStack.addArrangedSubview(loadingTextLabel)
|
||||||
|
loadingContainer.contentView.addSubview(loadingStack)
|
||||||
|
configureFailureCard()
|
||||||
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
|
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
|
||||||
playPauseButton.addTarget(self, action: #selector(togglePlayPause), 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)
|
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
|
||||||
jumpForwardButton.addTarget(self, action: #selector(jumpForward), 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
|
audioButton.showsMenuAsPrimaryAction = true
|
||||||
captionsButton.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(scrubbingStarted), for: .touchDown)
|
||||||
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
|
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
|
||||||
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
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))
|
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))
|
||||||
tap.cancelsTouchesInView = false
|
tap.cancelsTouchesInView = false
|
||||||
tapSurfaceView.addGestureRecognizer(tap)
|
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])
|
let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])
|
||||||
timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false
|
timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
@ -297,6 +388,8 @@ final class NativePlayerViewController: UIViewController {
|
||||||
stack.spacing = 6
|
stack.spacing = 6
|
||||||
controlsContainer.contentView.addSubview(stack)
|
controlsContainer.contentView.addSubview(stack)
|
||||||
|
|
||||||
|
controlsMaximumWidthConstraint = controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
|
@ -308,21 +401,31 @@ final class NativePlayerViewController: UIViewController {
|
||||||
tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),
|
tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
|
||||||
loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
loadingContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
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),
|
failureContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
|
||||||
failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),
|
failureContainer.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
|
||||||
failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
failureContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -40),
|
||||||
|
failureContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 420),
|
||||||
|
|
||||||
closeButton.widthAnchor.constraint(equalToConstant: 36),
|
centerPlayPauseButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
closeButton.heightAnchor.constraint(equalToConstant: 36),
|
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.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
|
||||||
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
|
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
|
||||||
|
|
||||||
controlsContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
|
controlsContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
|
||||||
controlsContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -24),
|
controlsContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -24),
|
||||||
controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430),
|
controlsMaximumWidthConstraint!,
|
||||||
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
|
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
|
||||||
|
|
||||||
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),
|
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),
|
||||||
|
|
@ -334,64 +437,108 @@ final class NativePlayerViewController: UIViewController {
|
||||||
remainingLabel.widthAnchor.constraint(equalToConstant: 42),
|
remainingLabel.widthAnchor.constraint(equalToConstant: 42),
|
||||||
scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
|
scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
|
||||||
|
|
||||||
jumpBackButton.widthAnchor.constraint(equalToConstant: 36),
|
scrubTimeBubble.centerXAnchor.constraint(equalTo: controlsContainer.centerXAnchor),
|
||||||
jumpBackButton.heightAnchor.constraint(equalToConstant: 36),
|
scrubTimeBubble.bottomAnchor.constraint(equalTo: controlsContainer.topAnchor, constant: -8),
|
||||||
playPauseButton.widthAnchor.constraint(equalToConstant: 42),
|
scrubTimeBubble.widthAnchor.constraint(greaterThanOrEqualToConstant: 64),
|
||||||
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
|
scrubTimeBubble.heightAnchor.constraint(equalToConstant: 28),
|
||||||
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
|
|
||||||
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
|
jumpBackButton.widthAnchor.constraint(equalToConstant: 44),
|
||||||
audioButton.widthAnchor.constraint(equalToConstant: 36),
|
jumpBackButton.heightAnchor.constraint(equalToConstant: 44),
|
||||||
audioButton.heightAnchor.constraint(equalToConstant: 36),
|
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),
|
playbackCluster.centerXAnchor.constraint(equalTo: controlRow.centerXAnchor),
|
||||||
captionsButton.widthAnchor.constraint(equalToConstant: 36),
|
captionsButton.widthAnchor.constraint(equalToConstant: 44),
|
||||||
captionsButton.heightAnchor.constraint(equalToConstant: 36)
|
captionsButton.heightAnchor.constraint(equalToConstant: 44)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showFailure(_ error: Error) {
|
private func showFailure(_ error: Error) {
|
||||||
|
controlsTimer?.invalidate()
|
||||||
loadingView.stopAnimating()
|
loadingView.stopAnimating()
|
||||||
loadingView.isHidden = true
|
loadingContainer.isHidden = true
|
||||||
failureLabel.text = "Native playback could not start.\n\(error.localizedDescription)"
|
controlsContainer.alpha = 0
|
||||||
failureLabel.isHidden = false
|
controlsContainer.isUserInteractionEnabled = false
|
||||||
|
failureDetailLabel.text = error.localizedDescription
|
||||||
|
failureContainer.isHidden = false
|
||||||
|
closeButton.alpha = 1
|
||||||
|
closeButton.isUserInteractionEnabled = true
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))")
|
print("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func retryPlayback() {
|
||||||
|
backend.stop()
|
||||||
|
progressTimer?.invalidate()
|
||||||
|
audioMenuSignature = nil
|
||||||
|
captionsMenuSignature = nil
|
||||||
|
startPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func close() {
|
@objc private func close() {
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func togglePlayPause() {
|
@objc private func togglePlayPause() {
|
||||||
backend.togglePlayPause()
|
backend.togglePlayPause()
|
||||||
|
flashCenterPlayPauseIcon()
|
||||||
revealControls()
|
revealControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func jumpBack() {
|
@objc private func jumpBack() {
|
||||||
backend.jump(by: -15)
|
backend.jump(by: -15)
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
revealControls()
|
revealControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func jumpForward() {
|
@objc private func jumpForward() {
|
||||||
backend.jump(by: 15)
|
backend.jump(by: 15)
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
revealControls()
|
revealControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func scrubbingStarted() {
|
@objc private func scrubbingStarted() {
|
||||||
isScrubbing = true
|
isScrubbing = true
|
||||||
controlsTimer?.invalidate()
|
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() {
|
@objc private func scrubberChanged() {
|
||||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)
|
updateScrubPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func scrubbingEnded() {
|
@objc private func scrubbingEnded() {
|
||||||
backend.seek(to: scrubber.value)
|
backend.seek(to: scrubber.value)
|
||||||
isScrubbing = false
|
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()
|
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() {
|
@objc private func toggleControlsVisibility() {
|
||||||
if controlsContainer.alpha < 1 {
|
if controlsContainer.alpha < 1 {
|
||||||
revealControls()
|
revealControls()
|
||||||
|
|
@ -428,7 +575,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
let delayActions = UIMenu(
|
let delayActions = UIMenu(
|
||||||
title: "Delay",
|
title: "Subtitle Delay",
|
||||||
options: .displayInline,
|
options: .displayInline,
|
||||||
children: [
|
children: [
|
||||||
UIAction(title: "Decrease 0.5s") { [weak self] _ in
|
UIAction(title: "Decrease 0.5s") { [weak self] _ in
|
||||||
|
|
@ -441,6 +588,12 @@ final class NativePlayerViewController: UIViewController {
|
||||||
self?.captionsMenuSignature = nil
|
self?.captionsMenuSignature = nil
|
||||||
self?.refreshControls()
|
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(
|
UIAction(
|
||||||
title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",
|
title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",
|
||||||
attributes: .disabled
|
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 {
|
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() {
|
private func startProgressUpdates() {
|
||||||
|
|
@ -499,6 +652,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
|
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
|
||||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
|
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
|
||||||
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
|
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
|
||||||
|
scrubber.accessibilityValue = "\(elapsedLabel.text ?? "0:00") elapsed, \(remainingLabel.text ?? "-0:00") remaining"
|
||||||
if !isScrubbing {
|
if !isScrubbing {
|
||||||
scrubber.value = backend.position
|
scrubber.value = backend.position
|
||||||
}
|
}
|
||||||
|
|
@ -514,6 +668,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
let hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1
|
let hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1
|
||||||
audioButton.isEnabled = hasSelectableTrack
|
audioButton.isEnabled = hasSelectableTrack
|
||||||
audioButton.alpha = hasSelectableTrack ? 1 : 0.45
|
audioButton.alpha = hasSelectableTrack ? 1 : 0.45
|
||||||
|
audioButton.accessibilityHint = hasSelectableTrack ? "Opens available audio tracks." : "Only one audio track is available."
|
||||||
guard signature != audioMenuSignature else {
|
guard signature != audioMenuSignature else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -534,6 +689,8 @@ final class NativePlayerViewController: UIViewController {
|
||||||
)
|
)
|
||||||
let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }
|
let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }
|
||||||
captionsButton.isEnabled = hasSelectableTrack
|
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 {
|
guard signature != captionsMenuSignature else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -567,17 +724,25 @@ final class NativePlayerViewController: UIViewController {
|
||||||
private func revealControls() {
|
private func revealControls() {
|
||||||
controlsContainer.isUserInteractionEnabled = true
|
controlsContainer.isUserInteractionEnabled = true
|
||||||
closeButton.isUserInteractionEnabled = true
|
closeButton.isUserInteractionEnabled = true
|
||||||
UIView.animate(withDuration: 0.18) {
|
let animations = {
|
||||||
self.controlsContainer.alpha = 1
|
self.controlsContainer.alpha = 1
|
||||||
self.closeButton.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()
|
scheduleControlsHide()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hideControls() {
|
private func hideControls() {
|
||||||
|
guard !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else { return }
|
||||||
controlsContainer.isUserInteractionEnabled = false
|
controlsContainer.isUserInteractionEnabled = false
|
||||||
closeButton.isUserInteractionEnabled = false
|
closeButton.isUserInteractionEnabled = false
|
||||||
UIView.animate(withDuration: 0.24) {
|
UIView.animate(withDuration: 0.28, delay: 0, options: [.curveEaseInOut]) {
|
||||||
self.controlsContainer.alpha = 0
|
self.controlsContainer.alpha = 0
|
||||||
self.closeButton.alpha = 0
|
self.closeButton.alpha = 0
|
||||||
}
|
}
|
||||||
|
|
@ -585,27 +750,143 @@ final class NativePlayerViewController: UIViewController {
|
||||||
|
|
||||||
private func scheduleControlsHide() {
|
private func scheduleControlsHide() {
|
||||||
controlsTimer?.invalidate()
|
controlsTimer?.invalidate()
|
||||||
guard backend.isPlaying else {
|
guard backend.isPlaying, !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
|
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.5, repeats: false) { [weak self] _ in
|
||||||
self?.hideControls()
|
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)
|
let button = UIButton(type: .system)
|
||||||
button.translatesAutoresizingMaskIntoConstraints = false
|
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.tintColor = .white
|
||||||
button.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
button.backgroundColor = UIColor.white.withAlphaComponent(0.14)
|
||||||
button.layer.cornerRadius = 18
|
button.layer.cornerRadius = 22
|
||||||
button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor
|
button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor
|
||||||
button.layer.borderWidth = 1
|
button.layer.borderWidth = 1
|
||||||
button.accessibilityLabel = label
|
button.accessibilityLabel = label
|
||||||
return button
|
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 {
|
private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {
|
||||||
let format = UIGraphicsImageRendererFormat()
|
let format = UIGraphicsImageRendererFormat()
|
||||||
format.scale = UIScreen.main.scale
|
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