diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 32e8e02..a0cd213 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -38,3 +38,5 @@ {"id":"int-697dc66d","kind":"field_change","created_at":"2026-05-25T17:01:32.697187Z","actor":"dirtydishes","issue_id":"dreamio-0lt","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"not implementing now; user asked only to move previous work to the audio-track-selection branch"}} {"id":"int-c9b3bcd7","kind":"field_change","created_at":"2026-05-25T17:48:09.142384Z","actor":"dirtydishes","issue_id":"dreamio-ejh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by preserving known external subtitle display names for generic VLC subtitle tracks and expanding language-code aliases."}} {"id":"int-12bf46aa","kind":"field_change","created_at":"2026-05-25T18:31:50.873069Z","actor":"dirtydishes","issue_id":"dreamio-kdf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files."}} +{"id":"int-fc9ecdb1","kind":"field_change","created_at":"2026-05-27T01:50:32.02792Z","actor":"dirtydishes","issue_id":"dreamio-ee1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Completed baseline UX audit in docs/native-player-ux-audit.md"}} +{"id":"int-c8a14c48","kind":"field_change","created_at":"2026-05-27T01:56:02.08139Z","actor":"dirtydishes","issue_id":"dreamio-060","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native player Liquid Glass UX improvements and validated simulator build."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 58596f1..ba89dea 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,26 @@ +{"_type":"issue","id":"dreamio-dd7","title":"Start VLC before slow range-cache probes","description":"Current MKV cache probing can still block native VLC startup because HEAD and tiny range timeout sequentially before any media is opened. Start direct playback immediately or otherwise ensure VLC media opens before probing completes, while preserving range-cache support when it is ready quickly.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T13:00:37Z","created_by":"dirtydishes","updated_at":"2026-05-26T13:01:28Z","started_at":"2026-05-26T13:00:43Z","closed_at":"2026-05-26T13:01:28Z","close_reason":"Changed VLC startup to open direct playback immediately instead of waiting for slow range-cache probes, restoring reliable native-player startup for MKV streams.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-btc","title":"Bound VLC range cache probe startup latency","description":"After enabling MKV range cache probing, some Torrentio/Real-Debrid MKV streams log cache-probe but never reach opening mode before the native-player startup timeout. Add a bounded probe/local-cache startup path that falls back to direct playback when the range probe is slow or inconclusive.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:14:02Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:16:53Z","started_at":"2026-05-26T12:14:11Z","closed_at":"2026-05-26T12:16:53Z","close_reason":"Added a short timeout to range-cache probe requests so slow MKV HEAD/range probes fall back to direct VLC startup instead of tripping the native-player startup timeout.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-mun","title":"fix vlc cache loopback port startup","description":"Device logs showed local-cache playback opening http://127.0.0.1:0, because the NWListener ephemeral port was read before the listener reached ready. Wait for the real assigned port before returning the local cache URL.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:32:41Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:15Z","started_at":"2026-05-25T22:33:14Z","closed_at":"2026-05-25T22:33:15Z","close_reason":"Wait for NWListener ready state before returning the local cache URL; verified tests and simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-5cz","title":"Make VLC range cache non-blocking at startup","description":"Native playback startup currently bypasses Dreamio's local range cache after cache probing caused VLC startup timeouts. Reintroduce cache startup only when preparation is fast and safe, otherwise fall back to direct playback immediately, with focused tests and clear logs.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T00:36:56Z","created_by":"dirtydishes","updated_at":"2026-05-27T00:43:03Z","started_at":"2026-05-27T00:37:03Z","closed_at":"2026-05-27T00:43:03Z","close_reason":"Implemented bounded non-blocking range-cache startup for VLC, with direct fallback on timeout, skipped probes, or local server failures; added focused startup policy tests and updated the turn document.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-3sw","title":"Fix VLC range cache fallback for tail-index MKV streams","description":"Video range caching currently refuses streams classified as tail-index containers, causing VLC playback to use direct mode and lose seek prefetch behavior. Investigate the probe logic and enable safe local range caching for these streams without breaking playback startup.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:05:20Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:10:16Z","started_at":"2026-05-26T12:05:38Z","closed_at":"2026-05-26T12:10:16Z","close_reason":"Removed the Matroska/WebM extension-level range-cache bypass and added a regression test proving MKV URLs use the cache when the origin advertises byte-range support.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-816","title":"Fix local range cache playback buffering","description":"Normal VLC playback can stay in buffering after the local progressive HTTP range cache is enabled. Logs show VLC repeatedly probes header/tail MKV ranges through the loopback server while the cache foreground fetch path serializes 1 MB remote requests. Investigate and adjust the cache path so normal direct-file playback can start reliably.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:54:13Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:56:14Z","started_at":"2026-05-26T04:54:17Z","closed_at":"2026-05-26T04:56:14Z","close_reason":"Bypassed the local range cache for Matroska-family tail-index containers and added a regression test confirming MKV probes fall back to direct VLC playback without issuing cache probe requests.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-2hw","title":"Fix range cache prefetch cursor after cached seek reads","description":"Skipping after the local range cache has warmed can leave prefetch following an older foreground cursor instead of the post-seek cached read position. Update the cache so cached foreground reads can reset the follow cursor and add regression coverage.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:45:44Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:47:44Z","started_at":"2026-05-26T04:46:36Z","closed_at":"2026-05-26T04:47:44Z","close_reason":"Fixed stale local range-cache prefetch state after cached seek reads and documented the validation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-mi1","title":"adapt vlc prefetch to actual post-seek reads","description":"Use real foreground VLC reads after a seek as a prefetch signal even when they are cache hits, and fetch aligned chunks for partial foreground misses so the cache warms ahead before VLC reaches the edge of retained data.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:38:14Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:40:10Z","started_at":"2026-05-26T04:38:16Z","closed_at":"2026-05-26T04:40:10Z","close_reason":"Used actual foreground VLC reads as prefetch follow signals on hits and changed foreground misses to fetch aligned chunks; added regression tests and updated the turn document.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-3pn","title":"reduce vlc seek buffering with range cache priming","description":"Improve VLC local range cache behavior after seek/jump by priming bytes behind the target, using stable global chunk boundaries, retaining useful cached ranges under a byte budget, and adding tests for the observed post-seek request pattern.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:31:46Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:35:36Z","started_at":"2026-05-26T04:31:51Z","closed_at":"2026-05-26T04:35:36Z","close_reason":"Implemented backward-biased seek priming, global 1 MB range-cache chunk alignment, bounded protected eviction, partial foreground miss fetching/logging, main-actor VLC delegate handling, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-meh","title":"Use VLC range requests to reprioritize seek prefetch","description":"Jump logs show duration-based byteOffset estimates can be far behind VLC's actual post-seek range requests, so prefetch keeps warming stale bytes while VLC buffers on higher cache misses.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:11:38Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:14:20Z","started_at":"2026-05-26T04:11:40Z","closed_at":"2026-05-26T04:14:20Z","close_reason":"Fixed range-cache prefetch reprioritization so foreground VLC misses cancel stale speculative work and restart around VLC's actual requested byte range; added regression coverage for the observed jump mismatch.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-42s","title":"Reduce VLC range-cache buffering after seeks","description":"Logs show repeated local-cache misses and cancelled prefetch tasks after VLC jumps backward, causing buffering while the cache restarts speculative requests instead of preserving useful adjacent downloads.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T03:58:03Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:00:46Z","started_at":"2026-05-26T03:58:10Z","closed_at":"2026-05-26T04:00:46Z","close_reason":"Fixed seek-time range-cache prefetching to prioritize the post-seek byte offset and avoid cancelling active prefetch work inside the same window; added focused coverage and validated with StreamResolverTests plus xcodebuild.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-9gw","title":"Cap VLC local range cache memory","description":"Playback can be killed for memory when VLC asks the loopback cache for a very large byte range. The local range cache should answer with bounded partial ranges and trim cached segments to the active window.","status":"closed","priority":1,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:38:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:44:07Z","closed_at":"2026-05-25T23:44:07Z","close_reason":"Capped local range-cache responses to 1 MB chunks, trimmed cached overlap windows, added focused tests, and confirmed the iOS simulator build succeeds.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-4t0","title":"Fix native external subtitle overlay fallback","description":"Parsed external subtitles are discovered but MobileVLCKit may report no imported subtitle tracks. Make Dreamio's parsed subtitle overlay the reliable fallback and add parser/overlay coverage.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:23:15Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:28:44Z","started_at":"2026-05-25T23:23:18Z","closed_at":"2026-05-25T23:28:44Z","close_reason":"Implemented parsed external subtitle overlay fallback, parser extraction, focused parser tests, and simulator build validation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-8l9","title":"Fix native external subtitle overlay fallback","description":"External subtitles are parsed and listed, but MobileVLCKit can report no imported subtitle tracks. Make parsed external subtitles the reliable overlay fallback, keep VLC import attempts optional, and add focused parser/cue tests.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:13:35Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:17:40Z","started_at":"2026-05-25T23:13:49Z","closed_at":"2026-05-25T23:17:40Z","close_reason":"Implemented native parsed external subtitle overlay fallback, added SRT parser/cue tests, and validated with parser tests plus iOS Simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-7wi","title":"render external subtitles outside vlc","description":"MobileVLCKit does not expose local external subtitle files even when they are added before playback. Add a native subtitle overlay fallback that parses resolved local subtitle files and renders cues against backend playback time.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:08:27Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:10:10Z","started_at":"2026-05-25T23:08:29Z","closed_at":"2026-05-25T23:10:10Z","close_reason":"Added a native subtitle overlay fallback that parses resolved local subtitle files and renders active cues when VLC exposes no subtitle tracks.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-3aq","title":"add queued subtitles before vlc playback","description":"MobileVLCKit accepts queued local subtitle files with addPlaybackSlave after playback starts but does not expose subtitle tracks. Add queued subtitle files to the VLCMedia before play using input-slave options, reserving addPlaybackSlave for late arrivals.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:04:34Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:05:33Z","started_at":"2026-05-25T23:04:36Z","closed_at":"2026-05-25T23:05:33Z","close_reason":"Moved queued local subtitle files onto the VLCMedia as input-slave options before playback starts, leaving addPlaybackSlave for late subtitles only.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-8oe","title":"accept plain stremio subtitle text","description":"Stremio subtitle download URLs return text that does not match the strict SRT/WebVTT/ASS detector, so the resolver rejects every candidate before VLC can attach subtitles. Accept non-markup text bodies from Stremio subtitle downloads as local subtitle files and improve rejection diagnostics.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:58:45Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:00:33Z","started_at":"2026-05-25T22:58:50Z","closed_at":"2026-05-25T23:00:33Z","close_reason":"Accepted plausible plain text from Stremio subtitle downloads as cached local SRT files and added rejection previews for any remaining misses.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-771","title":"cache stremio subtitles before VLC attach","description":"VLC accepts Stremio subtitle download URLs but does not expose external tracks from those extensionless provider URLs. Download resolvable subtitle payloads to local files with subtitle extensions before attachment so VLC can import them.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:53:43Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:55:39Z","started_at":"2026-05-25T22:53:45Z","closed_at":"2026-05-25T22:55:39Z","close_reason":"Resolved Stremio subtitle download URLs to local cached subtitle files before VLC attachment so extensionless provider URLs no longer go directly to MobileVLCKit.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-qyh","title":"queue subtitles until VLC media starts","description":"Buffered subtitle candidates can arrive before VLC has a media item when local range-cache setup performs an async probe. Queue those candidates and attach them once VLC media has been created so external subtitle tracks materialize.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:37:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:41:09Z","started_at":"2026-05-25T22:37:52Z","closed_at":"2026-05-25T22:41:09Z","close_reason":"Queued subtitle candidates until VLC media startup completes so local-cache playback no longer attaches external subtitles before a media item exists.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-11s","title":"add progressive vlc range cache","description":"Implement Dreamio-owned progressive HTTP range cache for native VLC playback, including local loopback playback, diagnostics, and range cache tests.","status":"closed","priority":1,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:20:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:59Z","closed_at":"2026-05-25T22:33:59Z","close_reason":"Implemented progressive HTTP range cache, VLC loopback playback selection, diagnostics, and cache tests.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-6bv","title":"fix native stream proxy buffering after seek","description":"Investigate and fix VLC staying in buffering after native proxy-backed jump seeks. Logs show time remains pinned after jump while state repeatedly reports buffering.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T21:53:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T21:55:32Z","started_at":"2026-05-25T21:54:01Z","closed_at":"2026-05-25T21:55:32Z","close_reason":"handled VLC buffering follow-up by supporting HEAD probes, moving fetch work off listener queue, reducing foreground range size, and locking cache access","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-dsp","title":"add local seek buffer proxy for native playback","description":"Implement a local HTTP range proxy/cache between VLC and direct-file streams so nearby seeks can use retained bytes, while preserving stream headers and subtitle behavior.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T20:18:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T20:22:41Z","started_at":"2026-05-25T20:18:47Z","closed_at":"2026-05-25T20:22:41Z","close_reason":"implemented local native stream cache proxy with range cache tests and successful simulator build","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ejh","title":"Preserve external subtitle language names in VLC captions menu","description":"VLC can surface externally attached subtitle slaves as generic Track N labels even though Dreamio already knows the OpenSubtitles language metadata. Preserve and apply that metadata when building the native captions menu so users can distinguish subtitle languages.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T17:46:38Z","created_by":"dirtydishes","updated_at":"2026-05-25T17:48:09Z","started_at":"2026-05-25T17:46:43Z","closed_at":"2026-05-25T17:48:09Z","close_reason":"Fixed by preserving known external subtitle display names for generic VLC subtitle tracks and expanding language-code aliases.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-9sp","title":"Accept Stremio subtitle download URLs","description":"Runtime logs show Stremio external subtitle tracks using subs5.strem.io /en/download URLs. The subtitle bridge and Swift parser currently reject those URLs because they do not have a subtitle file extension and are not on an OpenSubtitles host, so native playback receives zero external subtitle candidates.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:32:04Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:33:55Z","started_at":"2026-05-25T16:32:10Z","closed_at":"2026-05-25T16:33:55Z","close_reason":"Accepted Stremio subtitle download URLs in the bridge, parser, resolver, and regression tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-433","title":"Filter false OpenSubtitles subtitle candidates","description":"Dreamio is treating addon artwork and OpenSubtitles addon endpoints as external subtitle candidates, which causes the native player UI to show only embedded subtitles. Tighten subtitle URL detection in the web bridge and Swift parser, and add regression coverage for the logged false positives.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:20:47Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:22:50Z","started_at":"2026-05-25T16:20:50Z","closed_at":"2026-05-25T16:22:50Z","close_reason":"Fixed by tightening OpenSubtitles subtitle URL filtering in the web bridge and Swift parser, plus adding regression coverage for logged artwork and addon endpoint false positives.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -24,6 +46,11 @@ {"_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-060","title":"Improve native player controls experience","description":"Implement Liquid Glass-inspired native player UI improvements, touch target updates, scrubbing feedback, gestures, loading and failure states, menu polish, accessibility, and validation.","acceptance_criteria":"Native player controls are modernized; touch targets and scrubbing improve; gestures, loading/failure affordances, menu labels, visual polish, device adaptation, and accessibility are implemented; build validation is run.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:51:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:56:02Z","started_at":"2026-05-27T01:51:57Z","closed_at":"2026-05-27T01:56:02Z","close_reason":"Implemented native player Liquid Glass UX improvements and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-ee1","title":"Audit native player UX baseline","description":"Audit the existing native player controls and document current user experience strengths, gaps, and implementation constraints before making UI changes.","acceptance_criteria":"Current NativePlayerViewController controls are reviewed; backend constraints are summarized; UX improvement opportunities are documented.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:49:46Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:50:32Z","started_at":"2026-05-27T01:49:48Z","closed_at":"2026-05-27T01:50:32Z","close_reason":"Completed baseline UX audit in docs/native-player-ux-audit.md","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-p8p","title":"Recreate OpenSubtitles language turn doc with template","description":"Rebuild the OpenSubtitles caption-track turn document using the new lavender template and contained Clean SSR diff shells.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T05:16:17Z","created_by":"dirtydishes","updated_at":"2026-05-26T05:19:00Z","started_at":"2026-05-26T05:16:21Z","closed_at":"2026-05-26T05:19:00Z","close_reason":"Recreated the OpenSubtitles language turn document using the new lavender template and contained Clean SSR diff shells.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-h28","title":"Clean up turn document template guidance","description":"Add a reusable turn document template and tighten repository instructions so future turn docs use contained clean SSR diffs instead of raw generated diff blobs.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T05:07:57Z","created_by":"dirtydishes","updated_at":"2026-05-26T05:11:37Z","started_at":"2026-05-26T05:08:01Z","closed_at":"2026-05-26T05:11:37Z","close_reason":"Added the reusable turn document template and updated repository instructions for clean SSR diff rendering.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-3yb","title":"Add VLC seek buffer for native playback","description":"Configure balanced VLC media caching in the native playback backend so short seek jumps are less likely to feel like stream restarts while preserving existing playback controls, audio tracks, and subtitles.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T19:05:27Z","created_by":"dirtydishes","updated_at":"2026-05-25T19:07:24Z","started_at":"2026-05-25T19:05:31Z","closed_at":"2026-05-25T19:07:24Z","close_reason":"Added centralized 30-second VLC media caching for native playback and validated the iOS build.","comments":[{"id":"019e60b6-9b11-7c5f-af57-c9e54ac6129f","issue_id":"dreamio-3yb","author":"dirtydishes","text":"Device testing still shows repeated VLC buffering after 15-second jumps. Added DEBUG playback snapshots on state changes and delayed post-jump probes so the next pass can distinguish a stalled stream/range reconnect from a seek-state issue.","created_at":"2026-05-25T19:57:21Z"},{"id":"019e60c4-f824-79fe-b974-9fbe9fe91788","issue_id":"dreamio-3yb","author":"dirtydishes","text":"Latest device logs show VLC remains at the pre-jump time/position after a fixed skip while buffering. Added backend-only stalled jump recovery: after a short no-progress buffering window, reopen the same media with :start-time set to the requested target and reattach accepted subtitle candidates plus selected tracks where available.","created_at":"2026-05-25T20:13:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2} {"_type":"issue","id":"dreamio-kdf","title":"Stop tracking Xcode user state","description":"Xcode user interface state files are machine-specific and currently tracked, which causes noisy local modifications and pull conflicts. Remove tracked xcuserstate files from the git index while keeping ignore rules in place.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T18:31:36Z","created_by":"dirtydishes","updated_at":"2026-05-25T18:31:51Z","started_at":"2026-05-25T18:31:39Z","closed_at":"2026-05-25T18:31:51Z","close_reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-0lt","title":"add audio track selection","description":"Add native player support for viewing and switching available audio tracks during playback.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T17:00:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T17:01:33Z","closed_at":"2026-05-25T17:01:33Z","close_reason":"not implementing now; user asked only to move previous work to the audio-track-selection branch","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-2ju","title":"Show OpenSubtitles languages in caption tracks","description":"Preserve external subtitle metadata after VLC attaches OpenSubtitles tracks so the captions menu shows useful language labels instead of generic VLC track names.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:52:44Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:54:58Z","started_at":"2026-05-25T16:52:49Z","closed_at":"2026-05-25T16:54:58Z","close_reason":"Fixed by preserving OpenSubtitles subtitle display metadata through VLC external track attachment and adding display-name tests.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index 1679f1f..48b7dd6 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -11,8 +11,35 @@ final class NativePlayerViewController: UIViewController { private var attachedSubtitleURLs: Set private var audioMenuSignature: String? private var captionsMenuSignature: String? + private var controlsMaximumWidthConstraint: NSLayoutConstraint? + private let bottomScrimLayer = CAGradientLayer() var onDismiss: (() -> Void)? + private let loadingContainer: UIVisualEffectView = { + let view = NativePlayerViewController.glassPanel(cornerRadius: 24) + view.isHidden = false + return view + }() + + private let loadingStack: UIStackView = { + let stack = UIStackView() + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 12 + return stack + }() + + private let loadingTextLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "Opening stream…" + label.textColor = .white + label.font = .preferredFont(forTextStyle: .subheadline) + label.adjustsFontForContentSizeCategory = true + return label + }() + private let loadingView: UIActivityIndicatorView = { let view = UIActivityIndicatorView(style: .large) view.translatesAutoresizingMaskIntoConstraints = false @@ -26,22 +53,16 @@ final class NativePlayerViewController: UIViewController { button.translatesAutoresizingMaskIntoConstraints = false button.setImage(UIImage(systemName: "xmark"), for: .normal) button.tintColor = .white - button.backgroundColor = UIColor.black.withAlphaComponent(0.45) - button.layer.cornerRadius = 18 + button.backgroundColor = UIColor.white.withAlphaComponent(0.14) + button.layer.cornerRadius = 22 + button.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColor + button.layer.borderWidth = 1 button.accessibilityLabel = "Close" + button.accessibilityHint = "Closes native playback and returns to Stremio." return button }() - private let controlsContainer: UIVisualEffectView = { - let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) - view.translatesAutoresizingMaskIntoConstraints = false - view.layer.cornerRadius = 22 - view.clipsToBounds = true - view.backgroundColor = UIColor.white.withAlphaComponent(0.08) - view.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor - view.layer.borderWidth = 1 - return view - }() + private let controlsContainer = NativePlayerViewController.glassPanel(cornerRadius: 26) private let tapSurfaceView: UIView = { let view = UIView() @@ -50,11 +71,14 @@ final class NativePlayerViewController: UIViewController { return view }() - private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause") + private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause", pointSize: 24) private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds") private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds") - private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Tracks") - private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions") + private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Track") + private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Subtitles") + + private let centerPlayPauseIndicator = NativePlayerViewController.centerGlassIndicator() + private let centerPlayPauseButton = NativePlayerViewController.iconButton(systemName: "play.fill", label: "Toggle Playback", pointSize: 34) private let elapsedLabel: UILabel = { let label = UILabel() @@ -88,17 +112,50 @@ final class NativePlayerViewController: UIViewController { return slider }() - private let failureLabel: UILabel = { + private let scrubTimeBubble: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textColor = .white label.textAlignment = .center - label.numberOfLines = 0 - label.font = .preferredFont(forTextStyle: .body) - label.isHidden = true + label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .bold) + label.backgroundColor = UIColor.black.withAlphaComponent(0.56) + label.layer.cornerRadius = 14 + label.clipsToBounds = true + label.alpha = 0 return label }() + private let failureContainer: UIVisualEffectView = { + let view = NativePlayerViewController.glassPanel(cornerRadius: 28) + view.isHidden = true + return view + }() + + private let failureTitleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = "Playback could not start" + label.textColor = .white + label.textAlignment = .center + label.font = .preferredFont(forTextStyle: .headline) + label.adjustsFontForContentSizeCategory = true + return label + }() + + private let failureDetailLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = UIColor.white.withAlphaComponent(0.82) + label.textAlignment = .center + label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: .subheadline) + label.adjustsFontForContentSizeCategory = true + return label + }() + + private let retryButton: UIButton = NativePlayerViewController.textButton(title: "Retry") + private let failureCloseButton: UIButton = NativePlayerViewController.textButton(title: "Close") + init( request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend(), @@ -136,9 +193,14 @@ final class NativePlayerViewController: UIViewController { view.backgroundColor = .black configureBackend() configureLayout() - startStartupTimer() - backend.play(request: request) - addSubtitleCandidates(request.subtitleCandidates) + configureAccessibility() + startPlayback() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + bottomScrimLayer.frame = view.bounds + updateLayoutForCurrentSize() } @discardableResult @@ -212,7 +274,7 @@ final class NativePlayerViewController: UIViewController { DispatchQueue.main.async { self?.startupTimer?.invalidate() self?.loadingView.stopAnimating() - self?.loadingView.isHidden = true + self?.loadingContainer.isHidden = true self?.startProgressUpdates() self?.refreshControls() self?.scheduleControlsHide() @@ -241,6 +303,16 @@ final class NativePlayerViewController: UIViewController { } } + private func startPlayback() { + loadingContainer.isHidden = false + loadingView.startAnimating() + failureContainer.isHidden = true + startStartupTimer() + backend.play(request: request) + addSubtitleCandidates(request.subtitleCandidates) + revealControls() + } + private func startStartupTimer() { startupTimer?.invalidate() startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in @@ -251,18 +323,34 @@ final class NativePlayerViewController: UIViewController { private func configureLayout() { view.addSubview(backend.view) + configureBottomScrim() view.addSubview(tapSurfaceView) - view.addSubview(loadingView) - view.addSubview(failureLabel) + view.addSubview(loadingContainer) + view.addSubview(failureContainer) + view.addSubview(centerPlayPauseIndicator) + view.addSubview(centerPlayPauseButton) view.addSubview(controlsContainer) + view.addSubview(scrubTimeBubble) view.addSubview(closeButton) + loadingStack.addArrangedSubview(loadingView) + loadingStack.addArrangedSubview(loadingTextLabel) + loadingContainer.contentView.addSubview(loadingStack) + configureFailureCard() closeButton.addTarget(self, action: #selector(close), for: .touchUpInside) playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside) + centerPlayPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside) jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside) jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside) + retryButton.addTarget(self, action: #selector(retryPlayback), for: .touchUpInside) + failureCloseButton.addTarget(self, action: #selector(close), for: .touchUpInside) audioButton.showsMenuAsPrimaryAction = true captionsButton.showsMenuAsPrimaryAction = true - playPauseButton.layer.cornerRadius = 24 + playPauseButton.layer.cornerRadius = 28 + centerPlayPauseButton.layer.cornerRadius = 34 + centerPlayPauseButton.backgroundColor = .clear + centerPlayPauseButton.layer.borderWidth = 0 + centerPlayPauseButton.alpha = 0 + centerPlayPauseIndicator.alpha = 0 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]) @@ -270,6 +358,14 @@ final class NativePlayerViewController: UIViewController { let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility)) tap.cancelsTouchesInView = false tapSurfaceView.addGestureRecognizer(tap) + let leftDoubleTap = UITapGestureRecognizer(target: self, action: #selector(handleLeftDoubleTap)) + leftDoubleTap.numberOfTapsRequired = 2 + let rightDoubleTap = UITapGestureRecognizer(target: self, action: #selector(handleRightDoubleTap)) + rightDoubleTap.numberOfTapsRequired = 2 + tap.require(toFail: leftDoubleTap) + tap.require(toFail: rightDoubleTap) + tapSurfaceView.addGestureRecognizer(leftDoubleTap) + tapSurfaceView.addGestureRecognizer(rightDoubleTap) let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel]) timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false @@ -297,6 +393,8 @@ final class NativePlayerViewController: UIViewController { stack.spacing = 6 controlsContainer.contentView.addSubview(stack) + controlsMaximumWidthConstraint = controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430) + NSLayoutConstraint.activate([ backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -308,21 +406,35 @@ final class NativePlayerViewController: UIViewController { tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor), tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor), - loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + loadingContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor), + loadingStack.leadingAnchor.constraint(equalTo: loadingContainer.contentView.leadingAnchor, constant: 18), + loadingStack.trailingAnchor.constraint(equalTo: loadingContainer.contentView.trailingAnchor, constant: -18), + loadingStack.topAnchor.constraint(equalTo: loadingContainer.contentView.topAnchor, constant: 14), + loadingStack.bottomAnchor.constraint(equalTo: loadingContainer.contentView.bottomAnchor, constant: -14), - failureLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 28), - failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28), - failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + failureContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + failureContainer.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + failureContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -40), + failureContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 420), - closeButton.widthAnchor.constraint(equalToConstant: 36), - closeButton.heightAnchor.constraint(equalToConstant: 36), + centerPlayPauseIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + centerPlayPauseIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + centerPlayPauseIndicator.widthAnchor.constraint(equalToConstant: 68), + centerPlayPauseIndicator.heightAnchor.constraint(equalToConstant: 68), + centerPlayPauseButton.centerXAnchor.constraint(equalTo: centerPlayPauseIndicator.centerXAnchor), + centerPlayPauseButton.centerYAnchor.constraint(equalTo: centerPlayPauseIndicator.centerYAnchor), + centerPlayPauseButton.widthAnchor.constraint(equalTo: centerPlayPauseIndicator.widthAnchor), + centerPlayPauseButton.heightAnchor.constraint(equalTo: centerPlayPauseIndicator.heightAnchor), + + closeButton.widthAnchor.constraint(equalToConstant: 44), + closeButton.heightAnchor.constraint(equalToConstant: 44), 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), + controlsMaximumWidthConstraint!, controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12), stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12), @@ -334,64 +446,108 @@ final class NativePlayerViewController: UIViewController { 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), - audioButton.widthAnchor.constraint(equalToConstant: 36), - audioButton.heightAnchor.constraint(equalToConstant: 36), + scrubTimeBubble.centerXAnchor.constraint(equalTo: controlsContainer.centerXAnchor), + scrubTimeBubble.bottomAnchor.constraint(equalTo: controlsContainer.topAnchor, constant: -8), + scrubTimeBubble.widthAnchor.constraint(greaterThanOrEqualToConstant: 64), + scrubTimeBubble.heightAnchor.constraint(equalToConstant: 28), + + jumpBackButton.widthAnchor.constraint(equalToConstant: 44), + jumpBackButton.heightAnchor.constraint(equalToConstant: 44), + playPauseButton.widthAnchor.constraint(equalToConstant: 56), + playPauseButton.heightAnchor.constraint(equalToConstant: 56), + jumpForwardButton.widthAnchor.constraint(equalToConstant: 44), + jumpForwardButton.heightAnchor.constraint(equalToConstant: 44), + audioButton.widthAnchor.constraint(equalToConstant: 44), + audioButton.heightAnchor.constraint(equalToConstant: 44), playbackCluster.centerXAnchor.constraint(equalTo: controlRow.centerXAnchor), - captionsButton.widthAnchor.constraint(equalToConstant: 36), - captionsButton.heightAnchor.constraint(equalToConstant: 36) + captionsButton.widthAnchor.constraint(equalToConstant: 44), + captionsButton.heightAnchor.constraint(equalToConstant: 44) ]) } private func showFailure(_ error: Error) { + controlsTimer?.invalidate() loadingView.stopAnimating() - loadingView.isHidden = true - failureLabel.text = "Native playback could not start.\n\(error.localizedDescription)" - failureLabel.isHidden = false + loadingContainer.isHidden = true + controlsContainer.alpha = 0 + controlsContainer.isUserInteractionEnabled = false + failureDetailLabel.text = error.localizedDescription + failureContainer.isHidden = false + closeButton.alpha = 1 + closeButton.isUserInteractionEnabled = true #if DEBUG print("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))") #endif } + @objc private func retryPlayback() { + backend.stop() + progressTimer?.invalidate() + audioMenuSignature = nil + captionsMenuSignature = nil + startPlayback() + } + @objc private func close() { dismiss(animated: true) } @objc private func togglePlayPause() { backend.togglePlayPause() + flashCenterPlayPauseIcon() revealControls() } @objc private func jumpBack() { backend.jump(by: -15) + UIImpactFeedbackGenerator(style: .light).impactOccurred() revealControls() } @objc private func jumpForward() { backend.jump(by: 15) + UIImpactFeedbackGenerator(style: .light).impactOccurred() revealControls() } @objc private func scrubbingStarted() { isScrubbing = true controlsTimer?.invalidate() + scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 20), for: .normal) + scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 22), for: .highlighted) + UISelectionFeedbackGenerator().selectionChanged() + updateScrubPreview() + UIView.animate(withDuration: 0.16) { + self.scrubTimeBubble.alpha = 1 + } } @objc private func scrubberChanged() { - elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration) + updateScrubPreview() } @objc private func scrubbingEnded() { backend.seek(to: scrubber.value) isScrubbing = false + scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 14), for: .normal) + scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 18), for: .highlighted) + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + UIView.animate(withDuration: 0.18) { + self.scrubTimeBubble.alpha = 0 + } revealControls() } + @objc private func handleLeftDoubleTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.location(in: tapSurfaceView).x < tapSurfaceView.bounds.midX else { return } + jumpBack() + } + + @objc private func handleRightDoubleTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.location(in: tapSurfaceView).x >= tapSurfaceView.bounds.midX else { return } + jumpForward() + } + @objc private func toggleControlsVisibility() { if controlsContainer.alpha < 1 { revealControls() @@ -428,7 +584,7 @@ final class NativePlayerViewController: UIViewController { } let delayActions = UIMenu( - title: "Delay", + title: "Subtitle Delay", options: .displayInline, children: [ UIAction(title: "Decrease 0.5s") { [weak self] _ in @@ -441,6 +597,12 @@ final class NativePlayerViewController: UIViewController { self?.captionsMenuSignature = nil self?.refreshControls() }, + UIAction(title: "Reset Delay") { [weak self] _ in + guard let self else { return } + self.backend.adjustSubtitleDelay(by: -self.backend.subtitleDelay) + self.captionsMenuSignature = nil + self.refreshControls() + }, UIAction( title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))", attributes: .disabled @@ -448,7 +610,7 @@ final class NativePlayerViewController: UIViewController { ] ) - return UIMenu(title: "Captions", children: trackActions + [delayActions]) + return UIMenu(title: "Subtitles", children: trackActions + [delayActions]) } private func audioMenu() -> UIMenu { @@ -478,7 +640,7 @@ final class NativePlayerViewController: UIViewController { } } - return UIMenu(title: "Audio", children: trackActions) + return UIMenu(title: "Audio Track", children: trackActions) } private func startProgressUpdates() { @@ -499,6 +661,7 @@ final class NativePlayerViewController: UIViewController { updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks) elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime) remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))" + scrubber.accessibilityValue = "\(elapsedLabel.text ?? "0:00") elapsed, \(remainingLabel.text ?? "-0:00") remaining" if !isScrubbing { scrubber.value = backend.position } @@ -514,6 +677,7 @@ final class NativePlayerViewController: UIViewController { let hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1 audioButton.isEnabled = hasSelectableTrack audioButton.alpha = hasSelectableTrack ? 1 : 0.45 + audioButton.accessibilityHint = hasSelectableTrack ? "Opens available audio tracks." : "Only one audio track is available." guard signature != audioMenuSignature else { return } @@ -534,6 +698,8 @@ final class NativePlayerViewController: UIViewController { ) let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 } captionsButton.isEnabled = hasSelectableTrack + captionsButton.alpha = hasSelectableTrack ? 1 : 0.45 + captionsButton.accessibilityHint = hasSelectableTrack ? "Opens subtitle tracks and delay controls." : "No subtitle tracks are available yet." guard signature != captionsMenuSignature else { return } @@ -567,7 +733,8 @@ final class NativePlayerViewController: UIViewController { private func revealControls() { controlsContainer.isUserInteractionEnabled = true closeButton.isUserInteractionEnabled = true - UIView.animate(withDuration: 0.18) { + controlsContainer.transform = .identity + UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) { self.controlsContainer.alpha = 1 self.closeButton.alpha = 1 } @@ -575,9 +742,10 @@ final class NativePlayerViewController: UIViewController { } private func hideControls() { + guard !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else { return } controlsContainer.isUserInteractionEnabled = false closeButton.isUserInteractionEnabled = false - UIView.animate(withDuration: 0.24) { + UIView.animate(withDuration: 0.28, delay: 0, options: [.curveEaseInOut]) { self.controlsContainer.alpha = 0 self.closeButton.alpha = 0 } @@ -585,27 +753,171 @@ final class NativePlayerViewController: UIViewController { private func scheduleControlsHide() { controlsTimer?.invalidate() - guard backend.isPlaying else { + guard backend.isPlaying, !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else { return } - controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in + controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.5, repeats: false) { [weak self] _ in self?.hideControls() } } - private static func iconButton(systemName: String, label: String) -> UIButton { + private func configureBottomScrim() { + bottomScrimLayer.colors = [ + UIColor.clear.cgColor, + UIColor.black.withAlphaComponent(0.12).cgColor, + UIColor.black.withAlphaComponent(0.48).cgColor + ] + bottomScrimLayer.locations = [0, 0.58, 1] + view.layer.insertSublayer(bottomScrimLayer, above: backend.view.layer) + } + + private func configureFailureCard() { + let buttonRow = UIStackView(arrangedSubviews: [failureCloseButton, retryButton]) + buttonRow.translatesAutoresizingMaskIntoConstraints = false + buttonRow.axis = .horizontal + buttonRow.distribution = .fillEqually + buttonRow.spacing = 10 + + let stack = UIStackView(arrangedSubviews: [failureTitleLabel, failureDetailLabel, buttonRow]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.alignment = .fill + stack.spacing = 14 + failureContainer.contentView.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: failureContainer.contentView.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: failureContainer.contentView.trailingAnchor, constant: -20), + stack.topAnchor.constraint(equalTo: failureContainer.contentView.topAnchor, constant: 20), + stack.bottomAnchor.constraint(equalTo: failureContainer.contentView.bottomAnchor, constant: -20), + retryButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44), + failureCloseButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44) + ]) + } + + private func configureAccessibility() { + scrubber.accessibilityLabel = "Playback Position" + scrubber.accessibilityHint = "Adjusts the playback position." + playPauseButton.accessibilityHint = "Toggles playback." + jumpBackButton.accessibilityHint = "Rewinds 15 seconds when seeking is available." + jumpForwardButton.accessibilityHint = "Skips ahead 15 seconds when seeking is available." + view.accessibilityCustomActions = [ + UIAccessibilityCustomAction(name: "Jump Back 15 Seconds", target: self, selector: #selector(accessibilityJumpBack)), + UIAccessibilityCustomAction(name: "Jump Forward 15 Seconds", target: self, selector: #selector(accessibilityJumpForward)) + ] + } + + @objc private func accessibilityJumpBack() -> Bool { + jumpBack() + return true + } + + @objc private func accessibilityJumpForward() -> Bool { + jumpForward() + return true + } + + private func updateLayoutForCurrentSize() { + controlsMaximumWidthConstraint?.constant = traitCollection.horizontalSizeClass == .regular ? 560 : 430 + } + + private func updateScrubPreview() { + let target = TimeInterval(scrubber.value) * backend.duration + let label = PlaybackTimeFormatter.label(for: target) + elapsedLabel.text = label + scrubTimeBubble.text = " \(label) " + } + + private func flashCenterPlayPauseIcon() { + centerPlayPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal) + if UIAccessibility.isReduceMotionEnabled { + centerPlayPauseIndicator.transform = .identity + centerPlayPauseButton.transform = .identity + centerPlayPauseIndicator.alpha = 1 + centerPlayPauseButton.alpha = 1 + UIView.animate(withDuration: 0.18, delay: 0.55, options: [.curveEaseOut]) { + self.centerPlayPauseIndicator.alpha = 0 + self.centerPlayPauseButton.alpha = 0 + } + return + } + let initialScale = CGAffineTransform(scaleX: 0.86, y: 0.86) + centerPlayPauseIndicator.transform = initialScale + centerPlayPauseButton.transform = initialScale + UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseOut]) { + self.centerPlayPauseIndicator.alpha = 1 + self.centerPlayPauseButton.alpha = 1 + self.centerPlayPauseIndicator.transform = .identity + self.centerPlayPauseButton.transform = .identity + } completion: { _ in + UIView.animate(withDuration: 0.28, delay: 0.32, options: [.curveEaseOut]) { + self.centerPlayPauseIndicator.alpha = 0 + self.centerPlayPauseButton.alpha = 0 + } + } + } + + private static func centerGlassIndicator() -> UIVisualEffectView { + let view = glassPanel(cornerRadius: 34) + view.backgroundColor = UIColor.white.withAlphaComponent(0.08) + view.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor + view.layer.borderWidth = 0.75 + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = 0.28 + view.layer.shadowRadius = 16 + view.layer.shadowOffset = CGSize(width: 0, height: 6) + view.isUserInteractionEnabled = false + return view + } + + private static func glassPanel(cornerRadius: CGFloat) -> UIVisualEffectView { + let effect: UIVisualEffect + if #available(iOS 26.0, *) { + let glassEffect = UIGlassEffect() + glassEffect.tintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 0.16) + glassEffect.isInteractive = true + effect = glassEffect + } else { + effect = UIBlurEffect(style: .systemUltraThinMaterialDark) + } + let view = UIVisualEffectView(effect: effect) + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.cornerRadius = cornerRadius + view.clipsToBounds = true + view.backgroundColor = UIColor.white.withAlphaComponent(0.09) + view.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColor + view.layer.borderWidth = 1 + return view + } + + private static func iconButton(systemName: String, label: String, pointSize: CGFloat = 20) -> UIButton { let button = UIButton(type: .system) button.translatesAutoresizingMaskIntoConstraints = false - button.setImage(UIImage(systemName: systemName), for: .normal) + let configuration = UIImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold) + button.setImage(UIImage(systemName: systemName, withConfiguration: configuration), for: .normal) button.tintColor = .white - button.backgroundColor = UIColor.white.withAlphaComponent(0.12) - button.layer.cornerRadius = 18 + button.backgroundColor = UIColor.white.withAlphaComponent(0.14) + button.layer.cornerRadius = 22 button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor button.layer.borderWidth = 1 button.accessibilityLabel = label return button } + private static func textButton(title: String) -> UIButton { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(title, for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = .preferredFont(forTextStyle: .headline) + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.backgroundColor = UIColor.white.withAlphaComponent(0.16) + button.layer.cornerRadius = 14 + button.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor + button.layer.borderWidth = 1 + return button + } + private static func scrubberThumbImage(diameter: CGFloat) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = UIScreen.main.scale diff --git a/docs/native-player-ux-audit.md b/docs/native-player-ux-audit.md new file mode 100644 index 0000000..8179218 --- /dev/null +++ b/docs/native-player-ux-audit.md @@ -0,0 +1,124 @@ +# Native Player UX Baseline Audit + +Date: 2026-05-26 21:49 EDT +Issue: `dreamio-ee1` + +## Scope + +This audit covers the current VLC-backed native playback experience before Liquid Glass and broader UX changes. The primary implementation is `Dreamio/NativePlayerViewController.swift`, backed by `Dreamio/NativePlaybackBackend.swift` and `Dreamio/VLCNativePlaybackBackend.swift`. + +## Current Experience Summary + +Dreamio presents native playback as a full-screen black video surface with a compact bottom control tray, a top-right close button, a loading spinner, and a simple centered failure label. Controls are built directly in UIKit and are intentionally lightweight. + +Current user-facing controls: + +- Top-right close button. +- Bottom compact blur-backed tray capped at 430 points wide. +- Elapsed time, scrubber, and remaining time row. +- Audio menu button. +- 15-second jump back. +- Play/pause. +- 15-second jump forward. +- Captions menu button with subtitle delay controls. +- Tap anywhere on the playback surface to reveal or hide controls. +- Auto-hide while playing after 3 seconds. + +## Existing Strengths + +- **Minimal interruption:** The player is full-screen and hides chrome while playback is active. +- **Compact controls:** Recent work reduced the overlay footprint so video content stays dominant. +- **Core playback affordances exist:** Play/pause, seeking, jump controls, elapsed/remaining time, audio tracks, captions, and subtitle delay are present. +- **Backend-aware disabling:** Seek and jump controls disable when VLC reports a non-seekable stream. +- **Track menus are cached:** Audio and captions menus avoid unnecessary rebuilds through signature values. +- **Subtitle handoff exists:** The native player can receive subtitle candidates from the Stremio Web bridge and attach them through the backend. +- **Safe-area aware:** Close and controls are anchored to safe-area guides. + +## Current UX Gaps + +### Visual Treatment + +- The control surface uses `UIBlurEffect(style: .systemUltraThinMaterialDark)` plus manual tint/border styling, not iOS 26 Liquid Glass. +- Buttons are individual translucent wells rather than an intentional grouped glass system. +- There is no bottom readability scrim; bright video frames could reduce time label and icon contrast. +- Loading and failure states are plain compared with the rest of the player chrome. + +### Touch Targets and Hierarchy + +- Secondary controls are 36×36 points, below Apple’s recommended 44×44 point minimum target. +- The play/pause button is only 42×42 points, so the primary action is not dominant enough. +- Audio and captions have the same visual weight as transport controls even when unavailable or secondary. +- Disabled captions do not currently mirror the explicit alpha treatment used by audio. + +### Scrubbing + +- The scrubber has a compact thumb and row, but no preview/target time bubble. +- During scrubbing, the elapsed label updates, but the user does not get a larger focused scrubbing affordance. +- There is no haptic feedback on scrub begin/end or jump actions. + +### Gestures + +- Single tap toggles controls, but no richer playback gestures exist. +- There is no double-tap left/right jump behavior. +- There is no center tap play/pause behavior separate from chrome visibility. +- Gesture conflict handling will need care because `tap.cancelsTouchesInView = false` currently allows control touches to pass through. + +### Auto-Hide + +- Controls hide after 3 seconds while playing, which can feel short for subtitle/audio menu interactions or careful scrubbing. +- The auto-hide timer only checks `backend.isPlaying`; future changes should explicitly protect menu presentation, scrubbing, loading, paused, and failure states. + +### Loading and Failure + +- Startup loading is only a white `UIActivityIndicatorView` over black. +- Failure is a plain text label with no retry, close action, or debug affordance. +- The 20-second startup timeout is useful, but the user receives little guidance after it fires. + +### Accessibility + +- Buttons have accessibility labels, but no accessibility hints. +- The scrubber has no dynamic accessibility value describing elapsed and remaining time. +- Jump actions are not exposed as custom accessibility actions on the player surface. +- Motion preferences are not checked before animations. +- Dynamic Type behavior for the compact controls has not been explicitly protected. + +### Device Adaptation + +- The bottom tray is capped at 430 points, which is good for phones but underuses iPad/large landscape space. +- Button sizing and row spacing are fixed; orientation-specific density has not been tuned. +- The supported orientations allow all but upside down, so landscape crowding should be part of validation. + +## Implementation Constraints + +- `NativePlaybackBackend` currently exposes no thumbnail/preview frames, so scrub previews should begin as a time bubble rather than video thumbnails. +- The backend exposes seekability, duration, position, audio tracks, subtitle tracks, and subtitle delay, which is enough for richer controls without backend changes. +- The app must preserve compatibility with builds where MobileVLCKit is unavailable through the existing fallback backend behavior. +- Liquid Glass APIs should be gated with iOS availability checks and keep the current blur/material implementation as fallback. +- Multiple glass controls should be grouped with container effects where possible to avoid expensive standalone glass rendering. + +## Recommended Next Implementation Slice + +Start with a low-risk visual and ergonomics pass: + +1. Add iOS 26 Liquid Glass availability-gated styling for the main controls tray and interactive buttons, preserving the current blur fallback. +2. Increase button targets to at least 44×44 points and make play/pause 54–58 points. +3. Add a subtle bottom gradient scrim behind controls for contrast. +4. Extend auto-hide timing from 3 seconds to about 4.5 seconds and prevent hiding while scrubbing. +5. Add missing accessibility hints and scrubber accessibility value. + +Defer until the second implementation slice: + +- Scrubber target time bubble. +- Double-tap jump gestures. +- Center tap play/pause behavior. +- Glass-backed loading and failure cards with retry. +- iPad-specific wider layout. + +## Validation Targets for Future Changes + +- Build the workspace with Xcode command-line tools. +- Test seekable and non-seekable streams. +- Test streams with one audio track, multiple audio tracks, no captions, embedded captions, and external captions. +- Verify controls remain usable in portrait and landscape. +- Verify VoiceOver labels, hints, and scrubber values. +- Verify older iOS fallback styling still works when Liquid Glass is unavailable. diff --git a/docs/turns/2026-05-26-center-play-pause-contrast.html b/docs/turns/2026-05-26-center-play-pause-contrast.html new file mode 100644 index 0000000..abdaf31 --- /dev/null +++ b/docs/turns/2026-05-26-center-play-pause-contrast.html @@ -0,0 +1,452 @@ + + + + + + Improve Center Play/Pause Indicator Contrast + + + + + + +
+
+

Dreamio turn document

+

Improve Center Play/Pause Indicator Contrast

+

Improved the native player center play/pause flash so it remains visible over varied video content.

+
+ 2026-05-26 + Native player controls + Contrast fix +
+
+ +
+
+

Summary

+

Improved the native player center play/pause flash so it remains visible over varied video content.

+
+ +
+

Changes Made

+
    +
  • Made the center play/pause flash larger, darker, and more defined with a stronger border and shadow.
  • +
  • Kept the indicator visible for longer during the flash animation and added a reduced-motion fade path.
  • +
+
+ +
+

Context

+

The center-screen play/pause indicator was too transparent, making it hard to see against bright or busy video frames.

+
+ +
+

Important Implementation Details

+
    +
  • The existing Liquid Glass-style icon button remains in use, but the transient center instance now gets a high-contrast black backing instead of the low-alpha shared control treatment.
  • +
  • The hit target and visual footprint increased from 68 to 80 points while keeping the indicator centered.
  • +
  • Reduced Motion users now still receive a non-scaling visible flash instead of skipping the indicator entirely.
  • +
+
+ +
+

Relevant Diff Snippets

+
+
+

Dreamio/NativePlayerViewController.swift · center play/pause indicator contrast

+
+
Dreamio/NativePlayerViewController.swift
-6+20
343 unmodified lines
344
345
346
347
348
349
350
64 unmodified lines
415
416
417
418
419
420
421
422
404 unmodified lines
827
828
829
830
831
832
833
834
835
836
837
838
839
343 unmodified lines
audioButton.showsMenuAsPrimaryAction = true
captionsButton.showsMenuAsPrimaryAction = true
playPauseButton.layer.cornerRadius = 28
centerPlayPauseButton.layer.cornerRadius = 34
centerPlayPauseButton.alpha = 0
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
64 unmodified lines
+
centerPlayPauseButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
centerPlayPauseButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
centerPlayPauseButton.widthAnchor.constraint(equalToConstant: 68),
centerPlayPauseButton.heightAnchor.constraint(equalToConstant: 68),
+
closeButton.widthAnchor.constraint(equalToConstant: 44),
closeButton.heightAnchor.constraint(equalToConstant: 44),
404 unmodified lines
+
private func flashCenterPlayPauseIcon() {
centerPlayPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
guard !UIAccessibility.isReduceMotionEnabled else { return }
centerPlayPauseButton.transform = CGAffineTransform(scaleX: 0.86, y: 0.86)
UIView.animate(withDuration: 0.16, animations: {
self.centerPlayPauseButton.alpha = 1
self.centerPlayPauseButton.transform = .identity
}) { _ in
UIView.animate(withDuration: 0.22, delay: 0.28, options: [.curveEaseOut]) {
self.centerPlayPauseButton.alpha = 0
}
}
343 unmodified lines
344
345
346
347
348
349
350
351
352
353
354
355
356
357
64 unmodified lines
422
423
424
425
426
427
428
429
404 unmodified lines
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
343 unmodified lines
audioButton.showsMenuAsPrimaryAction = true
captionsButton.showsMenuAsPrimaryAction = true
playPauseButton.layer.cornerRadius = 28
centerPlayPauseButton.layer.cornerRadius = 40
centerPlayPauseButton.backgroundColor = UIColor.black.withAlphaComponent(0.58)
centerPlayPauseButton.layer.borderColor = UIColor.white.withAlphaComponent(0.38).cgColor
centerPlayPauseButton.layer.borderWidth = 1.5
centerPlayPauseButton.layer.shadowColor = UIColor.black.cgColor
centerPlayPauseButton.layer.shadowOpacity = 0.45
centerPlayPauseButton.layer.shadowRadius = 18
centerPlayPauseButton.layer.shadowOffset = CGSize(width: 0, height: 8)
centerPlayPauseButton.alpha = 0
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
64 unmodified lines
+
centerPlayPauseButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
centerPlayPauseButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
centerPlayPauseButton.widthAnchor.constraint(equalToConstant: 80),
centerPlayPauseButton.heightAnchor.constraint(equalToConstant: 80),
+
closeButton.widthAnchor.constraint(equalToConstant: 44),
closeButton.heightAnchor.constraint(equalToConstant: 44),
404 unmodified lines
+
private func flashCenterPlayPauseIcon() {
centerPlayPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
if UIAccessibility.isReduceMotionEnabled {
centerPlayPauseButton.transform = .identity
centerPlayPauseButton.alpha = 1
UIView.animate(withDuration: 0.18, delay: 0.55, options: [.curveEaseOut]) {
self.centerPlayPauseButton.alpha = 0
}
return
}
centerPlayPauseButton.transform = CGAffineTransform(scaleX: 0.82, y: 0.82)
UIView.animate(withDuration: 0.16, animations: {
self.centerPlayPauseButton.alpha = 1
self.centerPlayPauseButton.transform = .identity
}) { _ in
UIView.animate(withDuration: 0.26, delay: 0.34, options: [.curveEaseOut]) {
self.centerPlayPauseButton.alpha = 0
}
}
+
+
+
+
+

Diffs are generated with @pierre/diffs/ssr at documentation time. Each file diff stays inside its own shell so the page remains readable as a static HTML artifact.

+
+ +
+

Expected Impact for End-Users

+

Users should be able to quickly confirm play or pause state changes from the middle of the screen, even over bright scenes or high-motion content.

+
+ +
+

Validation

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

Issues, Limitations, and Mitigations

+
    +
  • No known issues. This is a visual contrast adjustment scoped to the native player overlay.
  • +
+
+ +
+

New Changes as of 2026-05-26 22:22

+

Summary of changes

+

Softened the center play/pause flash from a black badge with a strong outline into a separate liquid-glass blur plate behind a clear icon button.

+

Why this change was made

+

The previous contrast fix made the transient center indicator visible, but overcorrected with a heavy black fill and outline. The revised treatment keeps the indicator readable while better matching the native Liquid Glass direction.

+

Code diffs

+
+
Dreamio/NativePlayerViewController.swift — center indicator glass treatment
+
+ private let centerPlayPauseIndicator = NativePlayerViewController.centerGlassIndicator()
+...
++ view.addSubview(centerPlayPauseIndicator)
+  view.addSubview(centerPlayPauseButton)
+...
+- centerPlayPauseButton.backgroundColor = UIColor.black.withAlphaComponent(0.58)
+- centerPlayPauseButton.layer.borderColor = UIColor.white.withAlphaComponent(0.38).cgColor
+- centerPlayPauseButton.layer.borderWidth = 1.5
++ centerPlayPauseButton.backgroundColor = .clear
++ centerPlayPauseButton.layer.borderWidth = 0
++ centerPlayPauseIndicator.alpha = 0
+...
++ private static func centerGlassIndicator() -> UIVisualEffectView {
++     let view = glassPanel(cornerRadius: 34)
++     view.backgroundColor = UIColor.white.withAlphaComponent(0.08)
++     view.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor
++     view.layer.borderWidth = 0.75
++     view.isUserInteractionEnabled = false
++     return view
++ }
+
+

Related issues or PRs

+

No new Beads issue or PR was created for this small visual follow-up.

+
+ +
+

New Changes as of 2026-05-26 22:56

+

Summary of changes

+

Removed the positional/scale reveal motion from the native player controls. Tapping to show or hide controls now only fades the controls and close button in or out.

+

Why this change was made

+

The controls reveal animation felt overdone for a transient playback overlay. A simple opacity transition keeps the UI calmer while preserving clear visibility.

+

Code diffs

+
+
Dreamio/NativePlayerViewController.swift — controls tap animation
+
- let animations = {
+-     self.controlsContainer.alpha = 1
+-     self.closeButton.alpha = 1
+-     self.controlsContainer.transform = .identity
+- }
+- if UIAccessibility.isReduceMotionEnabled {
+-     animations()
+- } else {
+-     controlsContainer.transform = CGAffineTransform(translationX: 0, y: 8).scaledBy(x: 0.98, y: 0.98)
+-     UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut], animations: animations)
+- }
++ controlsContainer.transform = .identity
++ UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) {
++     self.controlsContainer.alpha = 1
++     self.closeButton.alpha = 1
++ }
+
+

