add vlc local range cache

This commit is contained in:
dirtydishes 2026-05-25 18:22:09 -04:00
parent 4815c3a7f6
commit e7a80df7cc
7 changed files with 1029 additions and 18 deletions

View file

@ -38,3 +38,6 @@
{"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-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-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-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-c15b9cb9","kind":"field_change","created_at":"2026-05-25T19:07:23.629637Z","actor":"dirtydishes","issue_id":"dreamio-3yb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added centralized 30-second VLC media caching for native playback and validated the iOS build."}}
{"id":"int-e339ed64","kind":"field_change","created_at":"2026-05-25T20:22:40.999137Z","actor":"dirtydishes","issue_id":"dreamio-dsp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"implemented local native stream cache proxy with range cache tests and successful simulator build"}}
{"id":"int-79713eba","kind":"field_change","created_at":"2026-05-25T21:55:32.323229Z","actor":"dirtydishes","issue_id":"dreamio-6bv","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"handled VLC buffering follow-up by supporting HEAD probes, moving fetch work off listener queue, reducing foreground range size, and locking cache access"}}

View file

@ -1,4 +1,6 @@
{"_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-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-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-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-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} {"_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 +26,7 @@
{"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-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-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-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-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-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-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} {"_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

@ -15,7 +15,8 @@
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; }; 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; };
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; }; 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; };
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; }; 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; };
BA013CEC876B829A86AE8DCB /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; }; 6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */; };
8BC00A493F84BEC6714B8F14 /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -29,6 +30,7 @@
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = "<group>"; }; 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = "<group>"; };
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; }; 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; }; 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; };
6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveHTTPRangeCache.swift; sourceTree = "<group>"; };
701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.release.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.release.xcconfig"; sourceTree = "<group>"; }; 701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.release.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.release.xcconfig"; sourceTree = "<group>"; };
908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Dreamio.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Dreamio.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.debug.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.debug.xcconfig"; sourceTree = "<group>"; }; BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.debug.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.debug.xcconfig"; sourceTree = "<group>"; };
@ -39,6 +41,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
8BC00A493F84BEC6714B8F14 /* Pods_Dreamio.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -90,6 +93,7 @@
6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */, 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */,
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */, 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */, 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */, 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */, 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
6F2A2B392C00100100DREAMIO /* Info.plist */, 6F2A2B392C00100100DREAMIO /* Info.plist */,
@ -236,6 +240,7 @@
6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */, 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */,
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */, 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */, 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */,
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */, 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */, 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
); );

View file

