From c0a017ceb25b72b6ba8aa925d18519256939f1fe Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 06:19:04 -0400 Subject: [PATCH] streamline native player controls --- .beads/interactions.jsonl | 2 + .beads/issues.jsonl | 1 + Dreamio/NativePlayerViewController.swift | 84 ++++--- ...-25-streamline-native-player-controls.html | 236 ++++++++++++++++++ 4 files changed, 290 insertions(+), 33 deletions(-) create mode 100644 docs/turns/2026-05-25-streamline-native-player-controls.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 876c137..3899c3b 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -10,3 +10,5 @@ {"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."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f97ee4a..b297ba4 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-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/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index a8d5fa5..f5127fa 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 }() @@ -184,6 +186,7 @@ final class NativePlayerViewController: UIViewController { 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]) @@ -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) ]) } @@ -385,8 +394,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/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

+
    +
  • Reworked the controls from a wide full-width panel into a centered compact overlay capped at 430 points.
  • +
  • Combined elapsed time, scrubber, and remaining time into one horizontal row.
  • +
  • Reduced button sizes while keeping circular targets for close, jump, play/pause, and captions.
  • +
  • Reduced control padding and spacing to lower the overlay height.
  • +
  • Added custom scrubber thumb images for a slimmer native-feeling slider.
  • +
+
+ +
+

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:

+
    +
  • xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -sdk iphonesimulator -configuration Debug build
  • +
+

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

+
    +
  • No simulator playback walkthrough was performed in this pass, so the exact visual feel should still be checked on device or simulator with real playback.
  • +
  • The controls are intentionally smaller. Accessibility labels remain in place, but future work could add larger pointer or VoiceOver-specific affordances if needed.
  • +
+
+ +
+

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