Related issues or PRs

+

No new Beads issue or PR was created for this focused playback UI adjustment.

+
+ +
+

Follow-up Work

+
    +
  • None currently identified.
  • +
+
+
+
+ + diff --git a/docs/turns/2026-05-26-native-player-liquid-glass-ux.html b/docs/turns/2026-05-26-native-player-liquid-glass-ux.html new file mode 100644 index 0000000..1c8cce9 --- /dev/null +++ b/docs/turns/2026-05-26-native-player-liquid-glass-ux.html @@ -0,0 +1,189 @@ + + + + + + Native Player Liquid Glass UX + + +

Dreamio turn document

Native Player Liquid Glass UX

The native VLC player received a fuller control experience: Liquid Glass-backed panels on iOS 26, larger controls, clearer loading and failure states, scrubbing feedback, double-tap seeking, menu polish, iPad width adaptation, and accessibility improvements.

2026-05-26Beads issue dreamio-060Native player UI
+

Summary

Implemented the remaining player UX plan in Dreamio/NativePlayerViewController.swift, keeping the VLC backend contract intact while improving the control surface and user feedback around playback.

+

Changes Made

  • Added an availability-gated UIGlassEffect panel helper with blur fallback for older iOS versions.
  • Restructured overlay elements into bottom controls, centered transient play/pause feedback, glass loading pill, and glass failure card.
  • Raised touch targets to 44 points and made play/pause the dominant 56-point action.
  • Added scrub target bubble, larger active thumb, haptics, and double-tap left/right seek gestures.
  • Extended auto-hide timing, added a bottom contrast scrim, improved audio/subtitle menu labels, and added reset subtitle delay.
  • Added retry/close failure actions, iPad wider controls, reduced-motion-aware animation, accessibility hints, scrubber value, and custom accessibility jump actions.