@ -0,0 +1,464 @@
import Foundation
import Network
struct HTTPByteRange: Equatable {
let start: Int64
let end: Int64
var length: Int64 {
max(0, end - start + 1)
}
func overlapsOrTouches(_ other: HTTPByteRange) -> Bool {
start <= other.end + 1 && other.start <= end + 1
}
func merged(with other: HTTPByteRange) -> HTTPByteRange {
HTTPByteRange(start: min(start, other.start), end: max(end, other.end))
}
}
struct HTTPContentRange: Equatable {
let range: HTTPByteRange
let totalLength: Int64?
static func parse(_ value: String) -> HTTPContentRange? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.lowercased().hasPrefix("bytes ") else {
return nil
}
let body = trimmed.dropFirst("bytes ".count)
let pieces = body.split(separator: "/", maxSplits: 1).map(String.init)
guard pieces.count == 2 else {
return nil
}
let rangePieces = pieces[0].split(separator: "-", maxSplits: 1).map(String.init)
guard rangePieces.count == 2,
let start = Int64(rangePieces[0]),
let end = Int64(rangePieces[1]),
start >= 0,
end >= start else {
return nil
}
let total = pieces[1] == "*" ? nil : Int64(pieces[1])
return HTTPContentRange(range: HTTPByteRange(start: start, end: end), totalLength: total)
}
}
struct HTTPRangeProbeResult {
let isCacheable: Bool
let contentLength: Int64?
let fallbackReason: String?
}
final class SparseHTTPByteRangeStore {
private struct Segment {
var range: HTTPByteRange
var data: Data
}
private let lock = NSLock()
private var segments: [Segment] = []
var cachedRanges: [HTTPByteRange] {
lock.withLock {
segments.map(\.range)
}
}
func insert(data: Data, at start: Int64) {
guard !data.isEmpty else {
return
}
let insertedRange = HTTPByteRange(start: start, end: start + Int64(data.count) - 1)
lock.withLock {
segments.append(Segment(range: insertedRange, data: data))
segments.sort { $0.range.start < $1.range.start }
mergeSegments()
}
}
func data(for range: HTTPByteRange) -> Data? {
lock.withLock {
guard let firstIndex = segments.firstIndex(where: { $0.range.start <= range.start && $0.range.end >= range.start }) else {
return nil
}
var cursor = range.start
var result = Data()
for segment in segments[firstIndex...] {
guard segment.range.start <= cursor, segment.range.end >= cursor else {
break
}
let readEnd = min(segment.range.end, range.end)
let lower = Int(cursor - segment.range.start)
let upper = Int(readEnd - segment.range.start + 1)
result.append(segment.data.subdata(in: lower..<upper))
cursor = readEnd + 1
if cursor > range.end {
return result
}
}
return nil
}
}
func hasData(for range: HTTPByteRange) -> Bool {
data(for: range) != nil
}
func evict(keeping window: HTTPByteRange) {
lock.withLock {
segments.removeAll { !$0.range.overlapsOrTouches(window) }
}
}
private func mergeSegments() {
guard !segments.isEmpty else {
return
}
var merged: [Segment] = []
for segment in segments {
guard var previous = merged.popLast() else {
merged.append(segment)
continue
}
guard previous.range.overlapsOrTouches(segment.range) else {
merged.append(previous)
merged.append(segment)
continue
}
if segment.range.end > previous.range.end {
let overlap = max(0, previous.range.end - segment.range.start + 1)
if overlap < Int64(segment.data.count) {
previous.data.append(segment.data.dropFirst(Int(overlap)))
}
previous.range = previous.range.merged(with: segment.range)
}
merged.append(previous)
}
segments = merged
}
}
final class HTTPRangeRemoteFetcher {
let url: URL
let headers: [String: String]
private let session: URLSession
init(url: URL, headers: [String: String], session: URLSession = .shared) {
self.url = url
self.headers = headers
self.session = session
}
func probe() async -> HTTPRangeProbeResult {
guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "non-http-url")
}
guard !url.path.lowercased().hasSuffix(".m3u8") else {
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist")
}
if let head = try? await response(for: request(method: "HEAD")),
(200..<400).contains(head.statusCode) {
let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true
let length = header("Content-Length", in: head).flatMap(Int64.init)
if acceptsRanges, let length, length > 0 {
return HTTPRangeProbeResult(isCacheable: true, contentLength: length, fallbackReason: nil)
}
}
var tinyRequest = request(method: "GET")
tinyRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range")
do {
let (data, response) = try await session.data(for: tinyRequest)
guard let http = response as? HTTPURLResponse else {
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "probe-non-http-response")
}
guard http.statusCode == 206,
let contentRange = header("Content-Range", in: http).flatMap(HTTPContentRange.parse),
data.count <= 1 else {
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "range-probe-status-\(http.statusCode)")
}
return HTTPRangeProbeResult(isCacheable: true, contentLength: contentRange.totalLength, fallbackReason: nil)
} catch {
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "range-probe-error-\(error.localizedDescription)")
}
}
func fetch(range: HTTPByteRange) async throws -> Data {
var rangeRequest = request(method: "GET")
rangeRequest.setValue("bytes=\(range.start)-\(range.end)", forHTTPHeaderField: "Range")
let (data, response) = try await session.data(for: rangeRequest)
guard let http = response as? HTTPURLResponse else {
throw HTTPRangeCacheError.remoteRejectedRange("non-http-response")
}
guard http.statusCode == 206 else {
throw HTTPRangeCacheError.remoteRejectedRange("status-\(http.statusCode)")
}
return data
}
private func response(for request: URLRequest) async throws -> HTTPURLResponse? {
let (_, response) = try await session.data(for: request)
return response as? HTTPURLResponse
}
private func request(method: String) -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = method
headers.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
return request
}
private func header(_ name: String, in response: HTTPURLResponse) -> String? {
response.value(forHTTPHeaderField: name)
}
}
enum HTTPRangeCacheError: Error {
case remoteRejectedRange(String)
case serverUnavailable
}
final class ProgressiveHTTPRangeCacheSession {
let id = UUID().uuidString
let store = SparseHTTPByteRangeStore()
let fetcher: HTTPRangeRemoteFetcher
let contentLength: Int64
let durationProvider: () -> TimeInterval
private let prefetchChunkSize: Int64 = 1_048_576
private var prefetchTask: Task<Void, Never>?
init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {
self.fetcher = fetcher
self.contentLength = contentLength
self.durationProvider = durationProvider
}
func data(for requestedRange: HTTPByteRange) async throws -> Data {
let bounded = clamp(requestedRange)
if let data = store.data(for: bounded) {
#if DEBUG
print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")
#endif
return data
}
#if DEBUG
print("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")
#endif
let data = try await fetcher.fetch(range: bounded)
store.insert(data: data, at: bounded.start)
prefetch(aroundByteOffset: bounded.end + 1)
return store.data(for: bounded) ?? data
}
func prefetch(aroundByteOffset offset: Int64) {
prefetchTask?.cancel()
let window = targetWindow(aroundByteOffset: offset)
store.evict(keeping: window)
guard !store.hasData(for: window) else {
return
}
prefetchTask = Task { [weak self] in
guard let self else {
return
}
var cursor = window.start
while cursor <= window.end, !Task.isCancelled {
let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1))
if !store.hasData(for: chunk) {
do {
let data = try await fetcher.fetch(range: chunk)
store.insert(data: data, at: chunk.start)
#if DEBUG
print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")
#endif
} catch {
#if DEBUG
print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")
#endif
return
}
}
cursor = chunk.end + 1
}
}
}
func byteOffset(for position: Float) -> Int64 {
let clamped = max(0, min(1, position))
return Int64(Float(contentLength) * clamped)
}
private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {
let bytesPerSecond = estimatedBytesPerSecond()
let behind = max(prefetchChunkSize, bytesPerSecond * 30)
let ahead = max(prefetchChunkSize * 2, bytesPerSecond * 60)
return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))
}
private func estimatedBytesPerSecond() -> Int64 {
let duration = durationProvider()
guard duration > 1 else {
return 512_000
}
return max(1, Int64(Double(contentLength) / duration))
}
private func clamp(_ range: HTTPByteRange) -> HTTPByteRange {
HTTPByteRange(
start: max(0, min(contentLength - 1, range.start)),
end: max(0, min(contentLength - 1, range.end))
)
}
}
final class ProgressiveHTTPRangeCacheServer {
static let shared = ProgressiveHTTPRangeCacheServer()
private let queue = DispatchQueue(label: "dreamio.range-cache.server")
private var listener: NWListener?
private var port: UInt16?
private var sessions: [String: ProgressiveHTTPRangeCacheSession] = [:]
func localURL(for session: ProgressiveHTTPRangeCacheSession) throws -> URL {
try startIfNeeded()
sessions[session.id] = session
guard let port,
let url = URL(string: "http://127.0.0.1:\(port)/stream/\(session.id)") else {
throw HTTPRangeCacheError.serverUnavailable
}
return url
}
private func startIfNeeded() throws {
guard listener == nil else {
return
}
let listener = try NWListener(using: .tcp, on: .any)
listener.newConnectionHandler = { [weak self] connection in
self?.handle(connection)
}
listener.start(queue: queue)
self.listener = listener
self.port = listener.port.map { UInt16($0.rawValue) }
}
private func handle(_ connection: NWConnection) {
connection.start(queue: queue)
connection.receive(minimumIncompleteLength: 1, maximumLength: 16_384) { [weak self] data, _, _, _ in
guard let self, let data, let requestText = String(data: data, encoding: .utf8) else {
connection.cancel()
return
}
Task {
await self.respond(to: requestText, on: connection)
}
}
}
private func respond(to requestText: String, on connection: NWConnection) async {
guard let requestLine = requestText.components(separatedBy: "\r\n").first else {
send(status: "400 Bad Request", headers: [:], body: Data(), on: connection)
return
}
let parts = requestLine.split(separator: " ")
guard parts.count >= 2,
parts[0] == "GET",
let path = parts[safe: 1],
path.hasPrefix("/stream/") else {
send(status: "404 Not Found", headers: [:], body: Data(), on: connection)
return
}
let id = String(path.dropFirst("/stream/".count))
guard let session = sessions[id] else {
send(status: "404 Not Found", headers: [:], body: Data(), on: connection)
return
}
let requestedRange = parseRangeHeader(in: requestText, contentLength: session.contentLength)
?? HTTPByteRange(start: 0, end: min(session.contentLength - 1, 1_048_575))
do {
let data = try await session.data(for: requestedRange)
let headers = [
"Accept-Ranges": "bytes",
"Content-Length": "\(data.count)",
"Content-Range": "bytes \(requestedRange.start)-\(requestedRange.end)/\(session.contentLength)",
"Content-Type": "application/octet-stream",
"Connection": "close"
]
send(status: "206 Partial Content", headers: headers, body: data, on: connection)
} catch {
send(status: "502 Bad Gateway", headers: ["Connection": "close"], body: Data(), on: connection)
}
}
private func parseRangeHeader(in request: String, contentLength: Int64) -> HTTPByteRange? {
let lines = request.components(separatedBy: "\r\n")
guard let line = lines.first(where: { $0.lowercased().hasPrefix("range:") }) else {
return nil
}
let value = line.dropFirst("Range:".count).trimmingCharacters(in: .whitespaces)
guard value.lowercased().hasPrefix("bytes=") else {
return nil
}
let rangeValue = value.dropFirst("bytes=".count)
let pieces = rangeValue.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
guard pieces.count == 2,
let start = Int64(pieces[0]) else {
return nil
}
let end = pieces[1].isEmpty ? contentLength - 1 : (Int64(pieces[1]) ?? contentLength - 1)
guard start >= 0, end >= start else {
return nil
}
return HTTPByteRange(start: start, end: min(end, contentLength - 1))
}
private func send(status: String, headers: [String: String], body: Data, on connection: NWConnection) {
var response = "HTTP/1.1 \(status)\r\n"
headers.forEach { key, value in
response += "\(key): \(value)\r\n"
}
response += "\r\n"
var payload = Data(response.utf8)
payload.append(body)
connection.send(content: payload, completion: .contentProcessed { _ in
connection.cancel()
})
}
}
private extension NSLock {
func withLock<T>(_ body: () -> T) -> T {
lock()
defer { unlock() }
return body()
}
}
private extension Array {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View file

@ -23,6 +23,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
private let mediaPlayer = VLCMediaPlayer() private let mediaPlayer = VLCMediaPlayer()
#endif #endif
private var rangeCacheSession: ProgressiveHTTPRangeCacheSession?
private var playbackStartupTask: Task<Void, Never>?
private var lastLoggedState: String?
private var lastBufferingLogTime: Date?
private var attachedSubtitleURLs = Set<URL>() private var attachedSubtitleURLs = Set<URL>()
private var didAutoSelectSubtitleTrack = false private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false private var didUserSelectSubtitleTrack = false
@ -48,6 +52,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
func play(request: NativePlaybackRequest) { func play(request: NativePlaybackRequest) {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
playbackStartupTask?.cancel()
attachedSubtitleURLs.removeAll() attachedSubtitleURLs.removeAll()
didAutoSelectSubtitleTrack = false didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false didUserSelectSubtitleTrack = false
@ -56,23 +61,63 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
hasPendingExternalSubtitleSelection = false hasPendingExternalSubtitleSelection = false
pendingExternalSubtitleDisplayNames.removeAll() pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll() externalSubtitleDisplayNamesByTrackID.removeAll()
let media = VLCMedia(url: request.playbackURL) rangeCacheSession = nil
let headerValue = request.headers lastLoggedState = nil
.map { "\($0.key): \($0.value)" } lastBufferingLogTime = nil
.joined(separator: "\r\n")
media.addOption(":http-referrer=\(request.referer)")
if let userAgent = request.userAgent {
media.addOption(":http-user-agent=\(userAgent)")
}
if !headerValue.isEmpty {
media.addOption(":http-header=\(headerValue)")
}
mediaPlayer.media = media
#if DEBUG #if DEBUG
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") print("[DreamioVLC] cache-probe url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif #endif
mediaPlayer.play() playbackStartupTask = Task { [weak self] in
guard let self else {
return
}
let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)
let probe = await fetcher.probe()
guard !Task.isCancelled else {
return
}
if probe.isCacheable, let contentLength = probe.contentLength, contentLength > 0 {
do {
let session = ProgressiveHTTPRangeCacheSession(
fetcher: fetcher,
contentLength: contentLength,
durationProvider: { [weak self] in self?.duration ?? 0 }
)
let localURL = try ProgressiveHTTPRangeCacheServer.shared.localURL(for: session)
await MainActor.run {
self.rangeCacheSession = session
session.prefetch(aroundByteOffset: 0)
self.startVLCMedia(
url: localURL,
request: request,
playbackMode: "local-cache",
cachingMilliseconds: 500,
includeRemoteHTTPOptions: false
)
}
return
} catch {
#if DEBUG
print("[DreamioVLC] cache fallback reason=local-server-error-\(error)")
#endif
}
} else {
#if DEBUG
print("[DreamioVLC] cache fallback reason=\(probe.fallbackReason ?? "unknown")")
#endif
}
await MainActor.run {
self.startVLCMedia(
url: request.playbackURL,
request: request,
playbackMode: "direct",
cachingMilliseconds: 2500,
includeRemoteHTTPOptions: true
)
}
}
#else #else
onFailure?(NativePlaybackError.backendUnavailable) onFailure?(NativePlaybackError.backendUnavailable)
#endif #endif
@ -99,7 +144,16 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
guard isSeekable else { guard isSeekable else {
return return
} }
mediaPlayer.position = max(0, min(1, position)) let clamped = max(0, min(1, position))
rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: clamped) ?? 0)
#if DEBUG
if let byteOffset = rangeCacheSession?.byteOffset(for: clamped) {
print("[DreamioVLC] seek targetPosition=\(clamped) byteOffset=\(byteOffset) mode=local-cache")
} else {
print("[DreamioVLC] seek targetPosition=\(clamped) mode=direct")
}
#endif
mediaPlayer.position = clamped
#endif #endif
} }
@ -109,6 +163,17 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
return return
} }
let nextTime = max(0, min(duration, currentTime + seconds)) let nextTime = max(0, min(duration, currentTime + seconds))
if duration > 0 {
let nextPosition = Float(nextTime / duration)
rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: nextPosition) ?? 0)
#if DEBUG
if let byteOffset = rangeCacheSession?.byteOffset(for: nextPosition) {
print("[DreamioVLC] jump seconds=\(seconds) target=\(nextTime) byteOffset=\(byteOffset) mode=local-cache")
} else {
print("[DreamioVLC] jump seconds=\(seconds) target=\(nextTime) mode=direct")
}
#endif
}
mediaPlayer.time = VLCTime(int: Int32(nextTime * 1000)) mediaPlayer.time = VLCTime(int: Int32(nextTime * 1000))
#endif #endif
} }
@ -165,6 +230,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
func stop() { func stop() {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
playbackStartupTask?.cancel()
rangeCacheSession = nil
mediaPlayer.stop() mediaPlayer.stop()
mediaPlayer.drawable = nil mediaPlayer.drawable = nil
mediaPlayer.media = nil mediaPlayer.media = nil
@ -269,6 +336,40 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
} }
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
private func startVLCMedia(
url: URL,
request: NativePlaybackRequest,
playbackMode: String,
cachingMilliseconds: Int,
includeRemoteHTTPOptions: Bool
) {
let media = VLCMedia(url: url)
media.addOption(":network-caching=\(cachingMilliseconds)")
if includeRemoteHTTPOptions {
media.addOption(":http-reconnect")
addRemoteHeaders(to: media, request: request)
}
mediaPlayer.media = media
#if DEBUG
print("[DreamioVLC] opening mode=\(playbackMode) cachingMs=\(cachingMilliseconds) url=\(URLRedactor.redactedURLString(url.absoluteString))")
#endif
mediaPlayer.play()
}
private func addRemoteHeaders(to media: VLCMedia, request: NativePlaybackRequest) {
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
.joined(separator: "\r\n")
media.addOption(":http-referrer=\(request.referer)")
if let userAgent = request.userAgent {
media.addOption(":http-user-agent=\(userAgent)")
}
if !headerValue.isEmpty {
media.addOption(":http-header=\(headerValue)")
}
}
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int { private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0 var attachedCount = 0
var duplicateCount = 0 var duplicateCount = 0
@ -430,7 +531,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) { func mediaPlayerStateChanged(_ aNotification: Notification) {
#if DEBUG #if DEBUG
print("[DreamioVLC] state=\(stateName(mediaPlayer.state))") logPlaybackStateIfNeeded(stateName(mediaPlayer.state))
#endif #endif
switch mediaPlayer.state { switch mediaPlayer.state {
case .buffering, .playing: case .buffering, .playing:
@ -477,5 +578,24 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
return "unknown" return "unknown"
} }
} }
#if DEBUG
private func logPlaybackStateIfNeeded(_ state: String) {
if state == "buffering" {
let now = Date()
if lastLoggedState == state,
let lastBufferingLogTime,
now.timeIntervalSince(lastBufferingLogTime) < 2 {
return
}
lastBufferingLogTime = now
}
if lastLoggedState != state || state == "buffering" {
print("[DreamioVLC] state=\(state)")
lastLoggedState = state
}
}
#endif
} }
#endif #endif

