diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl
index 3899c3b..df78ee8 100644
--- a/.beads/interactions.jsonl
+++ b/.beads/interactions.jsonl
@@ -12,3 +12,4 @@
{"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 b297ba4..5ad5342 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -8,6 +8,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-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}
diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift
index f5127fa..6c30810 100644
--- a/Dreamio/NativePlayerViewController.swift
+++ b/Dreamio/NativePlayerViewController.swift
@@ -185,7 +185,7 @@ 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)
@@ -314,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() {
@@ -351,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 {
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.