Merge pull request #11 from dirtydishes/player-ui

Refine native player controls
This commit is contained in:
dirtydishes 2026-05-26 23:08:41 -04:00 committed by GitHub
commit c585c38b8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1165 additions and 59 deletions

View file

@ -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."}}

View file

@ -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}

View file

@ -11,8 +11,35 @@ final class NativePlayerViewController: UIViewController {
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
@ -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

View file

@ -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 Apples 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 5458 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.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long