View file

@ -24,6 +24,12 @@ struct StreamResolverTests {
testSubtitleDisplayNameNormalization() testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone() testSubtitleOptionMappingIncludesNone()
testContentRangeParsing()
testSparseRangeStoreMergesOverlaps()
testSparseRangeStoreHitPartialHitAndMiss()
testSparseRangeStoreEvictsOutsideWindow()
await testRangeProbeFallsBackWhenServerIgnoresRange()
await testRangeFetcherPreservesHeaders()
print("StreamResolverTests passed") print("StreamResolverTests passed")
} }
@ -267,6 +273,107 @@ struct StreamResolverTests {
assert(SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be attachable without another resolver hop") assert(SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be attachable without another resolver hop")
} }
private static func testContentRangeParsing() {
let parsed = HTTPContentRange.parse("bytes 10-19/100")
assertEqual(parsed?.range.start, 10)
assertEqual(parsed?.range.end, 19)
assertEqual(parsed?.totalLength, 100)
assert(HTTPContentRange.parse("items 10-19/100") == nil, "Expected non-byte content range to be rejected")
assert(HTTPContentRange.parse("bytes 20-10/100") == nil, "Expected invalid content range to be rejected")
}
private static func testSparseRangeStoreMergesOverlaps() {
let store = SparseHTTPByteRangeStore()
store.insert(data: Data([0, 1, 2, 3]), at: 0)
store.insert(data: Data([3, 4, 5]), at: 3)
assertEqual(store.cachedRanges, [HTTPByteRange(start: 0, end: 5)])
assertEqual(Array(store.data(for: HTTPByteRange(start: 0, end: 5)) ?? Data()), [0, 1, 2, 3, 4, 5])
}
private static func testSparseRangeStoreHitPartialHitAndMiss() {
let store = SparseHTTPByteRangeStore()
store.insert(data: Data([10, 11, 12, 13]), at: 10)
assertEqual(Array(store.data(for: HTTPByteRange(start: 10, end: 13)) ?? Data()), [10, 11, 12, 13])
assert(store.data(for: HTTPByteRange(start: 11, end: 14)) == nil, "Expected partial hit to miss")
assert(store.data(for: HTTPByteRange(start: 20, end: 21)) == nil, "Expected uncached range to miss")
}
private static func testSparseRangeStoreEvictsOutsideWindow() {
let store = SparseHTTPByteRangeStore()
store.insert(data: Data([0, 1, 2]), at: 0)
store.insert(data: Data([10, 11, 12]), at: 10)
store.evict(keeping: HTTPByteRange(start: 9, end: 12))
assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])
}
private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {
MockURLProtocol.handler = { request in
if request.httpMethod == "HEAD" {
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Length": "4"]
)!
return (Data(), response)
}
assertEqual(request.value(forHTTPHeaderField: "Range"), "bytes=0-0")
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Length": "4"]
)!
return (Data([1, 2, 3, 4]), response)
}
let fetcher = HTTPRangeRemoteFetcher(
url: URL(string: "https://cdn.example.test/movie.mp4")!,
headers: [:],
session: mockSession()
)
let probe = await fetcher.probe()
assertEqual(probe.isCacheable, false)
assertEqual(probe.fallbackReason, "range-probe-status-200")
}
private static func testRangeFetcherPreservesHeaders() async {
MockURLProtocol.handler = { request in
assertEqual(request.value(forHTTPHeaderField: "User-Agent"), "DreamioTest/1")
assertEqual(request.value(forHTTPHeaderField: "Referer"), "https://web.stremio.com/")
assertEqual(request.value(forHTTPHeaderField: "Cookie"), "session=abc")
assertEqual(request.value(forHTTPHeaderField: "Range"), "bytes=5-7")
let response = HTTPURLResponse(
url: request.url!,
statusCode: 206,
httpVersion: nil,
headerFields: ["Content-Range": "bytes 5-7/20"]
)!
return (Data([5, 6, 7]), response)
}
let fetcher = HTTPRangeRemoteFetcher(
url: URL(string: "https://cdn.example.test/movie.mp4")!,
headers: [
"User-Agent": "DreamioTest/1",
"Referer": "https://web.stremio.com/",
"Cookie": "session=abc"
],
session: mockSession()
)
let data = try? await fetcher.fetch(range: HTTPByteRange(start: 5, end: 7))
assertEqual(Array(data ?? Data()), [5, 6, 7])
}
private static func testOpenSubtitlesV3DownloadResponseResolution() { private static func testOpenSubtitlesV3DownloadResponseResolution() {
let payload = """ let payload = """
{ {
@ -517,6 +624,7 @@ struct StreamResolverTests {
} }
private final class MockURLProtocol: URLProtocol { private final class MockURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (Data, HTTPURLResponse))?
static var handlers: [String: (status: Int, url: URL, data: Data)] = [:] static var handlers: [String: (status: Int, url: URL, data: Data)] = [:]
override class func canInit(with request: URLRequest) -> Bool { override class func canInit(with request: URLRequest) -> Bool {
@ -528,6 +636,18 @@ private final class MockURLProtocol: URLProtocol {
} }
override func startLoading() { override func startLoading() {
if let handler = Self.handler {
do {
let (data, response) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
return
}
guard let url = request.url, guard let url = request.url,
let handler = Self.handlers[url.absoluteString], let handler = Self.handlers[url.absoluteString],
let response = HTTPURLResponse( let response = HTTPURLResponse(

File diff suppressed because one or more lines are too long