diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 876c137..df78ee8 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -10,3 +10,6 @@ {"id":"int-27a61615","kind":"field_change","created_at":"2026-05-25T04:44:35.633997Z","actor":"dirtydishes","issue_id":"dreamio-ija","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install."}} {"id":"int-fad68cb4","kind":"field_change","created_at":"2026-05-25T05:04:55.103302Z","actor":"dirtydishes","issue_id":"dreamio-mj8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup."}} {"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 f97ee4a..5ad5342 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -8,6 +8,8 @@ {"_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} {"_type":"issue","id":"dreamio-a5b","title":"Track HTML diff rendering tooling as dev dependency","description":"Move the HTML diff rendering package into devDependencies and ignore installed Node modules so the repo tracks reproducible tooling without vendoring dependencies.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:12:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:12:44Z","started_at":"2026-05-25T01:12:14Z","closed_at":"2026-05-25T01:12:44Z","close_reason":"Moved @pierre/diffs to devDependencies and ignored node_modules.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate index d6bf1d4..d9f17eb 100644 Binary files a/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate and b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index a8d5fa5..6c30810 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -23,7 +23,7 @@ final class NativePlayerViewController: UIViewController { button.setImage(UIImage(systemName: "xmark"), for: .normal) button.tintColor = .white button.backgroundColor = UIColor.black.withAlphaComponent(0.45) - button.layer.cornerRadius = 22 + button.layer.cornerRadius = 18 button.accessibilityLabel = "Close" return button }() @@ -31,7 +31,7 @@ final class NativePlayerViewController: UIViewController { private let controlsContainer: UIVisualEffectView = { let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) view.translatesAutoresizingMaskIntoConstraints = false - view.layer.cornerRadius = 12 + view.layer.cornerRadius = 16 view.clipsToBounds = true return view }() @@ -52,7 +52,7 @@ final class NativePlayerViewController: UIViewController { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textColor = .white - label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium) + label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold) label.text = "0:00" return label }() @@ -61,7 +61,7 @@ final class NativePlayerViewController: UIViewController { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textColor = .white - label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium) + label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold) label.textAlignment = .right label.text = "-0:00" return label @@ -75,6 +75,8 @@ final class NativePlayerViewController: UIViewController { slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1) slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3) slider.thumbTintColor = .white + slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 12), for: .normal) + slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 16), for: .highlighted) return slider }() @@ -183,7 +185,8 @@ 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) scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel]) @@ -192,22 +195,23 @@ final class NativePlayerViewController: UIViewController { tap.cancelsTouchesInView = false tapSurfaceView.addGestureRecognizer(tap) + let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel]) + timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false + timeAndScrubRow.axis = .horizontal + timeAndScrubRow.alignment = .center + timeAndScrubRow.spacing = 8 + let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton]) controlRow.translatesAutoresizingMaskIntoConstraints = false controlRow.axis = .horizontal controlRow.alignment = .center - controlRow.distribution = .equalCentering - controlRow.spacing = 18 + controlRow.distribution = .equalSpacing + controlRow.spacing = 14 - let timeRow = UIStackView(arrangedSubviews: [elapsedLabel, remainingLabel]) - timeRow.translatesAutoresizingMaskIntoConstraints = false - timeRow.axis = .horizontal - timeRow.distribution = .fillEqually - - let stack = UIStackView(arrangedSubviews: [scrubber, timeRow, controlRow]) + let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow]) stack.translatesAutoresizingMaskIntoConstraints = false stack.axis = .vertical - stack.spacing = 8 + stack.spacing = 6 controlsContainer.contentView.addSubview(stack) NSLayoutConstraint.activate([ @@ -228,28 +232,33 @@ final class NativePlayerViewController: UIViewController { failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28), failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), - closeButton.widthAnchor.constraint(equalToConstant: 44), - closeButton.heightAnchor.constraint(equalToConstant: 44), - closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), - closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12), + closeButton.widthAnchor.constraint(equalToConstant: 36), + closeButton.heightAnchor.constraint(equalToConstant: 36), + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10), + closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10), - controlsContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18), - controlsContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18), - controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18), + controlsContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + controlsContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -24), + controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430), + controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12), - stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 16), - stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -16), - stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 14), - stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -14), + stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -12), + stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 8), + stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -10), - jumpBackButton.widthAnchor.constraint(equalToConstant: 44), - jumpBackButton.heightAnchor.constraint(equalToConstant: 44), - playPauseButton.widthAnchor.constraint(equalToConstant: 54), - playPauseButton.heightAnchor.constraint(equalToConstant: 54), - jumpForwardButton.widthAnchor.constraint(equalToConstant: 44), - jumpForwardButton.heightAnchor.constraint(equalToConstant: 44), - captionsButton.widthAnchor.constraint(equalToConstant: 44), - captionsButton.heightAnchor.constraint(equalToConstant: 44) + elapsedLabel.widthAnchor.constraint(equalToConstant: 42), + remainingLabel.widthAnchor.constraint(equalToConstant: 42), + scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160), + + jumpBackButton.widthAnchor.constraint(equalToConstant: 36), + jumpBackButton.heightAnchor.constraint(equalToConstant: 36), + playPauseButton.widthAnchor.constraint(equalToConstant: 42), + playPauseButton.heightAnchor.constraint(equalToConstant: 42), + jumpForwardButton.widthAnchor.constraint(equalToConstant: 36), + jumpForwardButton.heightAnchor.constraint(equalToConstant: 36), + captionsButton.widthAnchor.constraint(equalToConstant: 36), + captionsButton.heightAnchor.constraint(equalToConstant: 36) ]) } @@ -305,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() { @@ -342,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 { @@ -385,8 +405,17 @@ final class NativePlayerViewController: UIViewController { button.setImage(UIImage(systemName: systemName), for: .normal) button.tintColor = .white button.backgroundColor = UIColor.black.withAlphaComponent(0.35) - button.layer.cornerRadius = 22 + button.layer.cornerRadius = 18 button.accessibilityLabel = label return button } + + private static func scrubberThumbImage(diameter: CGFloat) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + return UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter), format: format).image { context in + UIColor.white.setFill() + context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: CGSize(width: diameter, height: diameter))) + } + } } 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

+ +
+
+ + diff --git a/docs/turns/2026-05-25-streamline-native-player-controls.html b/docs/turns/2026-05-25-streamline-native-player-controls.html new file mode 100644 index 0000000..a4541b8 --- /dev/null +++ b/docs/turns/2026-05-25-streamline-native-player-controls.html @@ -0,0 +1,236 @@ + + + + + + Streamline Native Player Controls + + + +
+
+

Streamline Native Player Controls

+

The native playback overlay was reduced from a large bottom control panel into a compact, centered control pill that leaves more of the video visible while preserving playback, seeking, jump, captions, and close actions.

+
+ +
+

Summary

+

Dreamio's native player controls now occupy much less vertical and horizontal space. The bottom controls use tighter padding, smaller circular buttons, smaller time labels, and a slimmer scrubber thumb so the screen feels more like a native iOS video player.

+
+ +
+

Changes Made

+ +
+ +
+

Context

+

The previous player overlay was visually heavy and could feel like it took over the bottom half of the playback surface. The requested direction was to make it much more streamlined and closer to a native player experience.

+
+ +
+

Important Implementation Details

+

The behavior lives in Dreamio/NativePlayerViewController.swift. This change only adjusts the UIKit control layout and visual treatment. It does not change VLC playback, stream resolution, subtitle selection behavior, timers, or dismiss behavior.

+

The compact overlay keeps the scrubber usable by giving it a minimum width while allowing the container to shrink to its content and stay within the safe area.

+
+ +
+

Relevant Diff Snippets

+

The rendered diff below was generated with @pierre/diffs/ssr.

+
Dreamio/NativePlayerViewController.swift
-36+54
22 unmodified lines
23
24
25
26
27
28
29
1 unmodified line
31
32
33
34
35
36
37
14 unmodified lines
52
53
54
55
56
57
58
2 unmodified lines
61
62
63
64
65
66
67
7 unmodified lines
75
76
77
78
79
80
103 unmodified lines
184
185
186
187
188
189
2 unmodified lines
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
14 unmodified lines
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
129 unmodified lines
385
386
387
388
389
390
391
392
22 unmodified lines
button.setImage(UIImage(systemName: "xmark"), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
button.layer.cornerRadius = 22
button.accessibilityLabel = "Close"
return button
}()
1 unmodified line
private let controlsContainer: UIVisualEffectView = {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 12
view.clipsToBounds = true
return view
}()
14 unmodified lines
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
label.text = "0:00"
return label
}()
2 unmodified lines
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
label.textAlignment = .right
label.text = "-0:00"
return label
7 unmodified lines
slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1)
slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3)
slider.thumbTintColor = .white
return slider
}()
+
103 unmodified lines
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside)
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
2 unmodified lines
tap.cancelsTouchesInView = false
tapSurfaceView.addGestureRecognizer(tap)
+
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])
controlRow.translatesAutoresizingMaskIntoConstraints = false
controlRow.axis = .horizontal
controlRow.alignment = .center
controlRow.distribution = .equalCentering
controlRow.spacing = 18
+
let timeRow = UIStackView(arrangedSubviews: [elapsedLabel, remainingLabel])
timeRow.translatesAutoresizingMaskIntoConstraints = false
timeRow.axis = .horizontal
timeRow.distribution = .fillEqually
+
let stack = UIStackView(arrangedSubviews: [scrubber, timeRow, controlRow])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.spacing = 8
controlsContainer.contentView.addSubview(stack)
+
NSLayoutConstraint.activate([
14 unmodified lines
failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),
failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+
closeButton.widthAnchor.constraint(equalToConstant: 44),
closeButton.heightAnchor.constraint(equalToConstant: 44),
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
+
controlsContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18),
controlsContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18),
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),
+
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 14),
stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -14),
+
jumpBackButton.widthAnchor.constraint(equalToConstant: 44),
jumpBackButton.heightAnchor.constraint(equalToConstant: 44),
playPauseButton.widthAnchor.constraint(equalToConstant: 54),
playPauseButton.heightAnchor.constraint(equalToConstant: 54),
jumpForwardButton.widthAnchor.constraint(equalToConstant: 44),
jumpForwardButton.heightAnchor.constraint(equalToConstant: 44),
captionsButton.widthAnchor.constraint(equalToConstant: 44),
captionsButton.heightAnchor.constraint(equalToConstant: 44)
])
}
+
129 unmodified lines
button.setImage(UIImage(systemName: systemName), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
button.layer.cornerRadius = 22
button.accessibilityLabel = label
return button
}
}
22 unmodified lines
23
24
25
26
27
28
29
1 unmodified line
31
32
33
34
35
36
37
14 unmodified lines
52
53
54
55
56
57
58
2 unmodified lines
61
62
63
64
65
66
67
7 unmodified lines
75
76
77
78
79
80
81
82
103 unmodified lines
186
187
188
189
190
191
192
2 unmodified lines
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
14 unmodified lines
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
129 unmodified lines
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
22 unmodified lines
button.setImage(UIImage(systemName: "xmark"), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
button.layer.cornerRadius = 18
button.accessibilityLabel = "Close"
return button
}()
1 unmodified line
private let controlsContainer: UIVisualEffectView = {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 16
view.clipsToBounds = true
return view
}()
14 unmodified lines
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
label.text = "0:00"
return label
}()
2 unmodified lines
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
label.textAlignment = .right
label.text = "-0:00"
return label
7 unmodified lines
slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1)
slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3)
slider.thumbTintColor = .white
slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 12), for: .normal)
slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 16), for: .highlighted)
return slider
}()
+
103 unmodified lines
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)
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
2 unmodified lines
tap.cancelsTouchesInView = false
tapSurfaceView.addGestureRecognizer(tap)
+
let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])
timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false
timeAndScrubRow.axis = .horizontal
timeAndScrubRow.alignment = .center
timeAndScrubRow.spacing = 8
+
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])
controlRow.translatesAutoresizingMaskIntoConstraints = false
controlRow.axis = .horizontal
controlRow.alignment = .center
controlRow.distribution = .equalSpacing
controlRow.spacing = 14
+
let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.spacing = 6
controlsContainer.contentView.addSubview(stack)
+
NSLayoutConstraint.activate([
14 unmodified lines
failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),
failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+
closeButton.widthAnchor.constraint(equalToConstant: 36),
closeButton.heightAnchor.constraint(equalToConstant: 36),
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
+
controlsContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
controlsContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -24),
controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430),
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
+
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),
stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -12),
stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 8),
stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -10),
+
elapsedLabel.widthAnchor.constraint(equalToConstant: 42),
remainingLabel.widthAnchor.constraint(equalToConstant: 42),
scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
+
jumpBackButton.widthAnchor.constraint(equalToConstant: 36),
jumpBackButton.heightAnchor.constraint(equalToConstant: 36),
playPauseButton.widthAnchor.constraint(equalToConstant: 42),
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
captionsButton.widthAnchor.constraint(equalToConstant: 36),
captionsButton.heightAnchor.constraint(equalToConstant: 36)
])
}
+
129 unmodified lines
button.setImage(UIImage(systemName: systemName), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
button.layer.cornerRadius = 18
button.accessibilityLabel = label
return button
}
+
private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = UIScreen.main.scale
return UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter), format: format).image { context in
UIColor.white.setFill()
context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: CGSize(width: diameter, height: diameter)))
}
}
}
+
+ +
+

Expected Impact for End-Users

+

Users should see more video and less chrome when controls are visible. Playback controls remain familiar, but the overlay is quieter and less intrusive, especially on smaller phones or landscape playback.

+
+ +
+

Validation

+

Passed:

+ +

The build completed successfully. Xcode still reports the existing MobileVLCKit script-phase warning about missing outputs; this was not introduced by this player UI change.

+
+ +
+

Issues, Limitations, and Mitigations

+ +
+ +
+

Follow-up Work

+

No required follow-up Beads issues were created. A useful next polish pass would be a simulator/device visual check during active playback, especially in portrait and landscape.

+
+
+ + \ No newline at end of file