diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl
index 876c137..df78ee8 100644
--- a/.beads/interactions.jsonl
+++ b/.beads/interactions.jsonl
@@ -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."}}
diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index f97ee4a..5ad5342 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -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}
diff --git a/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate
index d6bf1d4..d9f17eb 100644
Binary files a/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate and b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift
index a8d5fa5..6c30810 100644
--- a/Dreamio/NativePlayerViewController.swift
+++ b/Dreamio/NativePlayerViewController.swift
@@ -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
- self?.backend.adjustSubtitleDelay(by: -0.5)
- })
- alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default) { [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)
+
+ let delayActions = UIMenu(
+ title: "Delay",
+ options: .displayInline,
+ children: [
+ UIAction(title: "Decrease 0.5s") { [weak self] _ in
+ self?.backend.adjustSubtitleDelay(by: -0.5)
+ self?.refreshControls()
+ },
+ UIAction(title: "Increase 0.5s") { [weak self] _ in
+ self?.backend.adjustSubtitleDelay(by: 0.5)
+ 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)))
+ }
+ }
}
diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift
index 11ab6b3..3371b54 100644
--- a/Dreamio/StreamCandidate.swift
+++ b/Dreamio/StreamCandidate.swift
@@ -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 }
}
}
diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift
index 6cc5573..e70fc2b 100644
--- a/Tests/StreamResolverTests.swift
+++ b/Tests/StreamResolverTests.swift
@@ -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)
}
diff --git a/docs/turns/2026-05-25-caption-menu-selection-state.html b/docs/turns/2026-05-25-caption-menu-selection-state.html
new file mode 100644
index 0000000..7d131af
--- /dev/null
+++ b/docs/turns/2026-05-25-caption-menu-selection-state.html
@@ -0,0 +1,386 @@
+
+
+
+
+
+ Caption Menu Selection State
+
+
+
+
+
+
Caption Menu Selection State
+
Changed the native player captions control from a two-state-feeling action sheet into a single-choice captions menu with a clear None option and checked loaded tracks.
The captions control now presents the available caption choices as a proper menu. None represents captions being disabled, and any loaded caption track can be selected directly. UIKit marks the active choice with its selected state, so users do not have to infer state from a prefixed label.
+
+
+
+
Changes Made
+
+
Replaced the captions action-sheet selector with a UIMenu attached to the captions button.
+
Changed the sentinel subtitle option label from Off to None.
+
Made each caption track a UIAction with .on state when it matches the backend-selected subtitle track ID.
+
Kept subtitle delay controls inside the same menu, separated from the track choices.
+
Updated the subtitle option mapping test name and expected labels.
+
+
+
+
+
Context
+
The previous captions action sheet had an Off row and then loaded tracks. The active row was communicated by changing its title to include Selected:, which made the control feel like two separate visual modes instead of a direct menu of mutually exclusive choices.
+
+
+
+
Important Implementation Details
+
+
The backend contract stays the same: selecting ID -1 disables subtitles, and selecting a loaded track ID enables that track.
+
The captions button uses showsMenuAsPrimaryAction, so tapping it opens the menu directly.
+
The menu is rebuilt during refreshControls(), which keeps the checked row and current subtitle delay in sync after backend changes.
+
The loaded caption list still filters out negative IDs from backend tracks, then prepends the single None option.
Users should see a clearer captions menu: None when captions are disabled, or the selected caption track with the platform checkmark when captions are enabled. Choosing another row immediately switches the active caption state.
Also attempted a standalone Swift test binary for StreamResolverTests. The binary compiled after including the resolver source, but an existing subtitle parser assertion failed with Expected eng, got nil. That failure is unrelated to this menu-state change and remains documented here as a current test-suite limitation.
+
+
+
+
Issues, Limitations, and Mitigations
+
+
No simulator UI recording was performed, so visual validation is based on UIKit menu semantics and successful compilation.
+
The standalone resolver test command currently exposes an unrelated subtitle language parsing failure. The app build still succeeds.
+
The menu depends on UIKit UIAction selected-state rendering, which is appropriate for the app's iOS deployment target.
+
+
+
+
+
Follow-up Work
+
+
Investigate the existing subtitle parser test failure where lang does not populate the first candidate language.
+
Add a UI-level regression check for opening the captions menu once native-player UI automation exists.
The native playback overlay was reduced from a large bottom control panel into a compact, centered control pill that leaves more of the video visible while preserving playback, seeking, jump, captions, and close actions.
+
+
+
+
Summary
+
Dreamio's native player controls now occupy much less vertical and horizontal space. The bottom controls use tighter padding, smaller circular buttons, smaller time labels, and a slimmer scrubber thumb so the screen feels more like a native iOS video player.
+
+
+
+
Changes Made
+
+
Reworked the controls from a wide full-width panel into a centered compact overlay capped at 430 points.
+
Combined elapsed time, scrubber, and remaining time into one horizontal row.
+
Reduced button sizes while keeping circular targets for close, jump, play/pause, and captions.
+
Reduced control padding and spacing to lower the overlay height.
+
Added custom scrubber thumb images for a slimmer native-feeling slider.
+
+
+
+
+
Context
+
The previous player overlay was visually heavy and could feel like it took over the bottom half of the playback surface. The requested direction was to make it much more streamlined and closer to a native player experience.
+
+
+
+
Important Implementation Details
+
The behavior lives in Dreamio/NativePlayerViewController.swift. This change only adjusts the UIKit control layout and visual treatment. It does not change VLC playback, stream resolution, subtitle selection behavior, timers, or dismiss behavior.
+
The compact overlay keeps the scrubber usable by giving it a minimum width while allowing the container to shrink to its content and stay within the safe area.
+
+
+
+
Relevant Diff Snippets
+
The rendered diff below was generated with @pierre/diffs/ssr.
Users should see more video and less chrome when controls are visible. Playback controls remain familiar, but the overlay is quieter and less intrusive, especially on smaller phones or landscape playback.
The build completed successfully. Xcode still reports the existing MobileVLCKit script-phase warning about missing outputs; this was not introduced by this player UI change.
+
+
+
+
Issues, Limitations, and Mitigations
+
+
No simulator playback walkthrough was performed in this pass, so the exact visual feel should still be checked on device or simulator with real playback.
+
The controls are intentionally smaller. Accessibility labels remain in place, but future work could add larger pointer or VoiceOver-specific affordances if needed.
+
+
+
+
+
Follow-up Work
+
No required follow-up Beads issues were created. A useful next polish pass would be a simulator/device visual check during active playback, especially in portrait and landscape.