mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
Merge pull request #5 from dirtydishes/lavender/update-captions-menu
streamline native player controls and make caption selection clearer
This commit is contained in:
commit
76433f1268
8 changed files with 715 additions and 59 deletions
|
|
@ -10,3 +10,6 @@
|
||||||
{"id":"int-27a61615","kind":"field_change","created_at":"2026-05-25T04:44:35.633997Z","actor":"dirtydishes","issue_id":"dreamio-ija","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install."}}
|
{"id":"int-27a61615","kind":"field_change","created_at":"2026-05-25T04:44:35.633997Z","actor":"dirtydishes","issue_id":"dreamio-ija","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install."}}
|
||||||
{"id":"int-fad68cb4","kind":"field_change","created_at":"2026-05-25T05:04:55.103302Z","actor":"dirtydishes","issue_id":"dreamio-mj8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup."}}
|
{"id":"int-fad68cb4","kind":"field_change","created_at":"2026-05-25T05:04:55.103302Z","actor":"dirtydishes","issue_id":"dreamio-mj8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup."}}
|
||||||
{"id":"int-6b806f87","kind":"field_change","created_at":"2026-05-25T09:49:39.908604Z","actor":"dirtydishes","issue_id":"dreamio-poo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup."}}
|
{"id":"int-6b806f87","kind":"field_change","created_at":"2026-05-25T09:49:39.908604Z","actor":"dirtydishes","issue_id":"dreamio-poo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup."}}
|
||||||
|
{"id":"int-5d355e9b","kind":"field_change","created_at":"2026-05-25T09:51:17.04306Z","actor":"dirtydishes","issue_id":"dreamio-wgk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
||||||
|
{"id":"int-9ddb7b1a","kind":"field_change","created_at":"2026-05-25T10:18:30.826897Z","actor":"dirtydishes","issue_id":"dreamio-7w6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build."}}
|
||||||
|
{"id":"int-2a84633f","kind":"field_change","created_at":"2026-05-25T10:25:22.649574Z","actor":"dirtydishes","issue_id":"dreamio-88m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation."}}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
{"_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-88m","title":"Make caption selection states clearer","description":"The native player caption menu should behave like a simple single-choice menu with None and loaded caption tracks, making the current caption state visually obvious.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:22:12Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:25:23Z","started_at":"2026-05-25T10:22:48Z","closed_at":"2026-05-25T10:25:23Z","close_reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"_type":"issue","id":"dreamio-7w6","title":"Streamline native player controls","description":"Make the native playback controls take up less screen space while preserving play, seek, jump, captions, and close actions.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:15:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:18:31Z","started_at":"2026-05-25T10:15:59Z","closed_at":"2026-05-25T10:18:31Z","close_reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"dreamio-mj8","title":"Add native player controls and captions","description":"Implement a fuller VLC-backed native playback surface with transport controls, caption controls, external subtitle discovery, and a clean close flow back to Stremio episode selection.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:57:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T05:04:55Z","started_at":"2026-05-25T04:57:57Z","closed_at":"2026-05-25T05:04:55Z","close_reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"dreamio-mj8","title":"Add native player controls and captions","description":"Implement a fuller VLC-backed native playback surface with transport controls, caption controls, external subtitle discovery, and a clean close flow back to Stremio episode selection.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:57:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T05:04:55Z","started_at":"2026-05-25T04:57:57Z","closed_at":"2026-05-25T05:04:55Z","close_reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"dreamio-evt","title":"Enable WebView inspection and playback diagnostics","description":"Add development-only WKWebView inspection and token-safe playback diagnostics so Dreamio can debug hosted Stremio media failures without changing app navigation, login, or playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T02:30:26Z","created_by":"dirtydishes","updated_at":"2026-05-25T02:34:55Z","started_at":"2026-05-25T02:30:32Z","closed_at":"2026-05-25T02:34:55Z","close_reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"dreamio-evt","title":"Enable WebView inspection and playback diagnostics","description":"Add development-only WKWebView inspection and token-safe playback diagnostics so Dreamio can debug hosted Stremio media failures without changing app navigation, login, or playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T02:30:26Z","created_by":"dirtydishes","updated_at":"2026-05-25T02:34:55Z","started_at":"2026-05-25T02:30:32Z","closed_at":"2026-05-25T02:34:55Z","close_reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"dreamio-a5b","title":"Track HTML diff rendering tooling as dev dependency","description":"Move the HTML diff rendering package into devDependencies and ignore installed Node modules so the repo tracks reproducible tooling without vendoring dependencies.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:12:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:12:44Z","started_at":"2026-05-25T01:12:14Z","closed_at":"2026-05-25T01:12:44Z","close_reason":"Moved @pierre/diffs to devDependencies and ignored node_modules.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"dreamio-a5b","title":"Track HTML diff rendering tooling as dev dependency","description":"Move the HTML diff rendering package into devDependencies and ignore installed Node modules so the repo tracks reproducible tooling without vendoring dependencies.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:12:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:12:44Z","started_at":"2026-05-25T01:12:14Z","closed_at":"2026-05-25T01:12:44Z","close_reason":"Moved @pierre/diffs to devDependencies and ignored node_modules.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -23,7 +23,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
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.black.withAlphaComponent(0.45)
|
||||||
button.layer.cornerRadius = 22
|
button.layer.cornerRadius = 18
|
||||||
button.accessibilityLabel = "Close"
|
button.accessibilityLabel = "Close"
|
||||||
return button
|
return button
|
||||||
}()
|
}()
|
||||||
|
|
@ -31,7 +31,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
private let controlsContainer: UIVisualEffectView = {
|
private let controlsContainer: UIVisualEffectView = {
|
||||||
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
|
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.layer.cornerRadius = 12
|
view.layer.cornerRadius = 16
|
||||||
view.clipsToBounds = true
|
view.clipsToBounds = true
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
@ -52,7 +52,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
label.textColor = .white
|
label.textColor = .white
|
||||||
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
|
label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
|
||||||
label.text = "0:00"
|
label.text = "0:00"
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
@ -61,7 +61,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
label.textColor = .white
|
label.textColor = .white
|
||||||
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
|
label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
|
||||||
label.textAlignment = .right
|
label.textAlignment = .right
|
||||||
label.text = "-0:00"
|
label.text = "-0:00"
|
||||||
return label
|
return label
|
||||||
|
|
@ -75,6 +75,8 @@ final class NativePlayerViewController: UIViewController {
|
||||||
slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1)
|
slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1)
|
||||||
slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3)
|
slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3)
|
||||||
slider.thumbTintColor = .white
|
slider.thumbTintColor = .white
|
||||||
|
slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 12), for: .normal)
|
||||||
|
slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 16), for: .highlighted)
|
||||||
return slider
|
return slider
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -183,7 +185,8 @@ final class NativePlayerViewController: UIViewController {
|
||||||
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
|
playPauseButton.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)
|
||||||
captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside)
|
captionsButton.showsMenuAsPrimaryAction = true
|
||||||
|
playPauseButton.layer.cornerRadius = 21
|
||||||
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])
|
||||||
|
|
@ -192,22 +195,23 @@ final class NativePlayerViewController: UIViewController {
|
||||||
tap.cancelsTouchesInView = false
|
tap.cancelsTouchesInView = false
|
||||||
tapSurfaceView.addGestureRecognizer(tap)
|
tapSurfaceView.addGestureRecognizer(tap)
|
||||||
|
|
||||||
|
let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])
|
||||||
|
timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
timeAndScrubRow.axis = .horizontal
|
||||||
|
timeAndScrubRow.alignment = .center
|
||||||
|
timeAndScrubRow.spacing = 8
|
||||||
|
|
||||||
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])
|
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])
|
||||||
controlRow.translatesAutoresizingMaskIntoConstraints = false
|
controlRow.translatesAutoresizingMaskIntoConstraints = false
|
||||||
controlRow.axis = .horizontal
|
controlRow.axis = .horizontal
|
||||||
controlRow.alignment = .center
|
controlRow.alignment = .center
|
||||||
controlRow.distribution = .equalCentering
|
controlRow.distribution = .equalSpacing
|
||||||
controlRow.spacing = 18
|
controlRow.spacing = 14
|
||||||
|
|
||||||
let timeRow = UIStackView(arrangedSubviews: [elapsedLabel, remainingLabel])
|
let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow])
|
||||||
timeRow.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
timeRow.axis = .horizontal
|
|
||||||
timeRow.distribution = .fillEqually
|
|
||||||
|
|
||||||
let stack = UIStackView(arrangedSubviews: [scrubber, timeRow, controlRow])
|
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stack.axis = .vertical
|
stack.axis = .vertical
|
||||||
stack.spacing = 8
|
stack.spacing = 6
|
||||||
controlsContainer.contentView.addSubview(stack)
|
controlsContainer.contentView.addSubview(stack)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
|
@ -228,28 +232,33 @@ final class NativePlayerViewController: UIViewController {
|
||||||
failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),
|
failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),
|
||||||
failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
|
|
||||||
closeButton.widthAnchor.constraint(equalToConstant: 44),
|
closeButton.widthAnchor.constraint(equalToConstant: 36),
|
||||||
closeButton.heightAnchor.constraint(equalToConstant: 44),
|
closeButton.heightAnchor.constraint(equalToConstant: 36),
|
||||||
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
|
||||||
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
|
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
|
||||||
|
|
||||||
controlsContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18),
|
controlsContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
|
||||||
controlsContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18),
|
controlsContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -24),
|
||||||
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),
|
controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430),
|
||||||
|
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
|
||||||
|
|
||||||
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 16),
|
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),
|
||||||
stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -16),
|
stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -12),
|
||||||
stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 14),
|
stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 8),
|
||||||
stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -14),
|
stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -10),
|
||||||
|
|
||||||
jumpBackButton.widthAnchor.constraint(equalToConstant: 44),
|
elapsedLabel.widthAnchor.constraint(equalToConstant: 42),
|
||||||
jumpBackButton.heightAnchor.constraint(equalToConstant: 44),
|
remainingLabel.widthAnchor.constraint(equalToConstant: 42),
|
||||||
playPauseButton.widthAnchor.constraint(equalToConstant: 54),
|
scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
|
||||||
playPauseButton.heightAnchor.constraint(equalToConstant: 54),
|
|
||||||
jumpForwardButton.widthAnchor.constraint(equalToConstant: 44),
|
jumpBackButton.widthAnchor.constraint(equalToConstant: 36),
|
||||||
jumpForwardButton.heightAnchor.constraint(equalToConstant: 44),
|
jumpBackButton.heightAnchor.constraint(equalToConstant: 36),
|
||||||
captionsButton.widthAnchor.constraint(equalToConstant: 44),
|
playPauseButton.widthAnchor.constraint(equalToConstant: 42),
|
||||||
captionsButton.heightAnchor.constraint(equalToConstant: 44)
|
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
|
||||||
|
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
|
||||||
|
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
|
||||||
|
captionsButton.widthAnchor.constraint(equalToConstant: 36),
|
||||||
|
captionsButton.heightAnchor.constraint(equalToConstant: 36)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,28 +314,38 @@ final class NativePlayerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func showCaptions() {
|
private func captionsMenu() -> UIMenu {
|
||||||
revealControls()
|
let selectedTrackID = backend.selectedSubtitleTrackID
|
||||||
let alert = UIAlertController(title: "Captions", message: nil, preferredStyle: .actionSheet)
|
let trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track in
|
||||||
SubtitleOptionMapper.options(from: backend.subtitleTracks).forEach { track in
|
UIAction(
|
||||||
let prefix = track.id == backend.selectedSubtitleTrackID ? "Selected: " : ""
|
title: track.name,
|
||||||
alert.addAction(UIAlertAction(title: "\(prefix)\(track.name)", style: .default) { [weak self] _ in
|
state: track.id == selectedTrackID ? .on : .off
|
||||||
|
) { [weak self] _ in
|
||||||
self?.backend.selectSubtitleTrack(id: track.id)
|
self?.backend.selectSubtitleTrack(id: track.id)
|
||||||
})
|
self?.refreshControls()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
alert.addAction(UIAlertAction(title: "Delay -0.5s", style: .default) { [weak self] _ in
|
|
||||||
self?.backend.adjustSubtitleDelay(by: -0.5)
|
let delayActions = UIMenu(
|
||||||
})
|
title: "Delay",
|
||||||
alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default) { [weak self] _ in
|
options: .displayInline,
|
||||||
self?.backend.adjustSubtitleDelay(by: 0.5)
|
children: [
|
||||||
})
|
UIAction(title: "Decrease 0.5s") { [weak self] _ in
|
||||||
alert.addAction(UIAlertAction(title: "Current Delay: \(String(format: "%.1fs", backend.subtitleDelay))", style: .default))
|
self?.backend.adjustSubtitleDelay(by: -0.5)
|
||||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
self?.refreshControls()
|
||||||
if let popover = alert.popoverPresentationController {
|
},
|
||||||
popover.sourceView = captionsButton
|
UIAction(title: "Increase 0.5s") { [weak self] _ in
|
||||||
popover.sourceRect = captionsButton.bounds
|
self?.backend.adjustSubtitleDelay(by: 0.5)
|
||||||
}
|
self?.refreshControls()
|
||||||
present(alert, animated: true)
|
},
|
||||||
|
UIAction(
|
||||||
|
title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",
|
||||||
|
attributes: .disabled
|
||||||
|
) { _ in }
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return UIMenu(title: "Captions", children: trackActions + [delayActions])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startProgressUpdates() {
|
private func startProgressUpdates() {
|
||||||
|
|
@ -342,6 +361,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
jumpBackButton.isEnabled = backend.isSeekable
|
jumpBackButton.isEnabled = backend.isSeekable
|
||||||
jumpForwardButton.isEnabled = backend.isSeekable
|
jumpForwardButton.isEnabled = backend.isSeekable
|
||||||
captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty
|
captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty
|
||||||
|
captionsButton.menu = captionsMenu()
|
||||||
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))"
|
||||||
if !isScrubbing {
|
if !isScrubbing {
|
||||||
|
|
@ -385,8 +405,17 @@ final class NativePlayerViewController: UIViewController {
|
||||||
button.setImage(UIImage(systemName: systemName), for: .normal)
|
button.setImage(UIImage(systemName: systemName), for: .normal)
|
||||||
button.tintColor = .white
|
button.tintColor = .white
|
||||||
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
button.layer.cornerRadius = 22
|
button.layer.cornerRadius = 18
|
||||||
button.accessibilityLabel = label
|
button.accessibilityLabel = label
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {
|
||||||
|
let format = UIGraphicsImageRendererFormat()
|
||||||
|
format.scale = UIScreen.main.scale
|
||||||
|
return UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter), format: format).image { context in
|
||||||
|
UIColor.white.setFill()
|
||||||
|
context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: CGSize(width: diameter, height: diameter)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,10 @@ enum PlaybackTimeFormatter {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SubtitleOptionMapper {
|
enum SubtitleOptionMapper {
|
||||||
static let offTrack = SubtitleTrack(id: -1, name: "Off")
|
static let noneTrack = SubtitleTrack(id: -1, name: "None")
|
||||||
|
|
||||||
static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] {
|
static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] {
|
||||||
[offTrack] + tracks.filter { $0.id >= 0 }
|
[noneTrack] + tracks.filter { $0.id >= 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ struct StreamResolverTests {
|
||||||
testRedactorHandlesPercentEncodedPath()
|
testRedactorHandlesPercentEncodedPath()
|
||||||
testPlaybackTimeFormatting()
|
testPlaybackTimeFormatting()
|
||||||
testSubtitleCandidateParsing()
|
testSubtitleCandidateParsing()
|
||||||
testSubtitleOptionMappingIncludesOff()
|
testSubtitleOptionMappingIncludesNone()
|
||||||
print("StreamResolverTests passed")
|
print("StreamResolverTests passed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,13 +110,13 @@ struct StreamResolverTests {
|
||||||
assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1")
|
assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func testSubtitleOptionMappingIncludesOff() {
|
private static func testSubtitleOptionMappingIncludesNone() {
|
||||||
let options = SubtitleOptionMapper.options(from: [
|
let options = SubtitleOptionMapper.options(from: [
|
||||||
SubtitleTrack(id: 2, name: "English"),
|
SubtitleTrack(id: 2, name: "English"),
|
||||||
SubtitleTrack(id: 5, name: "Spanish")
|
SubtitleTrack(id: 5, name: "Spanish")
|
||||||
])
|
])
|
||||||
|
|
||||||
assertEqual(options.map(\.name), ["Off", "English", "Spanish"])
|
assertEqual(options.map(\.name), ["None", "English", "Spanish"])
|
||||||
assertEqual(options.first?.id, -1)
|
assertEqual(options.first?.id, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
386
docs/turns/2026-05-25-caption-menu-selection-state.html
Normal file
386
docs/turns/2026-05-25-caption-menu-selection-state.html
Normal file
File diff suppressed because one or more lines are too long
236
docs/turns/2026-05-25-streamline-native-player-controls.html
Normal file
236
docs/turns/2026-05-25-streamline-native-player-controls.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