Merge pull request #5 from dirtydishes/lavender/update-captions-menu

streamline native player controls and make caption selection clearer
This commit is contained in:
dirtydishes 2026-05-25 06:39:38 -04:00 committed by GitHub
commit 76433f1268
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 715 additions and 59 deletions

View file

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

View file

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

View file

@ -23,7 +23,7 @@ final class NativePlayerViewController: UIViewController {
button.setImage(UIImage(systemName: "xmark"), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
button.layer.cornerRadius = 22
button.layer.cornerRadius = 18
button.accessibilityLabel = "Close"
return button
}()
@ -31,7 +31,7 @@ final class NativePlayerViewController: UIViewController {
private let controlsContainer: UIVisualEffectView = {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 12
view.layer.cornerRadius = 16
view.clipsToBounds = true
return view
}()
@ -52,7 +52,7 @@ final class NativePlayerViewController: UIViewController {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
label.text = "0:00"
return label
}()
@ -61,7 +61,7 @@ final class NativePlayerViewController: UIViewController {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
label.textAlignment = .right
label.text = "-0:00"
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.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3)
slider.thumbTintColor = .white
slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 12), for: .normal)
slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 16), for: .highlighted)
return slider
}()
@ -183,7 +185,8 @@ final class NativePlayerViewController: UIViewController {
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside)
captionsButton.showsMenuAsPrimaryAction = true
playPauseButton.layer.cornerRadius = 21
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
@ -192,22 +195,23 @@ final class NativePlayerViewController: UIViewController {
tap.cancelsTouchesInView = false
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])
controlRow.translatesAutoresizingMaskIntoConstraints = false
controlRow.axis = .horizontal
controlRow.alignment = .center
controlRow.distribution = .equalCentering
controlRow.spacing = 18
controlRow.distribution = .equalSpacing
controlRow.spacing = 14
let timeRow = UIStackView(arrangedSubviews: [elapsedLabel, remainingLabel])
timeRow.translatesAutoresizingMaskIntoConstraints = false
timeRow.axis = .horizontal
timeRow.distribution = .fillEqually
let stack = UIStackView(arrangedSubviews: [scrubber, timeRow, controlRow])
let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.spacing = 8
stack.spacing = 6
controlsContainer.contentView.addSubview(stack)
NSLayoutConstraint.activate([
@ -228,28 +232,33 @@ final class NativePlayerViewController: UIViewController {
failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),
failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 44),
closeButton.heightAnchor.constraint(equalToConstant: 44),
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
closeButton.widthAnchor.constraint(equalToConstant: 36),
closeButton.heightAnchor.constraint(equalToConstant: 36),
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
controlsContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18),
controlsContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18),
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),
controlsContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
controlsContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -24),
controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430),
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 14),
stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -14),
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),
stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -12),
stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 8),
stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -10),
jumpBackButton.widthAnchor.constraint(equalToConstant: 44),
jumpBackButton.heightAnchor.constraint(equalToConstant: 44),
playPauseButton.widthAnchor.constraint(equalToConstant: 54),
playPauseButton.heightAnchor.constraint(equalToConstant: 54),
jumpForwardButton.widthAnchor.constraint(equalToConstant: 44),
jumpForwardButton.heightAnchor.constraint(equalToConstant: 44),
captionsButton.widthAnchor.constraint(equalToConstant: 44),
captionsButton.heightAnchor.constraint(equalToConstant: 44)
elapsedLabel.widthAnchor.constraint(equalToConstant: 42),
remainingLabel.widthAnchor.constraint(equalToConstant: 42),
scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
jumpBackButton.widthAnchor.constraint(equalToConstant: 36),
jumpBackButton.heightAnchor.constraint(equalToConstant: 36),
playPauseButton.widthAnchor.constraint(equalToConstant: 42),
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
captionsButton.widthAnchor.constraint(equalToConstant: 36),
captionsButton.heightAnchor.constraint(equalToConstant: 36)
])
}
@ -305,28 +314,38 @@ final class NativePlayerViewController: UIViewController {
}
}
@objc private func showCaptions() {
revealControls()
let alert = UIAlertController(title: "Captions", message: nil, preferredStyle: .actionSheet)
SubtitleOptionMapper.options(from: backend.subtitleTracks).forEach { track in
let prefix = track.id == backend.selectedSubtitleTrackID ? "Selected: " : ""
alert.addAction(UIAlertAction(title: "\(prefix)\(track.name)", style: .default) { [weak self] _ in
private func captionsMenu() -> UIMenu {
let selectedTrackID = backend.selectedSubtitleTrackID
let trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track in
UIAction(
title: track.name,
state: track.id == selectedTrackID ? .on : .off
) { [weak self] _ in
self?.backend.selectSubtitleTrack(id: track.id)
})
self?.refreshControls()
}
alert.addAction(UIAlertAction(title: "Delay -0.5s", style: .default) { [weak self] _ in
}
let delayActions = UIMenu(
title: "Delay",
options: .displayInline,
children: [
UIAction(title: "Decrease 0.5s") { [weak self] _ in
self?.backend.adjustSubtitleDelay(by: -0.5)
})
alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default) { [weak self] _ in
self?.refreshControls()
},
UIAction(title: "Increase 0.5s") { [weak self] _ in
self?.backend.adjustSubtitleDelay(by: 0.5)
})
alert.addAction(UIAlertAction(title: "Current Delay: \(String(format: "%.1fs", backend.subtitleDelay))", style: .default))
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = captionsButton
popover.sourceRect = captionsButton.bounds
}
present(alert, animated: true)
self?.refreshControls()
},
UIAction(
title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",
attributes: .disabled
) { _ in }
]
)
return UIMenu(title: "Captions", children: trackActions + [delayActions])
}
private func startProgressUpdates() {
@ -342,6 +361,7 @@ final class NativePlayerViewController: UIViewController {
jumpBackButton.isEnabled = backend.isSeekable
jumpForwardButton.isEnabled = backend.isSeekable
captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty
captionsButton.menu = captionsMenu()
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
if !isScrubbing {
@ -385,8 +405,17 @@ final class NativePlayerViewController: UIViewController {
button.setImage(UIImage(systemName: systemName), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
button.layer.cornerRadius = 22
button.layer.cornerRadius = 18
button.accessibilityLabel = label
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)))
}
}
}

View file

@ -59,10 +59,10 @@ enum PlaybackTimeFormatter {
}
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] {
[offTrack] + tracks.filter { $0.id >= 0 }
[noneTrack] + tracks.filter { $0.id >= 0 }
}
}

View file

@ -9,7 +9,7 @@ struct StreamResolverTests {
testRedactorHandlesPercentEncodedPath()
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testSubtitleOptionMappingIncludesOff()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
@ -110,13 +110,13 @@ struct StreamResolverTests {
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: [
SubtitleTrack(id: 2, name: "English"),
SubtitleTrack(id: 5, name: "Spanish")
])
assertEqual(options.map(\.name), ["Off", "English", "Spanish"])
assertEqual(options.map(\.name), ["None", "English", "Spanish"])
assertEqual(options.first?.id, -1)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long