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