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.

+
+ Date: 2026-05-25 + Issue: dreamio-88m + Area: Native player controls +
+
+ +
+

Summary

+

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

+ +
+ +
+

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

+ +
+ +
+

Relevant Diff Snippets

+

Dreamio/NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-21+32
184 unmodified lines
185
186
187
188
189
190
191
122 unmodified lines
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
9 unmodified lines
351
352
353
354
355
356
184 unmodified lines
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)
playPauseButton.layer.cornerRadius = 21
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
122 unmodified lines
}
}
+
@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
self?.backend.selectSubtitleTrack(id: track.id)
})
}
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)
}
+
private func startProgressUpdates() {
9 unmodified lines
jumpBackButton.isEnabled = backend.isSeekable
jumpForwardButton.isEnabled = backend.isSeekable
captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
if !isScrubbing {
184 unmodified lines
185
186
187
188
189
190
191
122 unmodified lines
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
9 unmodified lines
361
362
363
364
365
366
367
184 unmodified lines
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.showsMenuAsPrimaryAction = true
playPauseButton.layer.cornerRadius = 21
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
122 unmodified lines
}
}
+
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()
}
}
+
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() {
9 unmodified lines
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 {
+

Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
-2+2
58 unmodified lines
59
60
61
62
63
64
65
66
67
68
58 unmodified lines
}
+
enum SubtitleOptionMapper {
static let offTrack = SubtitleTrack(id: -1, name: "Off")
+
static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] {
[offTrack] + tracks.filter { $0.id >= 0 }
}
}
+
58 unmodified lines
59
60
61
62
63
64
65
66
67
68
58 unmodified lines
}
+
enum SubtitleOptionMapper {
static let noneTrack = SubtitleTrack(id: -1, name: "None")
+
static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] {
[noneTrack] + tracks.filter { $0.id >= 0 }
}
}
+
+

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
-3+3
8 unmodified lines
9
10
11
12
13
14
15
94 unmodified lines
110
111
112
113
114
115
116
117
118
119
120
121
122
8 unmodified lines
testRedactorHandlesPercentEncodedPath()
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testSubtitleOptionMappingIncludesOff()
print("StreamResolverTests passed")
}
+
94 unmodified lines
assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1")
}
+
private static func testSubtitleOptionMappingIncludesOff() {
let options = SubtitleOptionMapper.options(from: [
SubtitleTrack(id: 2, name: "English"),
SubtitleTrack(id: 5, name: "Spanish")
])
+
assertEqual(options.map(\.name), ["Off", "English", "Spanish"])
assertEqual(options.first?.id, -1)
}
+
8 unmodified lines
9
10
11
12
13
14
15
94 unmodified lines
110
111
112
113
114
115
116
117
118
119
120
121
122
8 unmodified lines
testRedactorHandlesPercentEncodedPath()
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
+
94 unmodified lines
assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1")
}
+
private static func testSubtitleOptionMappingIncludesNone() {
let options = SubtitleOptionMapper.options(from: [
SubtitleTrack(id: 2, name: "English"),
SubtitleTrack(id: 5, name: "Spanish")
])
+
assertEqual(options.map(\.name), ["None", "English", "Spanish"])
assertEqual(options.first?.id, -1)
}
+
+
+ +
+

Expected Impact for End-Users

+

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.

+
+ +
+

Validation

+
+

Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build

+
+

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

+ +
+ +
+

Follow-up Work

+ +
+
+ +