# 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

- 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.

## Relevant Diff Snippets

### Dreamio/NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-21+32

    184 unmodified lines185186187188189190191122 unmodified lines3143153163173183193203213223233243253263273283293303313323333343353363373383393403419 unmodified lines351352353354355356184 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 lines185186187188189190191122 unmodified lines3143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503519 unmodified lines361362363364365366367184 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 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

Tests/StreamResolverTests.swift
-3+3

    8 unmodified lines910111213141594 unmodified lines1101111121131141151161171181191201211228 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 lines910111213141594 unmodified lines1101111121131141151161171181191201211228 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

- 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.