+

Context

The prior baseline was compact and functional but used a plain blur tray, small controls, a spinner-only loading state, and a text-only failure state. This pass keeps the same native playback architecture while making the player feel more intentional and touch-friendly.

+

Important Implementation Details

  • Liquid Glass is gated behind #available(iOS 26.0, *); older systems keep systemUltraThinMaterialDark.
  • No backend API changes were required; scrub previews are timestamp-only because the backend does not expose preview frames.
  • Auto-hide now avoids hiding during scrubbing, loading, or failure states and uses 4.5 seconds for more comfortable interaction.
  • Retry restarts the same native playback request and resets cached menu signatures.
+

Relevant Diff Snippets

Dreamio/NativePlayerViewController.swift · player controls UX pass

Dreamio/NativePlayerViewController.swift
-60+341
10 unmodified lines
11
12
13
14
15
16
17
18
7 unmodified lines
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
2 unmodified lines
50
51
52
53
54
55
56
57
58
59
60
27 unmodified lines
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
31 unmodified lines
136
137
138
139
140
141
142
143
144
67 unmodified lines
212
213
214
215
216
217
218
22 unmodified lines
241
242
243
244
245
246
4 unmodified lines
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
1 unmodified line
270
271
272
273
274
275
21 unmodified lines
297
298
299
300
301
302
5 unmodified lines
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
5 unmodified lines
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
30 unmodified lines
428
429
430
431
432
433
434
6 unmodified lines
441
442
443
444
445
446
1 unmodified line
448
449
450
451
452
453
454
23 unmodified lines
478
479
480
481
482
483
484
14 unmodified lines
499
500
501
502
503
504
9 unmodified lines
514
515
516
517
518
519
14 unmodified lines
534
535
536
537
538
539
27 unmodified lines
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
1 unmodified line
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
10 unmodified lines
private var attachedSubtitleURLs: Set<URL>
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
var onDismiss: (() -> Void)?
+
private let loadingView: UIActivityIndicatorView = {
let view = UIActivityIndicatorView(style: .large)
view.translatesAutoresizingMaskIntoConstraints = false
7 unmodified lines
button.translatesAutoresizingMaskIntoConstraints = false
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
}()
+
private let controlsContainer: UIVisualEffectView = {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 22
view.clipsToBounds = true
view.backgroundColor = UIColor.white.withAlphaComponent(0.08)
view.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor
view.layer.borderWidth = 1
return view
}()
+
private let tapSurfaceView: UIView = {
let view = UIView()
2 unmodified lines
return view
}()
+
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")
private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds")
private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")
private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Tracks")
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions")
+
private let elapsedLabel: UILabel = {
let label = UILabel()
27 unmodified lines
return slider
}()
+
private let failureLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.textAlignment = .center
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .body)
label.isHidden = true
return label
}()
+
init(
request: NativePlaybackRequest,
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
31 unmodified lines
view.backgroundColor = .black
configureBackend()
configureLayout()
startStartupTimer()
backend.play(request: request)
addSubtitleCandidates(request.subtitleCandidates)
}
+
@discardableResult
67 unmodified lines
DispatchQueue.main.async {
self?.startupTimer?.invalidate()
self?.loadingView.stopAnimating()
self?.loadingView.isHidden = true
self?.startProgressUpdates()
self?.refreshControls()
self?.scheduleControlsHide()
22 unmodified lines
}
}
+
private func startStartupTimer() {
startupTimer?.invalidate()
startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in
4 unmodified lines
+
private func configureLayout() {
view.addSubview(backend.view)
view.addSubview(tapSurfaceView)
view.addSubview(loadingView)
view.addSubview(failureLabel)
view.addSubview(controlsContainer)
view.addSubview(closeButton)
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
audioButton.showsMenuAsPrimaryAction = true
captionsButton.showsMenuAsPrimaryAction = true
playPauseButton.layer.cornerRadius = 24
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])
1 unmodified line
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))
tap.cancelsTouchesInView = false
tapSurfaceView.addGestureRecognizer(tap)
+
let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])
timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false
21 unmodified lines
stack.spacing = 6
controlsContainer.contentView.addSubview(stack)
+
NSLayoutConstraint.activate([
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
5 unmodified lines
tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),
tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+
failureLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 28),
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),
5 unmodified lines
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),
audioButton.widthAnchor.constraint(equalToConstant: 36),
audioButton.heightAnchor.constraint(equalToConstant: 36),
playbackCluster.centerXAnchor.constraint(equalTo: controlRow.centerXAnchor),
captionsButton.widthAnchor.constraint(equalToConstant: 36),
captionsButton.heightAnchor.constraint(equalToConstant: 36)
])
}
+
private func showFailure(_ error: Error) {
loadingView.stopAnimating()
loadingView.isHidden = true
failureLabel.text = "Native playback could not start.\n\(error.localizedDescription)"
failureLabel.isHidden = false
#if DEBUG
print("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))")
#endif
}
+
@objc private func close() {
dismiss(animated: true)
}
+
@objc private func togglePlayPause() {
backend.togglePlayPause()
revealControls()
}
+
@objc private func jumpBack() {
backend.jump(by: -15)
revealControls()
}
+
@objc private func jumpForward() {
backend.jump(by: 15)
revealControls()
}
+
@objc private func scrubbingStarted() {
isScrubbing = true
controlsTimer?.invalidate()
}
+
@objc private func scrubberChanged() {
elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)
}
+
@objc private func scrubbingEnded() {
backend.seek(to: scrubber.value)
isScrubbing = false
revealControls()
}
+
@objc private func toggleControlsVisibility() {
if controlsContainer.alpha < 1 {
revealControls()
30 unmodified lines
}
+
let delayActions = UIMenu(
title: "Delay",
options: .displayInline,
children: [
UIAction(title: "Decrease 0.5s") { [weak self] _ in
6 unmodified lines
self?.captionsMenuSignature = nil
self?.refreshControls()
},
UIAction(
title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",
attributes: .disabled
1 unmodified line
]
)
+
return UIMenu(title: "Captions", children: trackActions + [delayActions])
}
+
private func audioMenu() -> UIMenu {
23 unmodified lines
}
}
+
return UIMenu(title: "Audio", children: trackActions)
}
+
private func startProgressUpdates() {
14 unmodified lines
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
if !isScrubbing {
scrubber.value = backend.position
}
9 unmodified lines
let hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1
audioButton.isEnabled = hasSelectableTrack
audioButton.alpha = hasSelectableTrack ? 1 : 0.45
guard signature != audioMenuSignature else {
return
}
14 unmodified lines
)
let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }
captionsButton.isEnabled = hasSelectableTrack
guard signature != captionsMenuSignature else {
return
}
27 unmodified lines
private func revealControls() {
controlsContainer.isUserInteractionEnabled = true
closeButton.isUserInteractionEnabled = true
UIView.animate(withDuration: 0.18) {
self.controlsContainer.alpha = 1
self.closeButton.alpha = 1
}
scheduleControlsHide()
}
+
private func hideControls() {
controlsContainer.isUserInteractionEnabled = false
closeButton.isUserInteractionEnabled = false
UIView.animate(withDuration: 0.24) {
self.controlsContainer.alpha = 0
self.closeButton.alpha = 0
}
1 unmodified line
+
private func scheduleControlsHide() {
controlsTimer?.invalidate()
guard backend.isPlaying else {
return
}
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
self?.hideControls()
}
}
+
private static func iconButton(systemName: String, label: String) -> UIButton {
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
button.setImage(UIImage(systemName: systemName), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.white.withAlphaComponent(0.12)
button.layer.cornerRadius = 18
button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor
button.layer.borderWidth = 1
button.accessibilityLabel = label
return button
}
+
private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = UIScreen.main.scale
10 unmodified lines
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
7 unmodified lines
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
2 unmodified lines
71
72
73
74
75
76
77
78
79
80
81
82
83
27 unmodified lines
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
31 unmodified lines
192
193
194
195
196
197
198
199
200
201
202
203
204
205
67 unmodified lines
273
274
275
276
277
278
279
22 unmodified lines
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
4 unmodified lines
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
1 unmodified line
353
354
355
356
357
358
359
360
361
362
363
364
365
366
21 unmodified lines
388
389
390
391
392
393
394
395
5 unmodified lines
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
5 unmodified lines
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
30 unmodified lines
575
576
577
578
579
580
581
6 unmodified lines
588
589
590
591
592
593
594
595
596
597
598
599
1 unmodified line
601
602
603
604
605
606
607
23 unmodified lines
631
632
633
634
635
636
637
14 unmodified lines
652
653
654
655
656
657
658
9 unmodified lines
668
669
670
671
672
673
674
14 unmodified lines
689
690
691
692
693
694
695
696
27 unmodified lines
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
1 unmodified line
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
10 unmodified lines
private var attachedSubtitleURLs: Set<URL>
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
private var controlsMaximumWidthConstraint: NSLayoutConstraint?
private let bottomScrimLayer = CAGradientLayer()
var onDismiss: (() -> Void)?
+
private let loadingContainer: UIVisualEffectView = {
let view = NativePlayerViewController.glassPanel(cornerRadius: 24)
view.isHidden = false
return view
}()
+
private let loadingStack: UIStackView = {
let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 12
return stack
}()
+
private let loadingTextLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Opening stream…"
label.textColor = .white
label.font = .preferredFont(forTextStyle: .subheadline)
label.adjustsFontForContentSizeCategory = true
return label
}()
+
private let loadingView: UIActivityIndicatorView = {
let view = UIActivityIndicatorView(style: .large)
view.translatesAutoresizingMaskIntoConstraints = false
7 unmodified lines
button.translatesAutoresizingMaskIntoConstraints = false
button.setImage(UIImage(systemName: "xmark"), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.white.withAlphaComponent(0.14)
button.layer.cornerRadius = 22
button.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColor
button.layer.borderWidth = 1
button.accessibilityLabel = "Close"
button.accessibilityHint = "Closes native playback and returns to Stremio."
return button
}()
+
private let controlsContainer = NativePlayerViewController.glassPanel(cornerRadius: 26)
+
private let tapSurfaceView: UIView = {
let view = UIView()
2 unmodified lines
return view
}()
+
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause", pointSize: 24)
private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds")
private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")
private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Track")
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Subtitles")
+
private let centerPlayPauseButton = NativePlayerViewController.iconButton(systemName: "play.fill", label: "Toggle Playback", pointSize: 34)
+
private let elapsedLabel: UILabel = {
let label = UILabel()
27 unmodified lines
return slider
}()
+
private let scrubTimeBubble: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.textAlignment = .center
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .bold)
label.backgroundColor = UIColor.black.withAlphaComponent(0.56)
label.layer.cornerRadius = 14
label.clipsToBounds = true
label.alpha = 0
return label
}()
+
private let failureContainer: UIVisualEffectView = {
let view = NativePlayerViewController.glassPanel(cornerRadius: 28)
view.isHidden = true
return view
}()
+
private let failureTitleLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Playback could not start"
label.textColor = .white
label.textAlignment = .center
label.font = .preferredFont(forTextStyle: .headline)
label.adjustsFontForContentSizeCategory = true
return label
}()
+
private let failureDetailLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = UIColor.white.withAlphaComponent(0.82)
label.textAlignment = .center
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .subheadline)
label.adjustsFontForContentSizeCategory = true
return label
}()
+
private let retryButton: UIButton = NativePlayerViewController.textButton(title: "Retry")
private let failureCloseButton: UIButton = NativePlayerViewController.textButton(title: "Close")
+
init(
request: NativePlaybackRequest,
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
31 unmodified lines
view.backgroundColor = .black
configureBackend()
configureLayout()
configureAccessibility()
startPlayback()
}
+
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
bottomScrimLayer.frame = view.bounds
updateLayoutForCurrentSize()
}
+
@discardableResult
67 unmodified lines
DispatchQueue.main.async {
self?.startupTimer?.invalidate()
self?.loadingView.stopAnimating()
self?.loadingContainer.isHidden = true
self?.startProgressUpdates()
self?.refreshControls()
self?.scheduleControlsHide()
22 unmodified lines
}
}
+
private func startPlayback() {
loadingContainer.isHidden = false
loadingView.startAnimating()
failureContainer.isHidden = true
startStartupTimer()
backend.play(request: request)
addSubtitleCandidates(request.subtitleCandidates)
revealControls()
}
+
private func startStartupTimer() {
startupTimer?.invalidate()
startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in
4 unmodified lines
+
private func configureLayout() {
view.addSubview(backend.view)
configureBottomScrim()
view.addSubview(tapSurfaceView)
view.addSubview(loadingContainer)
view.addSubview(failureContainer)
view.addSubview(centerPlayPauseButton)
view.addSubview(controlsContainer)
view.addSubview(scrubTimeBubble)
view.addSubview(closeButton)
loadingStack.addArrangedSubview(loadingView)
loadingStack.addArrangedSubview(loadingTextLabel)
loadingContainer.contentView.addSubview(loadingStack)
configureFailureCard()
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
centerPlayPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryPlayback), for: .touchUpInside)
failureCloseButton.addTarget(self, action: #selector(close), for: .touchUpInside)
audioButton.showsMenuAsPrimaryAction = true
captionsButton.showsMenuAsPrimaryAction = true
playPauseButton.layer.cornerRadius = 28
centerPlayPauseButton.layer.cornerRadius = 34
centerPlayPauseButton.alpha = 0
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])
1 unmodified line
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))
tap.cancelsTouchesInView = false
tapSurfaceView.addGestureRecognizer(tap)
let leftDoubleTap = UITapGestureRecognizer(target: self, action: #selector(handleLeftDoubleTap))
leftDoubleTap.numberOfTapsRequired = 2
let rightDoubleTap = UITapGestureRecognizer(target: self, action: #selector(handleRightDoubleTap))
rightDoubleTap.numberOfTapsRequired = 2
tap.require(toFail: leftDoubleTap)
tap.require(toFail: rightDoubleTap)
tapSurfaceView.addGestureRecognizer(leftDoubleTap)
tapSurfaceView.addGestureRecognizer(rightDoubleTap)
+
let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])
timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false
21 unmodified lines
stack.spacing = 6
controlsContainer.contentView.addSubview(stack)
+
controlsMaximumWidthConstraint = controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430)
+
NSLayoutConstraint.activate([
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
5 unmodified lines
tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),
tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
loadingContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor),
loadingStack.leadingAnchor.constraint(equalTo: loadingContainer.contentView.leadingAnchor, constant: 18),
loadingStack.trailingAnchor.constraint(equalTo: loadingContainer.contentView.trailingAnchor, constant: -18),
loadingStack.topAnchor.constraint(equalTo: loadingContainer.contentView.topAnchor, constant: 14),
loadingStack.bottomAnchor.constraint(equalTo: loadingContainer.contentView.bottomAnchor, constant: -14),
+
failureContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
failureContainer.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
failureContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -40),
failureContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 420),
+
centerPlayPauseButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
centerPlayPauseButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
centerPlayPauseButton.widthAnchor.constraint(equalToConstant: 68),
centerPlayPauseButton.heightAnchor.constraint(equalToConstant: 68),
+
closeButton.widthAnchor.constraint(equalToConstant: 44),
closeButton.heightAnchor.constraint(equalToConstant: 44),
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),
controlsMaximumWidthConstraint!,
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
+
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),
5 unmodified lines
remainingLabel.widthAnchor.constraint(equalToConstant: 42),
scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
+
scrubTimeBubble.centerXAnchor.constraint(equalTo: controlsContainer.centerXAnchor),
scrubTimeBubble.bottomAnchor.constraint(equalTo: controlsContainer.topAnchor, constant: -8),
scrubTimeBubble.widthAnchor.constraint(greaterThanOrEqualToConstant: 64),
scrubTimeBubble.heightAnchor.constraint(equalToConstant: 28),
+
jumpBackButton.widthAnchor.constraint(equalToConstant: 44),
jumpBackButton.heightAnchor.constraint(equalToConstant: 44),
playPauseButton.widthAnchor.constraint(equalToConstant: 56),
playPauseButton.heightAnchor.constraint(equalToConstant: 56),
jumpForwardButton.widthAnchor.constraint(equalToConstant: 44),
jumpForwardButton.heightAnchor.constraint(equalToConstant: 44),
audioButton.widthAnchor.constraint(equalToConstant: 44),
audioButton.heightAnchor.constraint(equalToConstant: 44),
playbackCluster.centerXAnchor.constraint(equalTo: controlRow.centerXAnchor),
captionsButton.widthAnchor.constraint(equalToConstant: 44),
captionsButton.heightAnchor.constraint(equalToConstant: 44)
])
}
+
private func showFailure(_ error: Error) {
controlsTimer?.invalidate()
loadingView.stopAnimating()
loadingContainer.isHidden = true
controlsContainer.alpha = 0
controlsContainer.isUserInteractionEnabled = false
failureDetailLabel.text = error.localizedDescription
failureContainer.isHidden = false
closeButton.alpha = 1
closeButton.isUserInteractionEnabled = true
#if DEBUG
print("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))")
#endif
}
+
@objc private func retryPlayback() {
backend.stop()
progressTimer?.invalidate()
audioMenuSignature = nil
captionsMenuSignature = nil
startPlayback()
}
+
@objc private func close() {
dismiss(animated: true)
}
+
@objc private func togglePlayPause() {
backend.togglePlayPause()
flashCenterPlayPauseIcon()
revealControls()
}
+
@objc private func jumpBack() {
backend.jump(by: -15)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
revealControls()
}
+
@objc private func jumpForward() {
backend.jump(by: 15)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
revealControls()
}
+
@objc private func scrubbingStarted() {
isScrubbing = true
controlsTimer?.invalidate()
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 20), for: .normal)
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 22), for: .highlighted)
UISelectionFeedbackGenerator().selectionChanged()
updateScrubPreview()
UIView.animate(withDuration: 0.16) {
self.scrubTimeBubble.alpha = 1
}
}
+
@objc private func scrubberChanged() {
updateScrubPreview()
}
+
@objc private func scrubbingEnded() {
backend.seek(to: scrubber.value)
isScrubbing = false
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 14), for: .normal)
scrubber.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 18), for: .highlighted)
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
UIView.animate(withDuration: 0.18) {
self.scrubTimeBubble.alpha = 0
}
revealControls()
}
+
@objc private func handleLeftDoubleTap(_ recognizer: UITapGestureRecognizer) {
guard recognizer.location(in: tapSurfaceView).x < tapSurfaceView.bounds.midX else { return }
jumpBack()
}
+
@objc private func handleRightDoubleTap(_ recognizer: UITapGestureRecognizer) {
guard recognizer.location(in: tapSurfaceView).x >= tapSurfaceView.bounds.midX else { return }
jumpForward()
}
+
@objc private func toggleControlsVisibility() {
if controlsContainer.alpha < 1 {
revealControls()
30 unmodified lines
}
+
let delayActions = UIMenu(
title: "Subtitle Delay",
options: .displayInline,
children: [
UIAction(title: "Decrease 0.5s") { [weak self] _ in
6 unmodified lines
self?.captionsMenuSignature = nil
self?.refreshControls()
},
UIAction(title: "Reset Delay") { [weak self] _ in
guard let self else { return }
self.backend.adjustSubtitleDelay(by: -self.backend.subtitleDelay)
self.captionsMenuSignature = nil
self.refreshControls()
},
UIAction(
title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",
attributes: .disabled
1 unmodified line
]
)
+
return UIMenu(title: "Subtitles", children: trackActions + [delayActions])
}
+
private func audioMenu() -> UIMenu {
23 unmodified lines
}
}
+
return UIMenu(title: "Audio Track", children: trackActions)
}
+
private func startProgressUpdates() {
14 unmodified lines
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
scrubber.accessibilityValue = "\(elapsedLabel.text ?? "0:00") elapsed, \(remainingLabel.text ?? "-0:00") remaining"
if !isScrubbing {
scrubber.value = backend.position
}
9 unmodified lines
let hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1
audioButton.isEnabled = hasSelectableTrack
audioButton.alpha = hasSelectableTrack ? 1 : 0.45
audioButton.accessibilityHint = hasSelectableTrack ? "Opens available audio tracks." : "Only one audio track is available."
guard signature != audioMenuSignature else {
return
}
14 unmodified lines
)
let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }
captionsButton.isEnabled = hasSelectableTrack
captionsButton.alpha = hasSelectableTrack ? 1 : 0.45
captionsButton.accessibilityHint = hasSelectableTrack ? "Opens subtitle tracks and delay controls." : "No subtitle tracks are available yet."
guard signature != captionsMenuSignature else {
return
}
27 unmodified lines
private func revealControls() {
controlsContainer.isUserInteractionEnabled = true
closeButton.isUserInteractionEnabled = true
let animations = {
self.controlsContainer.alpha = 1
self.closeButton.alpha = 1
self.controlsContainer.transform = .identity
}
if UIAccessibility.isReduceMotionEnabled {
animations()
} else {
controlsContainer.transform = CGAffineTransform(translationX: 0, y: 8).scaledBy(x: 0.98, y: 0.98)
UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut], animations: animations)
}
scheduleControlsHide()
}
+
private func hideControls() {
guard !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else { return }
controlsContainer.isUserInteractionEnabled = false
closeButton.isUserInteractionEnabled = false
UIView.animate(withDuration: 0.28, delay: 0, options: [.curveEaseInOut]) {
self.controlsContainer.alpha = 0
self.closeButton.alpha = 0
}
1 unmodified line
+
private func scheduleControlsHide() {
controlsTimer?.invalidate()
guard backend.isPlaying, !isScrubbing, failureContainer.isHidden, loadingContainer.isHidden else {
return
}
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.5, repeats: false) { [weak self] _ in
self?.hideControls()
}
}
+
private func configureBottomScrim() {
bottomScrimLayer.colors = [
UIColor.clear.cgColor,
UIColor.black.withAlphaComponent(0.12).cgColor,
UIColor.black.withAlphaComponent(0.48).cgColor
]
bottomScrimLayer.locations = [0, 0.58, 1]
view.layer.insertSublayer(bottomScrimLayer, above: backend.view.layer)
}
+
private func configureFailureCard() {
let buttonRow = UIStackView(arrangedSubviews: [failureCloseButton, retryButton])
buttonRow.translatesAutoresizingMaskIntoConstraints = false
buttonRow.axis = .horizontal
buttonRow.distribution = .fillEqually
buttonRow.spacing = 10
+
let stack = UIStackView(arrangedSubviews: [failureTitleLabel, failureDetailLabel, buttonRow])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.alignment = .fill
stack.spacing = 14
failureContainer.contentView.addSubview(stack)
+
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: failureContainer.contentView.leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(equalTo: failureContainer.contentView.trailingAnchor, constant: -20),
stack.topAnchor.constraint(equalTo: failureContainer.contentView.topAnchor, constant: 20),
stack.bottomAnchor.constraint(equalTo: failureContainer.contentView.bottomAnchor, constant: -20),
retryButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
failureCloseButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
])
}
+
private func configureAccessibility() {
scrubber.accessibilityLabel = "Playback Position"
scrubber.accessibilityHint = "Adjusts the playback position."
playPauseButton.accessibilityHint = "Toggles playback."
jumpBackButton.accessibilityHint = "Rewinds 15 seconds when seeking is available."
jumpForwardButton.accessibilityHint = "Skips ahead 15 seconds when seeking is available."
view.accessibilityCustomActions = [
UIAccessibilityCustomAction(name: "Jump Back 15 Seconds", target: self, selector: #selector(accessibilityJumpBack)),
UIAccessibilityCustomAction(name: "Jump Forward 15 Seconds", target: self, selector: #selector(accessibilityJumpForward))
]
}
+
@objc private func accessibilityJumpBack() -> Bool {
jumpBack()
return true
}
+
@objc private func accessibilityJumpForward() -> Bool {
jumpForward()
return true
}
+
private func updateLayoutForCurrentSize() {
controlsMaximumWidthConstraint?.constant = traitCollection.horizontalSizeClass == .regular ? 560 : 430
}
+
private func updateScrubPreview() {
let target = TimeInterval(scrubber.value) * backend.duration
let label = PlaybackTimeFormatter.label(for: target)
elapsedLabel.text = label
scrubTimeBubble.text = " \(label) "
}
+
private func flashCenterPlayPauseIcon() {
centerPlayPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
guard !UIAccessibility.isReduceMotionEnabled else { return }
centerPlayPauseButton.transform = CGAffineTransform(scaleX: 0.86, y: 0.86)
UIView.animate(withDuration: 0.16, animations: {
self.centerPlayPauseButton.alpha = 1
self.centerPlayPauseButton.transform = .identity
}) { _ in
UIView.animate(withDuration: 0.22, delay: 0.28, options: [.curveEaseOut]) {
self.centerPlayPauseButton.alpha = 0
}
}
}
+
private static func glassPanel(cornerRadius: CGFloat) -> UIVisualEffectView {
let effect: UIVisualEffect
if #available(iOS 26.0, *) {
let glassEffect = UIGlassEffect()
glassEffect.tintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 0.16)
glassEffect.isInteractive = true
effect = glassEffect
} else {
effect = UIBlurEffect(style: .systemUltraThinMaterialDark)
}
let view = UIVisualEffectView(effect: effect)
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = cornerRadius
view.clipsToBounds = true
view.backgroundColor = UIColor.white.withAlphaComponent(0.09)
view.layer.borderColor = UIColor.white.withAlphaComponent(0.22).cgColor
view.layer.borderWidth = 1
return view
}
+
private static func iconButton(systemName: String, label: String, pointSize: CGFloat = 20) -> UIButton {
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
let configuration = UIImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold)
button.setImage(UIImage(systemName: systemName, withConfiguration: configuration), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.white.withAlphaComponent(0.14)
button.layer.cornerRadius = 22
button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor
button.layer.borderWidth = 1
button.accessibilityLabel = label
return button
}
+
private static func textButton(title: String) -> UIButton {
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
button.titleLabel?.adjustsFontForContentSizeCategory = true
button.backgroundColor = UIColor.white.withAlphaComponent(0.16)
button.layer.cornerRadius = 14
button.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor
button.layer.borderWidth = 1
return button
}
+
private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = UIScreen.main.scale

Rendered with @pierre/diffs/ssr.

+

Expected Impact for End-Users

Users should see a more polished, readable, and forgiving native player with better touch ergonomics, clearer recovery from failures, more helpful loading feedback, and faster jump interactions.

+

Validation

  • Ran DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build.
  • Build succeeded.
+

Issues, Limitations, and Mitigations

  • Manual real-device iOS 26 Liquid Glass visual validation is still recommended because simulator build validation cannot confirm material feel over live video.
  • Scrubbing previews are timestamp-only until the backend can provide frame thumbnails.
+

Follow-up Work

  • Manual QA on iPhone/iPad portrait and landscape with seekable/non-seekable streams, audio tracks, and captions.
  • Consider future backend support for thumbnail previews during scrubbing.
+
\ No newline at end of file