Improve native player control experience

This commit is contained in:
dirtydishes 2026-05-26 21:56:05 -04:00
parent dbe9f1ca26
commit ddfa22f004
4 changed files with 531 additions and 59 deletions

View file

@ -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."}}

View file

@ -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}

View file

@ -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

File diff suppressed because one or more lines are too long