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.
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
- Replaced the captions action-sheet selector with a
UIMenuattached to the captions button. - Changed the sentinel subtitle option label from
OfftoNone. - Made each caption track a
UIActionwith.onstate 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
-1disables 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
Noneoption.
Relevant Diff Snippets
Dreamio/NativePlayerViewController.swift
184 unmodified lines185186187188189190191122 unmodified lines3143153163173183193203213223233243253263273283293303313323333343353363373383393403419 unmodified lines351352353354355356184 unmodified linesplayPauseButton.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 = 21scrubber.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 inlet prefix = track.id == backend.selectedSubtitleTrackID ? "Selected: " : ""alert.addAction(UIAlertAction(title: "\(prefix)\(track.name)", style: .default) { [weak self] _ inself?.backend.selectSubtitleTrack(id: track.id)})}alert.addAction(UIAlertAction(title: "Delay -0.5s", style: .default) { [weak self] _ inself?.backend.adjustSubtitleDelay(by: -0.5)})alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default) { [weak self] _ inself?.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 = captionsButtonpopover.sourceRect = captionsButton.bounds}present(alert, animated: true)}private func startProgressUpdates() {9 unmodified linesjumpBackButton.isEnabled = backend.isSeekablejumpForwardButton.isEnabled = backend.isSeekablecaptionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmptyelapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"if !isScrubbing {184 unmodified lines185186187188189190191122 unmodified lines3143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503519 unmodified lines361362363364365366367184 unmodified linesplayPauseButton.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 = trueplayPauseButton.layer.cornerRadius = 21scrubber.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.selectedSubtitleTrackIDlet trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track inUIAction(title: track.name,state: track.id == selectedTrackID ? .on : .off) { [weak self] _ inself?.backend.selectSubtitleTrack(id: track.id)self?.refreshControls()}}let delayActions = UIMenu(title: "Delay",options: .displayInline,children: [UIAction(title: "Decrease 0.5s") { [weak self] _ inself?.backend.adjustSubtitleDelay(by: -0.5)self?.refreshControls()},UIAction(title: "Increase 0.5s") { [weak self] _ inself?.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 linesjumpBackButton.isEnabled = backend.isSeekablejumpForwardButton.isEnabled = backend.isSeekablecaptionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmptycaptionsButton.menu = captionsMenu()elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"if !isScrubbing {
Dreamio/StreamCandidate.swift
58 unmodified lines5960616263646566676858 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 lines5960616263646566676858 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
8 unmodified lines910111213141594 unmodified lines1101111121131141151161171181191201211228 unmodified linestestRedactorHandlesPercentEncodedPath()testPlaybackTimeFormatting()testSubtitleCandidateParsing()testSubtitleOptionMappingIncludesOff()print("StreamResolverTests passed")}94 unmodified linesassertEqual(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 lines910111213141594 unmodified lines1101111121131141151161171181191201211228 unmodified linestestRedactorHandlesPercentEncodedPath()testPlaybackTimeFormatting()testSubtitleCandidateParsing()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")}94 unmodified linesassertEqual(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
- 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
UIActionselected-state rendering, which is appropriate for the app's iOS deployment target.
Follow-up Work
- Investigate the existing subtitle parser test failure where
langdoes not populate the first candidate language. - Add a UI-level regression check for opening the captions menu once native-player UI automation exists.