Compare commits

..

25 commits

Author SHA1 Message Date
0d0881638b update .gitignore 2026-05-25 13:45:09 -04:00
dirtydishes
25fe0d278f
Merge pull request #8 from dirtydishes/audio-track-selection
add native audio track selection
2026-05-25 13:26:19 -04:00
7e9bc35e61 add native audio track selection 2026-05-25 13:08:48 -04:00
dirtydishes
8355a0cf70
Merge pull request #7 from dirtydishes/lavender/opensubtitles
preserve opensubtitles labels through vlc subtitle tracks
2026-05-25 12:50:49 -04:00
046444f9ad preserve subtitle labels for opensubtitles tracks 2026-05-25 12:50:18 -04:00
11b9c6a12a accept stremio subtitle download urls 2026-05-25 12:34:06 -04:00
11ed364094 filter false opensubtitles subtitle candidates 2026-05-25 12:23:13 -04:00
6008272d0a fix opensubtitles manifest subtitle urls 2026-05-25 12:18:45 -04:00
07741bae96 capture opensubtitles candidates from stremio messages 2026-05-25 12:05:12 -04:00
f34d60af1b fix opensubtitles native captions 2026-05-25 11:54:14 -04:00
d3c5507763 fix stremio subtitle handoff to vlc 2026-05-25 11:33:15 -04:00
c59b318d9b quiet repeated vlc subtitle reapply logs 2026-05-25 11:09:53 -04:00
87686d16e9 update xcode workspace ui state 2026-05-25 10:56:11 -04:00
28f1dc4f8e capture opensubtitles text tracks 2026-05-25 10:51:52 -04:00
5fb7e6cc02 reapply embedded subtitle selection 2026-05-25 10:45:47 -04:00
892196421c update xcode workspace state 2026-05-25 10:44:15 -04:00
d6c445e9cb auto select embedded vlc subtitles 2026-05-25 10:38:48 -04:00
c571eb8873 trace stremio subtitle discovery 2026-05-25 10:31:54 -04:00
ff0ee65538 stabilize captions menu refresh 2026-05-25 10:26:07 -04:00
2cbe982a47 add captions selection proof logging 2026-05-25 10:19:28 -04:00
d8ebc7c7f9 add subtitle pipeline proof logging 2026-05-25 10:08:03 -04:00
fdc4444f6a resolve opensubtitles subtitle downloads 2026-05-25 09:51:02 -04:00
6a29dde857 keep stremio subtitle loads untouched 2026-05-25 07:07:59 -04:00
dc11afc45f update xcode workspace ui state 2026-05-25 07:02:22 -04:00
d6bcb52e8a forward late subtitles to native player 2026-05-25 06:43:53 -04:00
25 changed files with 7411 additions and 66 deletions

View file

@ -0,0 +1,279 @@
---
name: liquid-glass-design
description: iOS 26 Liquid Glass design system — dynamic glass material with blur, reflection, and interactive morphing for SwiftUI, UIKit, and WidgetKit.
---
# Liquid Glass Design System (iOS 26)
Patterns for implementing Apple's Liquid Glass — a dynamic material that blurs content behind it, reflects color and light from surrounding content, and reacts to touch and pointer interactions. Covers SwiftUI, UIKit, and WidgetKit integration.
## When to Activate
- Building or updating apps for iOS 26+ with the new design language
- Implementing glass-style buttons, cards, toolbars, or containers
- Creating morphing transitions between glass elements
- Applying Liquid Glass effects to widgets
- Migrating existing blur/material effects to the new Liquid Glass API
## Core Pattern — SwiftUI
### Basic Glass Effect
The simplest way to add Liquid Glass to any view:
```swift
Text("Hello, World!")
.font(.title)
.padding()
.glassEffect() // Default: regular variant, capsule shape
```
### Customizing Shape and Tint
```swift
Text("Hello, World!")
.font(.title)
.padding()
.glassEffect(.regular.tint(.orange).interactive(), in: .rect(cornerRadius: 16.0))
```
Key customization options:
- `.regular` — standard glass effect
- `.tint(Color)` — add color tint for prominence
- `.interactive()` — react to touch and pointer interactions
- Shape: `.capsule` (default), `.rect(cornerRadius:)`, `.circle`
### Glass Button Styles
```swift
Button("Click Me") { /* action */ }
.buttonStyle(.glass)
Button("Important") { /* action */ }
.buttonStyle(.glassProminent)
```
### GlassEffectContainer for Multiple Elements
Always wrap multiple glass views in a container for performance and morphing:
```swift
GlassEffectContainer(spacing: 40.0) {
HStack(spacing: 40.0) {
Image(systemName: "scribble.variable")
.frame(width: 80.0, height: 80.0)
.font(.system(size: 36))
.glassEffect()
Image(systemName: "eraser.fill")
.frame(width: 80.0, height: 80.0)
.font(.system(size: 36))
.glassEffect()
}
}
```
The `spacing` parameter controls merge distance — closer elements blend their glass shapes together.
### Uniting Glass Effects
Combine multiple views into a single glass shape with `glassEffectUnion`:
```swift
@Namespace private var namespace
GlassEffectContainer(spacing: 20.0) {
HStack(spacing: 20.0) {
ForEach(symbolSet.indices, id: \.self) { item in
Image(systemName: symbolSet[item])
.frame(width: 80.0, height: 80.0)
.glassEffect()
.glassEffectUnion(id: item < 2 ? "group1" : "group2", namespace: namespace)
}
}
}
```
### Morphing Transitions
Create smooth morphing when glass elements appear/disappear:
```swift
@State private var isExpanded = false
@Namespace private var namespace
GlassEffectContainer(spacing: 40.0) {
HStack(spacing: 40.0) {
Image(systemName: "scribble.variable")
.frame(width: 80.0, height: 80.0)
.glassEffect()
.glassEffectID("pencil", in: namespace)
if isExpanded {
Image(systemName: "eraser.fill")
.frame(width: 80.0, height: 80.0)
.glassEffect()
.glassEffectID("eraser", in: namespace)
}
}
}
Button("Toggle") {
withAnimation { isExpanded.toggle() }
}
.buttonStyle(.glass)
```
### Extending Horizontal Scrolling Under Sidebar
To allow horizontal scroll content to extend under a sidebar or inspector, ensure the `ScrollView` content reaches the leading/trailing edges of the container. The system automatically handles the under-sidebar scrolling behavior when the layout extends to the edges — no additional modifier is needed.
## Core Pattern — UIKit
### Basic UIGlassEffect
```swift
let glassEffect = UIGlassEffect()
glassEffect.tintColor = UIColor.systemBlue.withAlphaComponent(0.3)
glassEffect.isInteractive = true
let visualEffectView = UIVisualEffectView(effect: glassEffect)
visualEffectView.translatesAutoresizingMaskIntoConstraints = false
visualEffectView.layer.cornerRadius = 20
visualEffectView.clipsToBounds = true
view.addSubview(visualEffectView)
NSLayoutConstraint.activate([
visualEffectView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
visualEffectView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
visualEffectView.widthAnchor.constraint(equalToConstant: 200),
visualEffectView.heightAnchor.constraint(equalToConstant: 120)
])
// Add content to contentView
let label = UILabel()
label.text = "Liquid Glass"
label.translatesAutoresizingMaskIntoConstraints = false
visualEffectView.contentView.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: visualEffectView.contentView.centerXAnchor),
label.centerYAnchor.constraint(equalTo: visualEffectView.contentView.centerYAnchor)
])
```
### UIGlassContainerEffect for Multiple Elements
```swift
let containerEffect = UIGlassContainerEffect()
containerEffect.spacing = 40.0
let containerView = UIVisualEffectView(effect: containerEffect)
let firstGlass = UIVisualEffectView(effect: UIGlassEffect())
let secondGlass = UIVisualEffectView(effect: UIGlassEffect())
containerView.contentView.addSubview(firstGlass)
containerView.contentView.addSubview(secondGlass)
```
### Scroll Edge Effects
```swift
scrollView.topEdgeEffect.style = .automatic
scrollView.bottomEdgeEffect.style = .hard
scrollView.leftEdgeEffect.isHidden = true
```
### Toolbar Glass Integration
```swift
let favoriteButton = UIBarButtonItem(image: UIImage(systemName: "heart"), style: .plain, target: self, action: #selector(favoriteAction))
favoriteButton.hidesSharedBackground = true // Opt out of shared glass background
```
## Core Pattern — WidgetKit
### Rendering Mode Detection
```swift
struct MyWidgetView: View {
@Environment(\.widgetRenderingMode) var renderingMode
var body: some View {
if renderingMode == .accented {
// Tinted mode: white-tinted, themed glass background
} else {
// Full color mode: standard appearance
}
}
}
```
### Accent Groups for Visual Hierarchy
```swift
HStack {
VStack(alignment: .leading) {
Text("Title")
.widgetAccentable() // Accent group
Text("Subtitle")
// Primary group (default)
}
Image(systemName: "star.fill")
.widgetAccentable() // Accent group
}
```
### Image Rendering in Accented Mode
```swift
Image("myImage")
.widgetAccentedRenderingMode(.monochrome)
```
### Container Background
```swift
VStack { /* content */ }
.containerBackground(for: .widget) {
Color.blue.opacity(0.2)
}
```
## Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| GlassEffectContainer wrapping | Performance optimization, enables morphing between glass elements |
| `spacing` parameter | Controls merge distance — fine-tune how close elements must be to blend |
| `@Namespace` + `glassEffectID` | Enables smooth morphing transitions on view hierarchy changes |
| `interactive()` modifier | Explicit opt-in for touch/pointer reactions — not all glass should respond |
| UIGlassContainerEffect in UIKit | Same container pattern as SwiftUI for consistency |
| Accented rendering mode in widgets | System applies tinted glass when user selects tinted Home Screen |
## Best Practices
- **Always use GlassEffectContainer** when applying glass to multiple sibling views — it enables morphing and improves rendering performance
- **Apply `.glassEffect()` after** other appearance modifiers (frame, font, padding)
- **Use `.interactive()`** only on elements that respond to user interaction (buttons, toggleable items)
- **Choose spacing carefully** in containers to control when glass effects merge
- **Use `withAnimation`** when changing view hierarchies to enable smooth morphing transitions
- **Test across appearances** — light mode, dark mode, and accented/tinted modes
- **Ensure accessibility contrast** — text on glass must remain readable
## Anti-Patterns to Avoid
- Using multiple standalone `.glassEffect()` views without a GlassEffectContainer
- Nesting too many glass effects — degrades performance and visual clarity
- Applying glass to every view — reserve for interactive elements, toolbars, and cards
- Forgetting `clipsToBounds = true` in UIKit when using corner radii
- Ignoring accented rendering mode in widgets — breaks tinted Home Screen appearance
- Using opaque backgrounds behind glass — defeats the translucency effect
## When to Use
- Navigation bars, toolbars, and tab bars with the new iOS 26 design
- Floating action buttons and card-style containers
- Interactive controls that need visual depth and touch feedback
- Widgets that should integrate with the system's Liquid Glass appearance
- Morphing transitions between related UI states

View file

@ -13,3 +13,24 @@
{"id":"int-5d355e9b","kind":"field_change","created_at":"2026-05-25T09:51:17.04306Z","actor":"dirtydishes","issue_id":"dreamio-wgk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} {"id":"int-5d355e9b","kind":"field_change","created_at":"2026-05-25T09:51:17.04306Z","actor":"dirtydishes","issue_id":"dreamio-wgk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
{"id":"int-9ddb7b1a","kind":"field_change","created_at":"2026-05-25T10:18:30.826897Z","actor":"dirtydishes","issue_id":"dreamio-7w6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build."}} {"id":"int-9ddb7b1a","kind":"field_change","created_at":"2026-05-25T10:18:30.826897Z","actor":"dirtydishes","issue_id":"dreamio-7w6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build."}}
{"id":"int-2a84633f","kind":"field_change","created_at":"2026-05-25T10:25:22.649574Z","actor":"dirtydishes","issue_id":"dreamio-88m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation."}} {"id":"int-2a84633f","kind":"field_change","created_at":"2026-05-25T10:25:22.649574Z","actor":"dirtydishes","issue_id":"dreamio-88m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation."}}
{"id":"int-38a97132","kind":"field_change","created_at":"2026-05-25T10:43:21.805452Z","actor":"dirtydishes","issue_id":"dreamio-lw6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented late subtitle forwarding into active native playback, added VLC append path and parser tests."}}
{"id":"int-ddab585f","kind":"field_change","created_at":"2026-05-25T11:07:34.849628Z","actor":"dirtydishes","issue_id":"dreamio-8cz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation."}}
{"id":"int-e07aeefe","kind":"field_change","created_at":"2026-05-25T13:50:43.373777Z","actor":"dirtydishes","issue_id":"dreamio-h5q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation."}}
{"id":"int-c7246990","kind":"field_change","created_at":"2026-05-25T14:07:13.774172Z","actor":"dirtydishes","issue_id":"dreamio-e9p","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation."}}
{"id":"int-45781aa3","kind":"field_change","created_at":"2026-05-25T14:19:19.141163Z","actor":"dirtydishes","issue_id":"dreamio-c1m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results."}}
{"id":"int-6343b773","kind":"field_change","created_at":"2026-05-25T14:25:59.50764Z","actor":"dirtydishes","issue_id":"dreamio-bd9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build."}}
{"id":"int-26b872a1","kind":"field_change","created_at":"2026-05-25T14:31:46.83464Z","actor":"dirtydishes","issue_id":"dreamio-ese","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build."}}
{"id":"int-4e095d3f","kind":"field_change","created_at":"2026-05-25T14:38:21.968713Z","actor":"dirtydishes","issue_id":"dreamio-djc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices."}}
{"id":"int-96629c65","kind":"field_change","created_at":"2026-05-25T14:45:38.521113Z","actor":"dirtydishes","issue_id":"dreamio-ppj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing."}}
{"id":"int-027cec57","kind":"field_change","created_at":"2026-05-25T14:51:44.599319Z","actor":"dirtydishes","issue_id":"dreamio-3xi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback."}}
{"id":"int-8f943c34","kind":"field_change","created_at":"2026-05-25T15:01:35.610049Z","actor":"dirtydishes","issue_id":"dreamio-bao","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player."}}
{"id":"int-3acaadff","kind":"field_change","created_at":"2026-05-25T15:09:02.023077Z","actor":"dirtydishes","issue_id":"dreamio-h5n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations."}}
{"id":"int-c526b5ae","kind":"field_change","created_at":"2026-05-25T15:32:37.748454Z","actor":"dirtydishes","issue_id":"dreamio-dow","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation."}}
{"id":"int-320e7321","kind":"field_change","created_at":"2026-05-25T15:53:52.866657Z","actor":"dirtydishes","issue_id":"dreamio-hzj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation."}}
{"id":"int-95ad98d5","kind":"field_change","created_at":"2026-05-25T16:00:18.70354Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
{"id":"int-323d3a68","kind":"field_change","created_at":"2026-05-25T16:02:09.791701Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"fixed"}}
{"id":"int-6e411a6a","kind":"field_change","created_at":"2026-05-25T16:03:23.023525Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"in_progress","old_value":"open"}}
{"id":"int-fe1c7364","kind":"field_change","created_at":"2026-05-25T16:04:54.482803Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"fixed"}}
{"id":"int-f9deecdb","kind":"field_change","created_at":"2026-05-25T16:18:29.458162Z","actor":"dirtydishes","issue_id":"dreamio-urs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape."}}
{"id":"int-569ee372","kind":"field_change","created_at":"2026-05-25T16:22:50.024736Z","actor":"dirtydishes","issue_id":"dreamio-433","extra":{"field":"status","new_value":"closed","old_value":"in_progress","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."}}
{"id":"int-eca1f7f8","kind":"field_change","created_at":"2026-05-25T16:33:55.331041Z","actor":"dirtydishes","issue_id":"dreamio-9sp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Accepted Stremio subtitle download URLs in the bridge, parser, resolver, and regression tests."}}

View file

@ -1,3 +1,18 @@
{"_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-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-urs","title":"Fix OpenSubtitles manifest-style subtitle URLs","description":"OpenSubtitles subtitle candidates discovered from Stremio are being resolved as manifest.json_N URLs, producing 404s and leaving only embedded subtitles visible. Preserve and resolve real subtitle URLs so external subtitle tracks can attach in the native player.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:16:52Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:18:29Z","started_at":"2026-05-25T16:16:57Z","closed_at":"2026-05-25T16:18:29Z","close_reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-656","title":"Capture OpenSubtitles candidates from Stremio app-state messages","description":"OpenSubtitlesV3 appears loaded in Stremio before native playback launches, but Dreamio forwards zero external subtitle candidates. The likely failure is not native-player timing; it is that the injected WebKit bridge does not extract Stremio's loaded subtitle metadata/state into URL candidates before opening VLC.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:00:09Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:04:54Z","started_at":"2026-05-25T16:00:18Z","closed_at":"2026-05-25T16:04:54Z","close_reason":"fixed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-hzj","title":"OpenSubtitles tracks missing from native captions menu","description":"OpenSubtitles subtitle candidates can be discovered or resolved inconsistently, and external VLC subtitle slaves may not become visible quickly enough to show as selectable native caption tracks. Harden discovery, resolution, attachment, diagnostics, tests, and turn documentation for the native captions path.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:51:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:53:53Z","started_at":"2026-05-25T15:51:13Z","closed_at":"2026-05-25T15:53:53Z","close_reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-dow","title":"fix stremio external subtitle handoff to vlc","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:17:16Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:32:38Z","started_at":"2026-05-25T15:17:25Z","closed_at":"2026-05-25T15:32:38Z","close_reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-bao","title":"add native player audio track selection","description":"Add audio track discovery and selection to the native VLC-backed player so multi-language files can be filtered from the player controls.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:01:36Z","closed_at":"2026-05-25T15:01:36Z","close_reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-3xi","title":"Capture browser text tracks for OpenSubtitles V3","description":"OpenSubtitles V3 subtitles can be attached to the Stremio web player as HTML track/textTrack entries rather than appearing in the initial stream candidate. Extend the web bridge to inspect track elements and textTracks so external subtitles can be forwarded to native playback.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:49:50Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:51:45Z","started_at":"2026-05-25T14:49:52Z","closed_at":"2026-05-25T14:51:45Z","close_reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:45:38Z","started_at":"2026-05-25T14:44:18Z","closed_at":"2026-05-25T14:45:38Z","close_reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-djc","title":"Auto-select embedded VLC subtitle tracks","description":"VLC discovers embedded MKV subtitle tracks after playback starts, but Dreamio leaves subtitles disabled when no external candidates were provided. Add automatic selection for the first selectable VLC subtitle track while preserving manual caption choices.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:36:11Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:38:22Z","started_at":"2026-05-25T14:36:17Z","closed_at":"2026-05-25T14:38:22Z","close_reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-ese","title":"Discover Stremio external subtitle payloads","description":"Extend and instrument the injected web subtitle discovery path so Stremio/OpenSubtitles addon responses can be captured when native playback only sees embedded VLC subtitle tracks.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:29:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:31:47Z","started_at":"2026-05-25T14:30:03Z","closed_at":"2026-05-25T14:31:47Z","close_reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-bd9","title":"Stabilize captions menu refresh","description":"Stop rebuilding the captions UIMenu on every playback progress refresh so embedded subtitle actions can remain stable long enough to fire, while keeping DEBUG logs for menu state and selection.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:24:45Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:25:59Z","started_at":"2026-05-25T14:24:50Z","closed_at":"2026-05-25T14:25:59Z","close_reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-h5q","title":"Resolve OpenSubtitles API subtitle URLs before VLC attachment","description":"OpenSubtitles V3 can surface API/download endpoints that are not subtitle files themselves. Dreamio should resolve those endpoints to playable subtitle file URLs before handing them to VLC so Stremio does not show failed subtitle loads after native playback opens.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T13:47:17Z","created_by":"dirtydishes","updated_at":"2026-05-25T13:50:43Z","started_at":"2026-05-25T13:47:21Z","closed_at":"2026-05-25T13:50:43Z","close_reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-lw6","title":"forward late opensubtitles subtitles to native player","description":"Native playback only receives subtitle candidates discovered before the stream candidate is posted. OpenSubtitles V3 candidates can arrive later through addon/network responses, so the active native player needs an append path for newly discovered external subtitles.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:40:28Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:43:22Z","started_at":"2026-05-25T10:40:36Z","closed_at":"2026-05-25T10:43:22Z","close_reason":"Implemented late subtitle forwarding into active native playback, added VLC append path and parser tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-poo","title":"Native player controls captions and close flow","description":"Add and validate VLC-backed native playback transport controls, subtitle track controls, external subtitle discovery, and Stremio Web close cleanup after native playback dismisses.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:47:56Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:49:40Z","started_at":"2026-05-25T09:48:00Z","closed_at":"2026-05-25T09:49:40Z","close_reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-poo","title":"Native player controls captions and close flow","description":"Add and validate VLC-backed native playback transport controls, subtitle track controls, external subtitle discovery, and Stremio Web close cleanup after native playback dismisses.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:47:56Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:49:40Z","started_at":"2026-05-25T09:48:00Z","closed_at":"2026-05-25T09:49:40Z","close_reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-wgk","title":"Fix native player controls tap-to-show","description":"Native player controls can be hidden by tapping, but subsequent taps on the player do not bring them back. Investigate the overlay gesture handling and restore reliable tap-to-show/tap-to-hide behavior.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:27:58Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:51:17Z","started_at":"2026-05-25T09:28:11Z","closed_at":"2026-05-25T09:51:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-wgk","title":"Fix native player controls tap-to-show","description":"Native player controls can be hidden by tapping, but subsequent taps on the player do not bring them back. Investigate the overlay gesture handling and restore reliable tap-to-show/tap-to-hide behavior.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:27:58Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:51:17Z","started_at":"2026-05-25T09:28:11Z","closed_at":"2026-05-25T09:51:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-ija","title":"Fix MobileVLCKit linker dependency","description":"Dreamio fails to link because the MobileVLCKit framework is not found. Investigate how the dependency is configured and update the repository so the framework is available to Xcode builds.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:40:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T04:44:36Z","started_at":"2026-05-25T04:40:57Z","closed_at":"2026-05-25T04:44:36Z","close_reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ija","title":"Fix MobileVLCKit linker dependency","description":"Dreamio fails to link because the MobileVLCKit framework is not found. Investigate how the dependency is configured and update the repository so the framework is available to Xcode builds.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:40:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T04:44:36Z","started_at":"2026-05-25T04:40:57Z","closed_at":"2026-05-25T04:44:36Z","close_reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install.","dependency_count":0,"dependent_count":0,"comment_count":0}
@ -8,6 +23,9 @@
{"_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-h5n","title":"Throttle VLC subtitle reapply during buffering","description":"VLC subtitle auto-selection currently reapplies the same subtitle track on every buffering state notification, producing noisy logs and unnecessary repeated player writes. Limit state-driven reapply to meaningful selection recovery or state transitions while preserving delayed retries after initial auto-selection.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:06:48Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:09:02Z","started_at":"2026-05-25T15:06:55Z","closed_at":"2026-05-25T15:09:02Z","close_reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-c1m","title":"Add captions selection proof logging","description":"Add DEBUG-only logs around the native captions menu and VLC subtitle selection path so subtitle tap actions prove whether the UI fires and whether VLC accepts the selected embedded track index.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:18:06Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:19:19Z","started_at":"2026-05-25T14:18:11Z","closed_at":"2026-05-25T14:19:19Z","close_reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-e9p","title":"Add native subtitle pipeline proof logging","description":"Add DEBUG-only logs across the web bridge, native player, subtitle resolution, and VLC attachment points so the next Xcode run can identify where external subtitles disappear without changing playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:03:18Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:07:14Z","started_at":"2026-05-25T14:03:22Z","closed_at":"2026-05-25T14:07:14Z","close_reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-88m","title":"Make caption selection states clearer","description":"The native player caption menu should behave like a simple single-choice menu with None and loaded caption tracks, making the current caption state visually obvious.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:22:12Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:25:23Z","started_at":"2026-05-25T10:22:48Z","closed_at":"2026-05-25T10:25:23Z","close_reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-88m","title":"Make caption selection states clearer","description":"The native player caption menu should behave like a simple single-choice menu with None and loaded caption tracks, making the current caption state visually obvious.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:22:12Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:25:23Z","started_at":"2026-05-25T10:22:48Z","closed_at":"2026-05-25T10:25:23Z","close_reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-7w6","title":"Streamline native player controls","description":"Make the native playback controls take up less screen space while preserving play, seek, jump, captions, and close actions.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:15:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:18:31Z","started_at":"2026-05-25T10:15:59Z","closed_at":"2026-05-25T10:18:31Z","close_reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-7w6","title":"Streamline native player controls","description":"Make the native playback controls take up less screen space while preserving play, seek, jump, captions, and close actions.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:15:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:18:31Z","started_at":"2026-05-25T10:15:59Z","closed_at":"2026-05-25T10:18:31Z","close_reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-mj8","title":"Add native player controls and captions","description":"Implement a fuller VLC-backed native playback surface with transport controls, caption controls, external subtitle discovery, and a clean close flow back to Stremio episode selection.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:57:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T05:04:55Z","started_at":"2026-05-25T04:57:57Z","closed_at":"2026-05-25T05:04:55Z","close_reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-mj8","title":"Add native player controls and captions","description":"Implement a fuller VLC-backed native playback surface with transport controls, caption controls, external subtitle discovery, and a clean close flow back to Stremio episode selection.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:57:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T05:04:55Z","started_at":"2026-05-25T04:57:57Z","closed_at":"2026-05-25T05:04:55Z","close_reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0}

4
.gitignore vendored
View file

@ -9,3 +9,7 @@ node_modules/
# CocoaPods # CocoaPods
Pods/ Pods/
# Xcode user-specific state
*.xcuserstate
xcuserdata/

View file

@ -6,6 +6,7 @@ final class DreamioWebViewController: UIViewController {
static let stremioWebURL = URL(string: "https://web.stremio.com/")! static let stremioWebURL = URL(string: "https://web.stremio.com/")!
static let diagnosticsMessageHandler = "dreamioDiagnostics" static let diagnosticsMessageHandler = "dreamioDiagnostics"
static let streamCandidateMessageHandler = "dreamioStreamCandidate" static let streamCandidateMessageHandler = "dreamioStreamCandidate"
static let subtitleCandidateMessageHandler = "dreamioSubtitleCandidate"
} }
private lazy var webView: WKWebView = { private lazy var webView: WKWebView = {
@ -18,6 +19,10 @@ final class DreamioWebViewController: UIViewController {
WeakScriptMessageHandler(delegate: self), WeakScriptMessageHandler(delegate: self),
name: Constants.streamCandidateMessageHandler name: Constants.streamCandidateMessageHandler
) )
configuration.userContentController.add(
WeakScriptMessageHandler(delegate: self),
name: Constants.subtitleCandidateMessageHandler
)
configuration.userContentController.addUserScript(Self.streamCandidateScript) configuration.userContentController.addUserScript(Self.streamCandidateScript)
#if DEBUG #if DEBUG
configuration.userContentController.add( configuration.userContentController.add(
@ -52,6 +57,9 @@ final class DreamioWebViewController: UIViewController {
private var progressObservation: NSKeyValueObservation? private var progressObservation: NSKeyValueObservation?
private var userAgent: String? private var userAgent: String?
private var lastNativePlaybackURL: URL? private var lastNativePlaybackURL: URL?
private var pendingSubtitleCandidatesByStreamKey: [URL: [SubtitleCandidate]] = [:]
private var currentNativePlaybackKey: URL?
private weak var currentNativePlayer: NativePlayerViewController?
private let streamResolver: StreamResolving = StremioStreamResolver() private let streamResolver: StreamResolving = StremioStreamResolver()
private static let streamCandidateScript = WKUserScript( private static let streamCandidateScript = WKUserScript(
@ -73,7 +81,27 @@ final class DreamioWebViewController: UIViewController {
/\.mp4(?:[?#]|$)/i /\.mp4(?:[?#]|$)/i
]; ];
const subtitleCandidates = []; const subtitleCandidates = [];
const postedSubtitleURLs = new Set();
const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig; const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
const subtitleExtensions = new Set(["srt", "vtt", "ass", "ssa", "sub"]);
const nonSubtitleExtensions = new Set([
"aac", "avi", "bmp", "css", "gif", "heic", "ico", "jpeg", "jpg", "js", "json",
"m4a", "m4v", "mkv", "mov", "mp3", "mp4", "mpeg", "mpg", "png", "svg", "ts", "webm", "webp"
]);
const subtitleObjectKeys = [
"attributes",
"files",
"file_id",
"url",
"download",
"link",
"file",
"file_name",
"filename",
"language",
"lang"
];
const looksNative = (url) => { const looksNative = (url) => {
if (!url || typeof url !== "string") { if (!url || typeof url !== "string") {
@ -92,6 +120,76 @@ final class DreamioWebViewController: UIViewController {
} }
}; };
const isOpenSubtitlesManifestID = (url) => {
try {
const parsed = new URL(url, window.location.href);
return /opensubtitles/i.test(parsed.hostname)
&& /\/manifest\.json(?:_\d+)?$/i.test(parsed.pathname);
} catch (_) {
return false;
}
};
const isDirectSubtitleFileURL = (url) => {
try {
const parsed = new URL(url, window.location.href);
const extension = parsed.pathname.split(".").pop().toLowerCase();
return subtitleExtensions.has(extension)
|| Array.from(subtitleExtensions).some((ext) => parsed.href.toLowerCase().includes(`.${ext}?`) || parsed.href.toLowerCase().includes(`.${ext}&`));
} catch (_) {
return false;
}
};
const isProbablyNonSubtitleAssetURL = (url) => {
try {
const extension = new URL(url, window.location.href).pathname.split(".").pop().toLowerCase();
return nonSubtitleExtensions.has(extension);
} catch (_) {
return false;
}
};
const isOpenSubtitlesDownloadURL = (url) => {
try {
const parsed = new URL(url, window.location.href);
const host = parsed.hostname.toLowerCase();
const path = parsed.pathname.toLowerCase();
if (!host.includes("opensubtitles")) {
return false;
}
if (/\/manifest\.json(?:_\d+)?$/i.test(path)) {
return false;
}
return /\/api\/v1\/download(?:\/|$)/i.test(path)
|| /\/download(?:\/|$)/i.test(path)
|| /\/subtitles?(?:\/|$)/i.test(path);
} catch (_) {
return false;
}
};
const isStremioSubtitleDownloadURL = (url) => {
try {
const parsed = new URL(url, window.location.href);
const host = parsed.hostname.toLowerCase();
const path = parsed.pathname.toLowerCase();
return host === "strem.io" || host.endsWith(".strem.io")
? /\/[a-z]{2,3}\/download(?:\/|$)/i.test(path) || /\/download(?:\/|$)/i.test(path)
: false;
} catch (_) {
return false;
}
};
const isSubtitleURL = (url) => {
if (!url || isOpenSubtitlesManifestID(url)) {
return false;
}
return !isProbablyNonSubtitleAssetURL(url)
&& (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url) || isStremioSubtitleDownloadURL(url));
};
const findResolverURL = () => { const findResolverURL = () => {
const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]")); const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]"));
const match = links const match = links
@ -119,22 +217,126 @@ final class DreamioWebViewController: UIViewController {
} catch (_) {} } catch (_) {}
}; };
const addSubtitleCandidate = (entry) => { const postSubtitleCandidates = (candidates, debug = {}) => {
const rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download); const discoveredCount = candidates.length;
const url = absoluteURL(rawURL); const fresh = candidates.filter((candidate) => {
subtitleURLPattern.lastIndex = 0; const key = candidate && (candidate.url || candidate.link || candidate.download || candidate.file || candidate.file_id);
if (!url || !subtitleURLPattern.test(url)) { if (!key) {
subtitleURLPattern.lastIndex = 0; return false;
}
if (postedSubtitleURLs.has(String(key))) {
return false;
}
postedSubtitleURLs.add(String(key));
return true;
});
if (fresh.length === 0) {
try {
window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({
pageUrl: window.location.href,
subtitles: [],
debug: {
discovered: discoveredCount,
deduped: 0,
forwarded: 0,
...debug
}
});
} catch (_) {}
return;
}
try {
window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({
pageUrl: window.location.href,
subtitles: fresh,
debug: {
discovered: discoveredCount,
deduped: fresh.length,
forwarded: fresh.length,
...debug
}
});
} catch (_) {}
};
const addSubtitleCandidate = (entry) => {
const rawURL = typeof entry === "string"
? entry
: entry && (
entry.url ||
entry.href ||
entry.src ||
entry.link ||
entry.file ||
entry.download ||
entry.externalUrl ||
entry.externalURL ||
entry.fileUrl ||
entry.fileURL
);
let url = absoluteURL(rawURL);
if ((!url || isOpenSubtitlesManifestID(url)) && entry && entry.file_id) {
url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`;
}
if (!isSubtitleURL(url)) {
return; return;
} }
subtitleURLPattern.lastIndex = 0;
if (subtitleCandidates.some((candidate) => candidate.url === url)) { if (subtitleCandidates.some((candidate) => candidate.url === url)) {
return; return;
} }
subtitleCandidates.push({ const candidate = {
url, url,
label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle", label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle",
language: entry && (entry.lang || entry.language) || "" language: entry && (entry.lang || entry.language) || ""
};
subtitleCandidates.push(candidate);
postSubtitleCandidates([candidate], {
discovered: 1,
totalKnown: subtitleCandidates.length
});
};
const inspectTrack = (track) => {
if (!track) {
return;
}
if (track instanceof HTMLTrackElement) {
addSubtitleCandidate({
url: track.src || track.getAttribute("src") || "",
label: track.label || track.srclang || "External Subtitle",
language: track.srclang || ""
});
return;
}
const source = track.src || track.url || "";
if (source) {
addSubtitleCandidate({
url: source,
label: track.label || track.language || track.kind || "External Subtitle",
language: track.language || ""
});
}
};
const inspectTextTracks = (media) => {
try {
Array.from(media.textTracks || []).forEach(inspectTrack);
} catch (_) {}
try {
media.querySelectorAll("track").forEach(inspectTrack);
} catch (_) {}
};
const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => {
if (afterCount > beforeCount) {
return;
}
postSubtitleCandidates([], {
source,
inspected: true,
url: url || "",
payloadLength: payloadLength || 0,
totalKnown: subtitleCandidates.length
}); });
}; };
@ -157,16 +359,47 @@ final class DreamioWebViewController: UIViewController {
} }
if (typeof payload === "object") { if (typeof payload === "object") {
addSubtitleCandidate(payload); addSubtitleCandidate(payload);
const likelySubtitlePayload = subtitleObjectKeys.some((key) => Object.prototype.hasOwnProperty.call(payload, key));
if (likelySubtitlePayload) {
postSubtitleCandidates([payload], {
source: "payload-object",
totalKnown: subtitleCandidates.length
});
}
Object.values(payload).forEach(inspectSubtitlePayload); Object.values(payload).forEach(inspectSubtitlePayload);
} }
}; };
const inspectSubtitleText = (source, url, text) => {
const beforeCount = subtitleCandidates.length;
inspectSubtitlePayload(text);
postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0);
};
const inspectMessagePayload = (source, payload) => {
const beforeCount = subtitleCandidates.length;
inspectSubtitlePayload(payload);
postSubtitleInspection(source, "", beforeCount, subtitleCandidates.length, 0);
};
const originalFetch = window.fetch; const originalFetch = window.fetch;
if (originalFetch) { if (originalFetch) {
window.fetch = async (...args) => { window.fetch = async (...args) => {
const response = await originalFetch(...args); const response = await originalFetch(...args);
try { try {
response.clone().text().then(inspectSubtitlePayload).catch(() => {}); const contentType = response.headers && response.headers.get("content-type") || "";
const url = response.url || "";
subtitleURLPattern.lastIndex = 0;
const shouldInspect = !contentType
|| /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType)
|| subtitleURLPattern.test(url)
|| subtitleSignalPattern.test(url);
if (shouldInspect) {
subtitleURLPattern.lastIndex = 0;
response.clone().text().then((text) => {
inspectSubtitleText("fetch", url, text);
}).catch(() => {});
}
} catch (_) {} } catch (_) {}
return response; return response;
}; };
@ -175,11 +408,101 @@ final class DreamioWebViewController: UIViewController {
const originalXHRSend = XMLHttpRequest.prototype.send; const originalXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function(...args) { XMLHttpRequest.prototype.send = function(...args) {
try { try {
this.addEventListener("load", () => inspectSubtitlePayload(this.responseText)); this.addEventListener("load", () => {
try {
const responseType = this.responseType || "";
if (responseType && responseType !== "text") {
return;
}
const url = this.responseURL || "";
const text = this.responseText || "";
if (subtitleSignalPattern.test(url) || subtitleSignalPattern.test(text)) {
inspectSubtitleText("xhr", url, text);
} else {
inspectSubtitlePayload(text);
}
} catch (_) {}
});
} catch (_) {} } catch (_) {}
return originalXHRSend.apply(this, args); return originalXHRSend.apply(this, args);
}; };
const originalWindowPostMessage = window.postMessage;
if (originalWindowPostMessage) {
window.postMessage = function(message, targetOrigin, transfer) {
try { inspectMessagePayload("window.postMessage", message); } catch (_) {}
return originalWindowPostMessage.apply(this, arguments);
};
}
window.addEventListener("message", (event) => {
try { inspectMessagePayload("window.message", event.data); } catch (_) {}
}, true);
const OriginalWorker = window.Worker;
if (OriginalWorker) {
window.Worker = function(...args) {
const worker = new OriginalWorker(...args);
try {
const originalWorkerPostMessage = worker.postMessage;
worker.postMessage = function(message, transfer) {
try { inspectMessagePayload("worker.postMessage", message); } catch (_) {}
return originalWorkerPostMessage.apply(this, arguments);
};
worker.addEventListener("message", (event) => {
try { inspectMessagePayload("worker.message", event.data); } catch (_) {}
}, true);
} catch (_) {}
return worker;
};
try {
window.Worker.prototype = OriginalWorker.prototype;
} catch (_) {}
}
if (window.MessagePort && window.MessagePort.prototype) {
const originalPortPostMessage = window.MessagePort.prototype.postMessage;
if (originalPortPostMessage) {
window.MessagePort.prototype.postMessage = function(message, transfer) {
try { inspectMessagePayload("message-port.postMessage", message); } catch (_) {}
return originalPortPostMessage.apply(this, arguments);
};
}
const originalPortAddEventListener = window.MessagePort.prototype.addEventListener;
if (originalPortAddEventListener) {
window.MessagePort.prototype.addEventListener = function(type, listener, options) {
if (type === "message" && typeof listener === "function") {
const wrapped = function(event) {
try { inspectMessagePayload("message-port.message", event && event.data); } catch (_) {}
return listener.apply(this, arguments);
};
return originalPortAddEventListener.call(this, type, wrapped, options);
}
return originalPortAddEventListener.apply(this, arguments);
};
}
}
const OriginalBroadcastChannel = window.BroadcastChannel;
if (OriginalBroadcastChannel) {
window.BroadcastChannel = function(...args) {
const channel = new OriginalBroadcastChannel(...args);
try {
const originalBroadcastPostMessage = channel.postMessage;
channel.postMessage = function(message) {
try { inspectMessagePayload("broadcast-channel.postMessage", message); } catch (_) {}
return originalBroadcastPostMessage.apply(this, arguments);
};
channel.addEventListener("message", (event) => {
try { inspectMessagePayload("broadcast-channel.message", event.data); } catch (_) {}
}, true);
} catch (_) {}
return channel;
};
try {
window.BroadcastChannel.prototype = OriginalBroadcastChannel.prototype;
} catch (_) {}
}
const stopNativeHandledMedia = (element) => { const stopNativeHandledMedia = (element) => {
const media = element instanceof HTMLVideoElement const media = element instanceof HTMLVideoElement
? element ? element
@ -201,11 +524,17 @@ final class DreamioWebViewController: UIViewController {
if (!node) { if (!node) {
return; return;
} }
if (node instanceof HTMLTrackElement) {
inspectTrack(node);
}
if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) { if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) {
postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node); postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node);
} }
if (node.querySelectorAll) { if (node.querySelectorAll) {
node.querySelectorAll("video, source").forEach(inspectMedia); node.querySelectorAll("video, source, track").forEach(inspectMedia);
}
if (node instanceof HTMLVideoElement) {
inspectTextTracks(node);
} }
}; };
@ -231,11 +560,35 @@ final class DreamioWebViewController: UIViewController {
}); });
} }
const trackSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLTrackElement.prototype, "src");
if (trackSrcDescriptor && trackSrcDescriptor.set) {
Object.defineProperty(HTMLTrackElement.prototype, "src", {
get: trackSrcDescriptor.get,
set(value) {
addSubtitleCandidate({
url: value,
label: this.label || this.srclang || "External Subtitle",
language: this.srclang || ""
});
return trackSrcDescriptor.set.call(this, value);
}
});
}
const originalSetAttribute = Element.prototype.setAttribute; const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) { Element.prototype.setAttribute = function(name, value) {
if (String(name).toLowerCase() === "src" && (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement)) { if (String(name).toLowerCase() === "src") {
if (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement) {
postCandidate(value, this); postCandidate(value, this);
} }
if (this instanceof HTMLTrackElement) {
addSubtitleCandidate({
url: value,
label: this.label || this.srclang || "External Subtitle",
language: this.srclang || ""
});
}
}
return originalSetAttribute.call(this, name, value); return originalSetAttribute.call(this, name, value);
}; };
@ -243,9 +596,13 @@ final class DreamioWebViewController: UIViewController {
HTMLMediaElement.prototype.load = function() { HTMLMediaElement.prototype.load = function() {
inspectMedia(this); inspectMedia(this);
this.querySelectorAll("source").forEach(inspectMedia); this.querySelectorAll("source").forEach(inspectMedia);
inspectTextTracks(this);
return originalLoad.call(this); return originalLoad.call(this);
}; };
document.addEventListener("addtrack", (event) => {
inspectTrack(event.track || event.target);
}, true);
document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true); document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true);
document.addEventListener("error", (event) => inspectMedia(event.target), true); document.addEventListener("error", (event) => inspectMedia(event.target), true);
new MutationObserver((mutations) => { new MutationObserver((mutations) => {
@ -259,7 +616,7 @@ final class DreamioWebViewController: UIViewController {
childList: true, childList: true,
subtree: true, subtree: true,
attributes: true, attributes: true,
attributeFilter: ["src"] attributeFilter: ["src", "label", "srclang"]
}); });
inspectMedia(document); inspectMedia(document);
@ -416,24 +773,67 @@ final class DreamioWebViewController: UIViewController {
let duplicateKey = request.resolverURL ?? request.playbackURL let duplicateKey = request.resolverURL ?? request.playbackURL
if lastNativePlaybackURL == duplicateKey { if lastNativePlaybackURL == duplicateKey {
mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey)
return return
} }
lastNativePlaybackURL = duplicateKey lastNativePlaybackURL = duplicateKey
currentNativePlaybackKey = duplicateKey
mergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey)
let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey)
#if DEBUG #if DEBUG
let classification = request.classification let classification = request.classification
print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")") print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(mergedSubtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
#endif #endif
let playbackRequest = NativePlaybackRequest(
playbackURL: request.playbackURL,
observedURL: request.observedURL,
resolverURL: request.resolverURL,
pageURL: request.pageURL,
userAgent: request.userAgent,
referer: request.referer,
headers: request.headers,
classification: request.classification,
subtitleCandidates: mergedSubtitleCandidates
)
Task { [weak self] in Task { [weak self] in
await self?.resolveAndPresentNativePlayback(request) await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey)
} }
} }
private func handleSubtitleCandidates(_ candidates: [SubtitleCandidate]) {
guard !candidates.isEmpty else {
return
}
let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL
if let streamKey {
mergeSubtitleCandidates(candidates, for: streamKey)
}
#if DEBUG
print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) streamKey=\(streamKey.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none") candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
#endif
guard let currentNativePlayer else {
#if DEBUG
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)")
#endif
return
}
let forwarded = currentNativePlayer.addSubtitleCandidates(candidates)
#if DEBUG
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded) reason=active-native-player")
#endif
}
@MainActor @MainActor
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async { private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async {
guard VLCNativePlaybackBackend.isAvailable else { guard VLCNativePlaybackBackend.isAvailable else {
lastNativePlaybackURL = nil lastNativePlaybackURL = nil
currentNativePlaybackKey = nil
showNativePlaybackUnavailableAlert() showNativePlaybackUnavailableAlert()
return return
} }
@ -452,23 +852,72 @@ final class DreamioWebViewController: UIViewController {
referer: request.referer, referer: request.referer,
headers: resolved.headers, headers: resolved.headers,
classification: request.classification, classification: request.classification,
subtitleCandidates: request.subtitleCandidates subtitleCandidates: subtitleCandidates(for: streamKey)
) )
let player = NativePlayerViewController(request: resolvedRequest) let player = NativePlayerViewController(request: resolvedRequest)
player.onDismiss = { [weak self] in player.onDismiss = { [weak self] in
self?.lastNativePlaybackURL = nil self?.lastNativePlaybackURL = nil
self?.currentNativePlaybackKey = nil
self?.currentNativePlayer = nil
self?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
self?.cleanUpStremioPlayerAfterNativeDismiss() self?.cleanUpStremioPlayerAfterNativeDismiss()
} }
present(player, animated: true) present(player, animated: true) { [weak self, weak player] in
guard let self, let player else {
return
}
self.currentNativePlayer = player
let lateBufferedCandidates = self.subtitleCandidates(for: streamKey)
let forwarded = player.addSubtitleCandidates(lateBufferedCandidates)
#if DEBUG
print("[DreamioSubtitles] presented buffered=\(lateBufferedCandidates.count) forwarded=\(forwarded) streamKey=\(URLRedactor.redactedURLString(streamKey.absoluteString))")
#endif
}
} catch { } catch {
#if DEBUG #if DEBUG
print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")") print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
#endif #endif
lastNativePlaybackURL = nil lastNativePlaybackURL = nil
currentNativePlaybackKey = nil
pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
showNativePlaybackResolutionFailure(error) showNativePlaybackResolutionFailure(error)
} }
} }
private func mergeSubtitleCandidates(_ candidates: [SubtitleCandidate], for streamKey: URL) {
guard !candidates.isEmpty else {
return
}
let existing = pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
pendingSubtitleCandidatesByStreamKey[streamKey] = Self.mergedSubtitleCandidates(existing + candidates)
}
private func subtitleCandidates(for streamKey: URL) -> [SubtitleCandidate] {
pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
}
private static func mergedSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> [SubtitleCandidate] {
var orderedKeys: [String] = []
var bestByURL: [String: SubtitleCandidate] = [:]
candidates.forEach { candidate in
let key = candidate.url.absoluteString
if bestByURL[key] == nil {
orderedKeys.append(key)
bestByURL[key] = candidate
} else if let current = bestByURL[key],
subtitleCandidateScore(candidate) > subtitleCandidateScore(current) {
bestByURL[key] = candidate
}
}
return orderedKeys.compactMap { bestByURL[$0] }
}
private static func subtitleCandidateScore(_ candidate: SubtitleCandidate) -> Int {
let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != candidate.url.deletingPathExtension().lastPathComponent
return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
}
private func showNativePlaybackResolutionFailure(_ error: Error) { private func showNativePlaybackResolutionFailure(_ error: Error) {
let alert = UIAlertController( let alert = UIAlertController(
title: "Could not open stream", title: "Could not open stream",
@ -593,6 +1042,21 @@ final class DreamioWebViewController: UIViewController {
private func redactedURLString(_ value: String) -> String { private func redactedURLString(_ value: String) -> String {
URLRedactor.redactedURLString(value) URLRedactor.redactedURLString(value)
} }
private func logSubtitleBridgeMessage(_ body: Any, parsedCandidates: [SubtitleCandidate]) {
let dictionary = body as? [String: Any]
let debug = dictionary?["debug"] as? [String: Any]
let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count
let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count
let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count
let source = debug?["source"] as? String ?? "bridge"
let inspected = debug?["inspected"] as? Bool ?? false
let inspectedURL = (debug?["url"] as? String).map(redactedURLString) ?? "none"
let payloadLength = debug?["payloadLength"] as? Int ?? 0
let totalKnown = debug?["totalKnown"] as? Int ?? parsedCandidates.count
let pageURL = dictionary?["pageUrl"] as? String
print("[DreamioSubtitles] bridge source=\(source) inspected=\(inspected) discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) totalKnown=\(totalKnown) payloadLength=\(payloadLength) playerActive=\(currentNativePlayer != nil) inspectedURL=\(inspectedURL) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")
}
#endif #endif
} }
@ -669,6 +1133,15 @@ extension DreamioWebViewController: WKScriptMessageHandler {
return return
} }
if message.name == Constants.subtitleCandidateMessageHandler {
let candidates = SubtitleCandidateParser.candidates(in: message.body)
#if DEBUG
logSubtitleBridgeMessage(message.body, parsedCandidates: candidates)
#endif
handleSubtitleCandidates(candidates)
return
}
#if DEBUG #if DEBUG
guard message.name == Constants.diagnosticsMessageHandler, guard message.name == Constants.diagnosticsMessageHandler,
let body = message.body as? [String: Any], let body = message.body as? [String: Any],

View file

@ -6,12 +6,15 @@ protocol NativePlaybackBackend: AnyObject {
var onFailure: ((Error) -> Void)? { get set } var onFailure: ((Error) -> Void)? { get set }
var onStateChange: (() -> Void)? { get set } var onStateChange: (() -> Void)? { get set }
var onSubtitleTracksChange: (() -> Void)? { get set } var onSubtitleTracksChange: (() -> Void)? { get set }
var onAudioTracksChange: (() -> Void)? { get set }
var isPlaying: Bool { get } var isPlaying: Bool { get }
var isSeekable: Bool { get } var isSeekable: Bool { get }
var duration: TimeInterval { get } var duration: TimeInterval { get }
var currentTime: TimeInterval { get } var currentTime: TimeInterval { get }
var remainingTime: TimeInterval { get } var remainingTime: TimeInterval { get }
var position: Float { get } var position: Float { get }
var audioTracks: [AudioTrack] { get }
var selectedAudioTrackID: Int32 { get }
var subtitleTracks: [SubtitleTrack] { get } var subtitleTracks: [SubtitleTrack] { get }
var selectedSubtitleTrackID: Int32 { get } var selectedSubtitleTrackID: Int32 { get }
var subtitleDelay: TimeInterval { get } var subtitleDelay: TimeInterval { get }
@ -23,8 +26,11 @@ protocol NativePlaybackBackend: AnyObject {
func togglePlayPause() func togglePlayPause()
func seek(to position: Float) func seek(to position: Float)
func jump(by seconds: TimeInterval) func jump(by seconds: TimeInterval)
func selectAudioTrack(id: Int32)
func selectSubtitleTrack(id: Int32) func selectSubtitleTrack(id: Int32)
func adjustSubtitleDelay(by seconds: TimeInterval) func adjustSubtitleDelay(by seconds: TimeInterval)
@discardableResult
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int
func stop() func stop()
} }

View file

@ -3,10 +3,14 @@ import UIKit
final class NativePlayerViewController: UIViewController { final class NativePlayerViewController: UIViewController {
private let request: NativePlaybackRequest private let request: NativePlaybackRequest
private var backend: NativePlaybackBackend private var backend: NativePlaybackBackend
private let subtitleResolver: SubtitleResolving
private var startupTimer: Timer? private var startupTimer: Timer?
private var controlsTimer: Timer? private var controlsTimer: Timer?
private var progressTimer: Timer? private var progressTimer: Timer?
private var isScrubbing = false private var isScrubbing = false
private var attachedSubtitleURLs: Set<URL>
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
var onDismiss: (() -> Void)? var onDismiss: (() -> Void)?
private let loadingView: UIActivityIndicatorView = { private let loadingView: UIActivityIndicatorView = {
@ -31,8 +35,11 @@ final class NativePlayerViewController: UIViewController {
private let controlsContainer: UIVisualEffectView = { private let controlsContainer: UIVisualEffectView = {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
view.translatesAutoresizingMaskIntoConstraints = false view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 16 view.layer.cornerRadius = 22
view.clipsToBounds = true 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 return view
}() }()
@ -46,6 +53,7 @@ final class NativePlayerViewController: UIViewController {
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause") private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")
private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds") private let 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 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 captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions")
private let elapsedLabel: UILabel = { private let elapsedLabel: UILabel = {
@ -91,9 +99,15 @@ final class NativePlayerViewController: UIViewController {
return label return label
}() }()
init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) { init(
request: NativePlaybackRequest,
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
subtitleResolver: SubtitleResolving = SubtitleResolver()
) {
self.request = request self.request = request
self.backend = backend self.backend = backend
self.subtitleResolver = subtitleResolver
self.attachedSubtitleURLs = []
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen modalPresentationStyle = .fullScreen
modalTransitionStyle = .crossDissolve modalTransitionStyle = .crossDissolve
@ -124,6 +138,52 @@ final class NativePlayerViewController: UIViewController {
configureLayout() configureLayout()
startStartupTimer() startStartupTimer()
backend.play(request: request) backend.play(request: request)
addSubtitleCandidates(request.subtitleCandidates)
}
@discardableResult
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }
guard !pendingCandidates.isEmpty else {
#if DEBUG
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(backend.subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)")
#endif
return 0
}
pendingCandidates.forEach { attachedSubtitleURLs.insert($0.url) }
Task { [weak self] in
guard let self else {
return
}
let resolvedCandidates = await self.resolveSubtitleCandidates(pendingCandidates)
await MainActor.run {
guard !resolvedCandidates.isEmpty else {
#if DEBUG
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks)) selected=\(self.backend.selectedSubtitleTrackID) candidates=\(SubtitleDebugFormatter.candidateSummary(pendingCandidates))")
#endif
return
}
let attachableCandidates = resolvedCandidates.filter { candidate in
guard !self.attachedSubtitleURLs.contains(candidate.url) || pendingCandidates.contains(where: { $0.url == candidate.url }) else {
return false
}
self.attachedSubtitleURLs.insert(candidate.url)
return true
}
let attachedCount = self.backend.addSubtitleCandidates(attachableCandidates)
if attachedCount > 0 {
self.refreshControls()
}
#if DEBUG
let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attachable=\(attachableCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks)) selected=\(self.backend.selectedSubtitleTrackID) resolvedCandidates=\(SubtitleDebugFormatter.candidateSummary(resolvedCandidates))")
#endif
}
}
return pendingCandidates.count
} }
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
@ -135,6 +195,16 @@ final class NativePlayerViewController: UIViewController {
onDismiss?() onDismiss?()
} }
private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {
var resolved: [SubtitleCandidate] = []
for candidate in candidates {
if let playableCandidate = await subtitleResolver.resolve(candidate) {
resolved.append(playableCandidate)
}
}
return resolved
}
private func configureBackend() { private func configureBackend() {
backend.prepare(in: self) backend.prepare(in: self)
backend.view.translatesAutoresizingMaskIntoConstraints = false backend.view.translatesAutoresizingMaskIntoConstraints = false
@ -164,6 +234,11 @@ final class NativePlayerViewController: UIViewController {
self?.refreshControls() self?.refreshControls()
} }
} }
backend.onAudioTracksChange = { [weak self] in
DispatchQueue.main.async {
self?.refreshControls()
}
}
} }
private func startStartupTimer() { private func startStartupTimer() {
@ -185,8 +260,9 @@ final class NativePlayerViewController: UIViewController {
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside) playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside) jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside) jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
audioButton.showsMenuAsPrimaryAction = true
captionsButton.showsMenuAsPrimaryAction = true captionsButton.showsMenuAsPrimaryAction = true
playPauseButton.layer.cornerRadius = 21 playPauseButton.layer.cornerRadius = 24
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown) scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged) scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel]) scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
@ -201,12 +277,19 @@ final class NativePlayerViewController: UIViewController {
timeAndScrubRow.alignment = .center timeAndScrubRow.alignment = .center
timeAndScrubRow.spacing = 8 timeAndScrubRow.spacing = 8
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton]) let playbackCluster = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton])
playbackCluster.translatesAutoresizingMaskIntoConstraints = false
playbackCluster.axis = .horizontal
playbackCluster.alignment = .center
playbackCluster.distribution = .equalCentering
playbackCluster.spacing = 14
let controlRow = UIStackView(arrangedSubviews: [audioButton, playbackCluster, captionsButton])
controlRow.translatesAutoresizingMaskIntoConstraints = false controlRow.translatesAutoresizingMaskIntoConstraints = false
controlRow.axis = .horizontal controlRow.axis = .horizontal
controlRow.alignment = .center controlRow.alignment = .center
controlRow.distribution = .equalSpacing controlRow.distribution = .equalCentering
controlRow.spacing = 14 controlRow.spacing = 18
let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow]) let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow])
stack.translatesAutoresizingMaskIntoConstraints = false stack.translatesAutoresizingMaskIntoConstraints = false
@ -257,6 +340,9 @@ final class NativePlayerViewController: UIViewController {
playPauseButton.heightAnchor.constraint(equalToConstant: 42), playPauseButton.heightAnchor.constraint(equalToConstant: 42),
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36), jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36), jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
audioButton.widthAnchor.constraint(equalToConstant: 36),
audioButton.heightAnchor.constraint(equalToConstant: 36),
playbackCluster.centerXAnchor.constraint(equalTo: controlRow.centerXAnchor),
captionsButton.widthAnchor.constraint(equalToConstant: 36), captionsButton.widthAnchor.constraint(equalToConstant: 36),
captionsButton.heightAnchor.constraint(equalToConstant: 36) captionsButton.heightAnchor.constraint(equalToConstant: 36)
]) ])
@ -316,13 +402,28 @@ final class NativePlayerViewController: UIViewController {
private func captionsMenu() -> UIMenu { private func captionsMenu() -> UIMenu {
let selectedTrackID = backend.selectedSubtitleTrackID let selectedTrackID = backend.selectedSubtitleTrackID
let trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track in let tracks = backend.subtitleTracks
let options = SubtitleOptionMapper.options(from: tracks)
#if DEBUG
print("[DreamioCaptions] build-menu tracks=\(SubtitleDebugFormatter.trackSummary(tracks)) options=\(SubtitleDebugFormatter.trackSummary(options)) selected=\(selectedTrackID)")
#endif
let trackActions = options.map { track in
UIAction( UIAction(
title: track.name, title: track.name,
state: track.id == selectedTrackID ? .on : .off state: track.id == selectedTrackID ? .on : .off
) { [weak self] _ in ) { [weak self] _ in
self?.backend.selectSubtitleTrack(id: track.id) guard let self else {
self?.refreshControls() return
}
#if DEBUG
print("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")
#endif
self.backend.selectSubtitleTrack(id: track.id)
#if DEBUG
print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")
#endif
self.captionsMenuSignature = nil
self.refreshControls()
} }
} }
@ -332,10 +433,12 @@ final class NativePlayerViewController: UIViewController {
children: [ children: [
UIAction(title: "Decrease 0.5s") { [weak self] _ in UIAction(title: "Decrease 0.5s") { [weak self] _ in
self?.backend.adjustSubtitleDelay(by: -0.5) self?.backend.adjustSubtitleDelay(by: -0.5)
self?.captionsMenuSignature = nil
self?.refreshControls() self?.refreshControls()
}, },
UIAction(title: "Increase 0.5s") { [weak self] _ in UIAction(title: "Increase 0.5s") { [weak self] _ in
self?.backend.adjustSubtitleDelay(by: 0.5) self?.backend.adjustSubtitleDelay(by: 0.5)
self?.captionsMenuSignature = nil
self?.refreshControls() self?.refreshControls()
}, },
UIAction( UIAction(
@ -348,6 +451,36 @@ final class NativePlayerViewController: UIViewController {
return UIMenu(title: "Captions", children: trackActions + [delayActions]) return UIMenu(title: "Captions", children: trackActions + [delayActions])
} }
private func audioMenu() -> UIMenu {
let selectedTrackID = backend.selectedAudioTrackID
let tracks = backend.audioTracks
let options = AudioOptionMapper.options(from: tracks)
#if DEBUG
print("[DreamioAudio] build-menu tracks=\(SubtitleDebugFormatter.trackSummary(tracks)) selected=\(selectedTrackID)")
#endif
let trackActions = options.map { track in
UIAction(
title: track.name,
state: track.id == selectedTrackID ? .on : .off
) { [weak self] _ in
guard let self else {
return
}
#if DEBUG
print("[DreamioAudio] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedAudioTrackID)")
#endif
self.backend.selectAudioTrack(id: track.id)
#if DEBUG
print("[DreamioAudio] select-result id=\(track.id) after=\(self.backend.selectedAudioTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.audioTracks))")
#endif
self.audioMenuSignature = nil
self.refreshControls()
}
}
return UIMenu(title: "Audio", children: trackActions)
}
private func startProgressUpdates() { private func startProgressUpdates() {
progressTimer?.invalidate() progressTimer?.invalidate()
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
@ -356,12 +489,14 @@ final class NativePlayerViewController: UIViewController {
} }
private func refreshControls() { private func refreshControls() {
let audioTracks = backend.audioTracks
let subtitleTracks = backend.subtitleTracks
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal) playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
scrubber.isEnabled = backend.isSeekable scrubber.isEnabled = backend.isSeekable
jumpBackButton.isEnabled = backend.isSeekable jumpBackButton.isEnabled = backend.isSeekable
jumpForwardButton.isEnabled = backend.isSeekable jumpForwardButton.isEnabled = backend.isSeekable
captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty updateAudioMenuIfNeeded(audioTracks: audioTracks)
captionsButton.menu = captionsMenu() updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime) elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))" remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
if !isScrubbing { if !isScrubbing {
@ -370,6 +505,65 @@ final class NativePlayerViewController: UIViewController {
[scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 } [scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 }
} }
private func updateAudioMenuIfNeeded(audioTracks: [AudioTrack]) {
let selectedTrackID = backend.selectedAudioTrackID
let signature = trackMenuSignatureValue(
tracks: audioTracks,
selectedTrackID: selectedTrackID
)
let hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1
audioButton.isEnabled = hasSelectableTrack
audioButton.alpha = hasSelectableTrack ? 1 : 0.45
guard signature != audioMenuSignature else {
return
}
audioMenuSignature = signature
audioButton.menu = audioMenu()
#if DEBUG
print("[DreamioAudio] refresh-menu enabled=\(audioButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(audioTracks)) selected=\(selectedTrackID)")
#endif
}
private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {
let selectedTrackID = backend.selectedSubtitleTrackID
let signature = captionsMenuSignatureValue(
tracks: subtitleTracks,
selectedTrackID: selectedTrackID,
delay: backend.subtitleDelay
)
let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }
captionsButton.isEnabled = hasSelectableTrack
guard signature != captionsMenuSignature else {
return
}
captionsMenuSignature = signature
captionsButton.menu = captionsMenu()
#if DEBUG
print("[DreamioCaptions] refresh-menu enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(selectedTrackID)")
#endif
}
private func captionsMenuSignatureValue(
tracks: [SubtitleTrack],
selectedTrackID: Int32,
delay: TimeInterval
) -> String {
let trackSignature = trackMenuSignatureValue(tracks: tracks, selectedTrackID: selectedTrackID)
return "\(trackSignature)#delay=\(String(format: "%.1f", delay))"
}
private func trackMenuSignatureValue(
tracks: [SubtitleTrack],
selectedTrackID: Int32
) -> String {
let trackSignature = tracks
.map { "\($0.id):\($0.name)" }
.joined(separator: "|")
return "\(trackSignature)#selected=\(selectedTrackID)"
}
private func revealControls() { private func revealControls() {
controlsContainer.isUserInteractionEnabled = true controlsContainer.isUserInteractionEnabled = true
closeButton.isUserInteractionEnabled = true closeButton.isUserInteractionEnabled = true
@ -404,8 +598,10 @@ final class NativePlayerViewController: UIViewController {
button.translatesAutoresizingMaskIntoConstraints = false button.translatesAutoresizingMaskIntoConstraints = false
button.setImage(UIImage(systemName: systemName), for: .normal) button.setImage(UIImage(systemName: systemName), for: .normal)
button.tintColor = .white button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.35) button.backgroundColor = UIColor.white.withAlphaComponent(0.12)
button.layer.cornerRadius = 18 button.layer.cornerRadius = 18
button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor
button.layer.borderWidth = 1
button.accessibilityLabel = label button.accessibilityLabel = label
return button return button
} }

View file

@ -40,6 +40,35 @@ struct SubtitleTrack: Equatable {
let name: String let name: String
} }
typealias AudioTrack = SubtitleTrack
#if DEBUG
enum SubtitleDebugFormatter {
static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {
guard !candidates.isEmpty else {
return "[]"
}
return candidates.map { candidate in
let extensionLabel = candidate.url.pathExtension.isEmpty ? "none" : candidate.url.pathExtension.lowercased()
let language = candidate.language?.isEmpty == false ? candidate.language! : "unknown"
let label = candidate.label.isEmpty ? "External Subtitle" : candidate.label
return "{label=\(label), language=\(language), ext=\(extensionLabel)}"
}.joined(separator: ", ")
}
static func trackSummary(_ tracks: [SubtitleTrack]) -> String {
guard !tracks.isEmpty else {
return "[]"
}
return tracks.map { track in
"{id=\(track.id), name=\(track.name)}"
}.joined(separator: ", ")
}
}
#endif
enum PlaybackTimeFormatter { enum PlaybackTimeFormatter {
static func label(for seconds: TimeInterval) -> String { static func label(for seconds: TimeInterval) -> String {
guard seconds.isFinite, seconds > 0 else { guard seconds.isFinite, seconds > 0 else {
@ -66,6 +95,12 @@ enum SubtitleOptionMapper {
} }
} }
enum AudioOptionMapper {
static func options(from tracks: [AudioTrack]) -> [AudioTrack] {
tracks.filter { $0.id >= 0 }
}
}
struct StreamClassification { struct StreamClassification {
let sourceKind: StreamSourceKind let sourceKind: StreamSourceKind
let containerGuess: StreamContainerGuess let containerGuess: StreamContainerGuess
@ -105,39 +140,64 @@ struct StreamCandidate {
enum SubtitleCandidateParser { enum SubtitleCandidateParser {
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"] private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
private static let urlFields = ["url", "href", "src", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"] private static let nonSubtitleExtensions = [
private static let labelFields = ["label", "name", "title", "lang", "language", "id"] "aac", "avi", "bmp", "css", "gif", "heic", "ico", "jpeg", "jpg", "js", "json",
"m4a", "m4v", "mkv", "mov", "mp3", "mp4", "mpeg", "mpg", "png", "svg", "ts", "webm", "webp"
]
private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download", "fileUrl", "fileURL"]
private static let labelFields = ["label", "name", "title", "file_name", "filename", "lang", "language", "id"]
private struct CandidateContext {
let label: String?
let language: String?
func merged(with dictionary: [String: Any]) -> CandidateContext {
let label = Self.firstString(in: dictionary, fields: labelFields) ?? self.label
let language = (dictionary["lang"] as? String)
?? (dictionary["language"] as? String)
?? self.language
return CandidateContext(label: label, language: language)
}
private static func firstString(in dictionary: [String: Any], fields: [String]) -> String? {
fields.lazy.compactMap { dictionary[$0] as? String }.first { !$0.isEmpty }
}
}
static func candidates(in payload: Any?) -> [SubtitleCandidate] { static func candidates(in payload: Any?) -> [SubtitleCandidate] {
var results: [SubtitleCandidate] = [] var results: [SubtitleCandidate] = []
collect(from: payload, into: &results) collect(from: payload, context: CandidateContext(label: nil, language: nil), into: &results)
var seen = Set<String>() var orderedKeys: [String] = []
return results.filter { candidate in var bestByURL: [String: SubtitleCandidate] = [:]
results.forEach { candidate in
let key = candidate.url.absoluteString let key = candidate.url.absoluteString
guard !seen.contains(key) else { if bestByURL[key] == nil {
return false orderedKeys.append(key)
bestByURL[key] = candidate
} else if let current = bestByURL[key],
candidateScore(candidate) > candidateScore(current) {
bestByURL[key] = candidate
} }
seen.insert(key)
return true
} }
return orderedKeys.compactMap { bestByURL[$0] }
} }
private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) { private static func collect(from value: Any?, context: CandidateContext, into results: inout [SubtitleCandidate]) {
switch value { switch value {
case let dictionary as [String: Any]: case let dictionary as [String: Any]:
if let candidate = candidate(from: dictionary) { let nextContext = context.merged(with: dictionary)
if let candidate = candidate(from: dictionary, context: nextContext) {
results.append(candidate) results.append(candidate)
} }
dictionary.values.forEach { collect(from: $0, into: &results) } orderedNestedValues(in: dictionary).forEach { collect(from: $0, context: nextContext, into: &results) }
case let array as [Any]: case let array as [Any]:
array.forEach { collect(from: $0, into: &results) } array.forEach { collect(from: $0, context: context, into: &results) }
case let string as String: case let string as String:
if let url = subtitleURL(from: string) { if let url = subtitleURL(from: string) {
results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil)) results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))
} else { } else {
extractSubtitleURLs(from: string).forEach { url in extractSubtitleURLs(from: string).forEach { url in
results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil)) results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))
} }
} }
default: default:
@ -145,8 +205,10 @@ enum SubtitleCandidateParser {
} }
} }
private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? { private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? {
guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else { guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first
?? openSubtitlesDownloadURL(from: dictionary["file_id"])
else {
return nil return nil
} }
@ -154,11 +216,38 @@ enum SubtitleCandidateParser {
let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String) let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)
return SubtitleCandidate( return SubtitleCandidate(
url: url, url: url,
label: label?.isEmpty == false ? label! : defaultLabel(for: url), label: label?.isEmpty == false ? label! : (context.label ?? defaultLabel(for: url)),
language: language language: language ?? context.language
) )
} }
private static func candidateScore(_ candidate: SubtitleCandidate) -> Int {
let defaultLabel = defaultLabel(for: candidate.url)
let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != defaultLabel
return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
}
private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
let preferredKeys = ["attributes", "subtitles", "subtitle", "files", "downloads", "download", "data", "results"]
var visitedKeys = Set<String>()
var values: [Any] = []
preferredKeys.forEach { key in
if let value = dictionary[key] {
values.append(value)
visitedKeys.insert(key)
}
}
dictionary.keys
.filter { !visitedKeys.contains($0) && !urlFields.contains($0) }
.sorted()
.compactMap { dictionary[$0] }
.forEach { values.append($0) }
return values
}
private static func subtitleURL(from string: String?) -> URL? { private static func subtitleURL(from string: String?) -> URL? {
guard let string, guard let string,
let url = URL(string: string), let url = URL(string: string),
@ -167,11 +256,15 @@ enum SubtitleCandidateParser {
return nil return nil
} }
let lowercased = url.absoluteString.lowercased() if isOpenSubtitlesManifestIdentifier(url) {
guard supportedExtensions.contains(url.pathExtension.lowercased()) return nil
|| supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") }) }
|| lowercased.contains("subtitle") guard !nonSubtitleExtensions.contains(url.pathExtension.lowercased()) else {
|| lowercased.contains("opensubtitles") return nil
}
guard isDirectSubtitleFile(url)
|| isOpenSubtitlesDownloadURL(url)
|| isStremioSubtitleDownloadURL(url)
else { else {
return nil return nil
} }
@ -179,6 +272,61 @@ enum SubtitleCandidateParser {
return url return url
} }
private static func isDirectSubtitleFile(_ url: URL) -> Bool {
let lowercased = url.absoluteString.lowercased()
return supportedExtensions.contains(url.pathExtension.lowercased())
|| supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") })
}
private static func isOpenSubtitlesDownloadURL(_ url: URL) -> Bool {
guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
return false
}
let path = url.path.lowercased()
guard !isOpenSubtitlesManifestIdentifier(url) else {
return false
}
return path.range(of: #"(^|/)api/v1/download(/|$)"#, options: .regularExpression) != nil
|| path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
|| path.range(of: #"(^|/)subtitles?(/|$)"#, options: .regularExpression) != nil
}
private static func isStremioSubtitleDownloadURL(_ url: URL) -> Bool {
guard let host = url.host?.lowercased(),
host == "strem.io" || host.hasSuffix(".strem.io")
else {
return false
}
let path = url.path.lowercased()
return path.range(of: #"^/[a-z]{2,3}/download(/|$)"#, options: .regularExpression) != nil
|| path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
}
private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool {
guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
return false
}
let path = url.path.lowercased()
return path == "/manifest.json" || path.range(of: #"/manifest\.json_\d+$"#, options: .regularExpression) != nil
}
private static func openSubtitlesDownloadURL(from value: Any?) -> URL? {
let id: String?
if let string = value as? String, !string.isEmpty {
id = string
} else if let number = value as? NSNumber {
id = number.stringValue
} else {
id = nil
}
guard let id else {
return nil
}
return URL(string: "https://api.opensubtitles.com/api/v1/download/\(id)")
}
private static func defaultLabel(for url: URL) -> String { private static func defaultLabel(for url: URL) -> String {
let lastPathComponent = url.deletingPathExtension().lastPathComponent let lastPathComponent = url.deletingPathExtension().lastPathComponent
return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent

View file

@ -6,6 +6,10 @@ struct ResolvedNativeStream {
let source: String let source: String
} }
protocol SubtitleResolving {
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
}
enum StreamResolverError: LocalizedError { enum StreamResolverError: LocalizedError {
case noResolverURL case noResolverURL
case httpStatus(Int) case httpStatus(Int)
@ -29,6 +33,146 @@ enum StreamResolverError: LocalizedError {
} }
} }
final class SubtitleResolver: SubtitleResolving {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {
if Self.isDirectSubtitleFile(candidate.url) {
return candidate
}
guard Self.shouldResolve(candidate.url) else {
return nil
}
var request = URLRequest(url: candidate.url)
request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
StreamClassifier.defaultHeaders(userAgent: nil).forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
do {
let (data, response) = try await session.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
#if DEBUG
print("[DreamioSubtitles] resolve status=\(httpResponse.statusCode) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
#endif
return nil
}
if let finalURL = response.url, Self.isDirectSubtitleFile(finalURL) {
return SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language)
}
return Self.bestPlayableCandidate(
from: data,
responseURL: response.url,
original: candidate
).map { resolved in
#if DEBUG
print("[DreamioSubtitles] resolved candidate from=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) to=\(URLRedactor.redactedURLString(resolved.url.absoluteString))")
#endif
return resolved
} ?? Self.logRejected(candidate, responseURL: response.url, data: data)
} catch {
#if DEBUG
print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
#endif
return nil
}
}
static func bestPlayableCandidate(
from data: Data,
responseURL: URL?,
original: SubtitleCandidate
) -> SubtitleCandidate? {
if let responseURL, isDirectSubtitleFile(responseURL) {
return SubtitleCandidate(url: responseURL, label: original.label, language: original.language)
}
guard !data.isEmpty else {
return nil
}
if let payload = try? JSONSerialization.jsonObject(with: data) {
return SubtitleCandidateParser.candidates(in: payload)
.first(where: { isDirectSubtitleFile($0.url) })
.map { playable in
SubtitleCandidate(
url: playable.url,
label: original.label.isEmpty ? playable.label : original.label,
language: playable.language ?? original.language
)
}
}
if let text = String(data: data, encoding: .utf8) {
return SubtitleCandidateParser.candidates(in: text)
.first(where: { isDirectSubtitleFile($0.url) })
.map { playable in
SubtitleCandidate(
url: playable.url,
label: original.label.isEmpty ? playable.label : original.label,
language: playable.language ?? original.language
)
}
}
return nil
}
static func isDirectSubtitleFile(_ url: URL) -> Bool {
let lowercased = url.absoluteString.lowercased()
return ["srt", "vtt", "ass", "ssa", "sub"].contains(url.pathExtension.lowercased())
|| [".srt?", ".vtt?", ".ass?", ".ssa?", ".sub?", ".srt&", ".vtt&", ".ass&", ".ssa&", ".sub&"].contains(where: lowercased.contains)
|| isStremioSubtitleDownloadURL(url)
}
private static func shouldResolve(_ url: URL) -> Bool {
let lowercased = url.absoluteString.lowercased()
return lowercased.contains("opensubtitles")
|| lowercased.contains("/subtitle")
|| lowercased.contains("subtitle")
|| isStremioSubtitleDownloadURL(url)
}
private static func isStremioSubtitleDownloadURL(_ url: URL) -> Bool {
guard let host = url.host?.lowercased(),
host == "strem.io" || host.hasSuffix(".strem.io")
else {
return false
}
let path = url.path.lowercased()
return path.range(of: #"^/[a-z]{2,3}/download(/|$)"#, options: .regularExpression) != nil
|| path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
}
private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
#if DEBUG
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
let bodyKind: String
if data.isEmpty {
bodyKind = "empty"
} else if (try? JSONSerialization.jsonObject(with: data)) != nil {
bodyKind = "json-without-direct-subtitle"
} else if String(data: data, encoding: .utf8) != nil {
bodyKind = "text-without-direct-subtitle"
} else {
bodyKind = "unreadable"
}
print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription)")
#endif
return nil
}
}
protocol StreamResolving { protocol StreamResolving {
func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream
} }

View file

@ -18,11 +18,17 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
var onFailure: ((Error) -> Void)? var onFailure: ((Error) -> Void)?
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var onSubtitleTracksChange: (() -> Void)? var onSubtitleTracksChange: (() -> Void)?
var onAudioTracksChange: (() -> Void)?
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
private let mediaPlayer = VLCMediaPlayer() private let mediaPlayer = VLCMediaPlayer()
#endif #endif
private var attachedSubtitleURLs = Set<URL>() private var attachedSubtitleURLs = Set<URL>()
private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false
private var autoSelectedSubtitleTrackID: Int32?
private var externalSubtitleBaselineTrackIDs = Set<Int32>()
private var hasPendingExternalSubtitleSelection = false
override init() { override init() {
super.init() super.init()
@ -41,6 +47,11 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
func play(request: NativePlaybackRequest) { func play(request: NativePlaybackRequest) {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
attachedSubtitleURLs.removeAll() attachedSubtitleURLs.removeAll()
didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false
autoSelectedSubtitleTrackID = nil
externalSubtitleBaselineTrackIDs.removeAll()
hasPendingExternalSubtitleSelection = false
let media = VLCMedia(url: request.playbackURL) let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers let headerValue = request.headers
.map { "\($0.key): \($0.value)" } .map { "\($0.key): \($0.value)" }
@ -58,7 +69,6 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif #endif
mediaPlayer.play() mediaPlayer.play()
attachSubtitles(request.subtitleCandidates)
#else #else
onFailure?(NativePlaybackError.backendUnavailable) onFailure?(NativePlaybackError.backendUnavailable)
#endif #endif
@ -99,20 +109,56 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
#endif #endif
} }
func selectAudioTrack(id: Int32) {
#if canImport(MobileVLCKit)
#if DEBUG
logAudioTracks(reason: "before-select-\(id)")
#endif
mediaPlayer.currentAudioTrackIndex = id
#if DEBUG
logAudioTracks(reason: "after-select-\(id)")
#endif
onAudioTracksChange?()
#endif
}
func selectSubtitleTrack(id: Int32) { func selectSubtitleTrack(id: Int32) {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
didUserSelectSubtitleTrack = true
autoSelectedSubtitleTrackID = nil
#if DEBUG
logSubtitleTracks(reason: "before-select-\(id)")
#endif
mediaPlayer.currentVideoSubTitleIndex = id mediaPlayer.currentVideoSubTitleIndex = id
#if DEBUG
logSubtitleTracks(reason: "after-select-\(id)")
#endif
onSubtitleTracksChange?() onSubtitleTracksChange?()
#endif #endif
} }
func adjustSubtitleDelay(by seconds: TimeInterval) { func adjustSubtitleDelay(by seconds: TimeInterval) {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
#if DEBUG
print("[DreamioVLC] subtitle delay before=\(subtitleDelay) delta=\(seconds)")
#endif
mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000) mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000)
#if DEBUG
print("[DreamioVLC] subtitle delay after=\(subtitleDelay)")
#endif
onSubtitleTracksChange?() onSubtitleTracksChange?()
#endif #endif
} }
@discardableResult
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
#if canImport(MobileVLCKit)
return attachSubtitles(candidates)
#else
return 0
#endif
}
func stop() { func stop() {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
mediaPlayer.stop() mediaPlayer.stop()
@ -165,6 +211,26 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
#endif #endif
} }
var audioTracks: [AudioTrack] {
#if canImport(MobileVLCKit)
let names = mediaPlayer.audioTrackNames as? [String] ?? []
let indexes = mediaPlayer.audioTrackIndexes as? [NSNumber] ?? []
return zip(indexes, names).map { index, name in
AudioTrack(id: index.int32Value, name: name)
}
#else
[]
#endif
}
var selectedAudioTrackID: Int32 {
#if canImport(MobileVLCKit)
mediaPlayer.currentAudioTrackIndex
#else
-1
#endif
}
var subtitleTracks: [SubtitleTrack] { var subtitleTracks: [SubtitleTrack] {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? [] let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
@ -194,24 +260,132 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
} }
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
private func attachSubtitles(_ candidates: [SubtitleCandidate]) { private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
let baselineTrackIDs = Set(subtitleTracks.filter { $0.id >= 0 }.map(\.id))
candidates.forEach { candidate in candidates.forEach { candidate in
guard !attachedSubtitleURLs.contains(candidate.url) else { guard !attachedSubtitleURLs.contains(candidate.url) else {
duplicateCount += 1
return return
} }
attachedSubtitleURLs.insert(candidate.url) attachedSubtitleURLs.insert(candidate.url)
externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)
hasPendingExternalSubtitleSelection = true
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false) mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
#if DEBUG #if DEBUG
print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))") print("[DreamioVLC] attach accepted subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased()) visibleBefore=\(baselineTrackIDs.count)")
logSubtitleTracks(reason: "after-addPlaybackSlave")
#endif #endif
} }
guard !candidates.isEmpty else { #if DEBUG
return if !candidates.isEmpty {
print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) visible=\(subtitleTracks.filter { $0.id >= 0 }.count)")
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in #endif
guard attachedCount > 0 else {
return attachedCount
}
[0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.selectPreferredSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
#if DEBUG
self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
if delay == 4.0 {
self?.logMissingExternalSubtitleTrackIfNeeded()
}
#endif
self?.onSubtitleTracksChange?() self?.onSubtitleTracksChange?()
} }
} }
return attachedCount
}
#if DEBUG
private func logAudioTracks(reason: String) {
let names = mediaPlayer.audioTrackNames as? [String] ?? []
let indexes = mediaPlayer.audioTrackIndexes as? [NSNumber] ?? []
print("[DreamioVLC] audio tracks reason=\(reason) names=\(names) indexes=\(indexes.map { $0.int32Value }) selected=\(mediaPlayer.currentAudioTrackIndex)")
}
private func logSubtitleTracks(reason: String) {
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
print("[DreamioVLC] subtitle tracks reason=\(reason) names=\(names) indexes=\(indexes.map { $0.int32Value }) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
}
#endif
private func selectPreferredSubtitleTrackIfNeeded(reason: String) {
guard !didUserSelectSubtitleTrack else {
return
}
if hasPendingExternalSubtitleSelection,
let externalTrack = subtitleTracks.first(where: { $0.id >= 0 && !externalSubtitleBaselineTrackIDs.contains($0.id) }) {
selectAutoSubtitleTrack(externalTrack, reason: "\(reason)-external")
hasPendingExternalSubtitleSelection = false
return
}
guard !didAutoSelectSubtitleTrack,
mediaPlayer.currentVideoSubTitleIndex < 0,
let track = subtitleTracks.first(where: { $0.id >= 0 }) else {
return
}
selectAutoSubtitleTrack(track, reason: reason)
}
private func selectAutoSubtitleTrack(_ track: SubtitleTrack, reason: String) {
didAutoSelectSubtitleTrack = true
autoSelectedSubtitleTrackID = track.id
#if DEBUG
print("[DreamioVLC] auto-select subtitle id=\(track.id) name=\(track.name) reason=\(reason)")
#endif
mediaPlayer.currentVideoSubTitleIndex = track.id
scheduleAutoSubtitleSelectionReapply(trackID: track.id)
}
#if DEBUG
private func logMissingExternalSubtitleTrackIfNeeded() {
guard hasPendingExternalSubtitleSelection else {
return
}
print("[DreamioVLC] attach accepted but no new external subtitle track visible baseline=\(externalSubtitleBaselineTrackIDs.sorted()) visible=\(subtitleTracks.filter { $0.id >= 0 }.map(\.id))")
}
#endif
private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) {
[0.3, 1.0, 2.0, 4.0].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.reapplyAutoSelectedSubtitleTrackIfNeeded(
reason: "delayed-\(String(format: "%.1f", delay))",
shouldLogNoop: true
)
}
}
}
private func reapplyAutoSelectedSubtitleTrackIfNeeded(reason: String, shouldLogNoop: Bool = false) {
guard !didUserSelectSubtitleTrack,
let trackID = autoSelectedSubtitleTrackID,
subtitleTracks.contains(where: { $0.id == trackID }) else {
return
}
let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex
guard selectedTrackID < 0 || (selectedTrackID == trackID && shouldLogNoop) else {
return
}
if selectedTrackID < 0 {
mediaPlayer.currentVideoSubTitleIndex = trackID
}
#if DEBUG
let action = selectedTrackID == trackID ? "confirm" : "recover"
print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
#endif
}
#endif #endif
} }
@ -223,13 +397,21 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
#endif #endif
switch mediaPlayer.state { switch mediaPlayer.state {
case .buffering, .playing: case .buffering, .playing:
reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))
onReady?() onReady?()
onStateChange?() onStateChange?()
onAudioTracksChange?()
case .error: case .error:
onFailure?(NativePlaybackError.playbackFailed) onFailure?(NativePlaybackError.playbackFailed)
case .paused, .stopped, .ended: case .paused, .stopped, .ended:
onStateChange?() onStateChange?()
case .esAdded: case .esAdded:
selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")
#if DEBUG
logAudioTracks(reason: "esAdded")
logSubtitleTracks(reason: "esAdded")
#endif
onAudioTracksChange?()
onSubtitleTracksChange?() onSubtitleTracksChange?()
default: default:
break break

View file

@ -2,13 +2,25 @@ import Foundation
@main @main
struct StreamResolverTests { struct StreamResolverTests {
static func main() { static func main() async {
testClassifierPrefersObservedDirectFile() testClassifierPrefersObservedDirectFile()
testResolverSelectsUnsupportedDirectURLAndHeaders() testResolverSelectsUnsupportedDirectURLAndHeaders()
testResolverRejectsHLSOnlyResponse() testResolverRejectsHLSOnlyResponse()
testRedactorHandlesPercentEncodedPath() testRedactorHandlesPercentEncodedPath()
testPlaybackTimeFormatting() testPlaybackTimeFormatting()
testSubtitleCandidateParsing() testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testOpenSubtitlesNestedAttributesFilesParsing()
testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles()
testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored()
testStremioSubtitleDownloadURLParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleCandidateDeduplicationUpgradesLabels()
testSubtitleOptionMappingIncludesNone() testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed") print("StreamResolverTests passed")
} }
@ -110,6 +122,311 @@ struct StreamResolverTests {
assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1") assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1")
} }
private static func testOpenSubtitlesV3CandidateParsing() {
let payload: [String: Any] = [
"subtitles": [
[
"language": "English",
"download": "https://api.opensubtitles.com/api/v1/download/subtitle-file",
"nested": [
[
"file": "https://dl.opensubtitles.org/en/subtitle.vtt?download=1"
]
]
],
[
"lang": "spa",
"url": "https://opensubtitles.example.test/download/episode.srt"
]
],
"body": "alternate https://cdn.example.test/from-string.ass?source=opensubtitles",
"ignored": [
"https://cdn.example.test/poster.jpg",
["file": "https://cdn.example.test/video.mkv"]
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 4)
assertEqual(candidates[0].label, "English")
assertEqual(candidates[0].language, "English")
assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/subtitle.vtt?download=1")
assertEqual(candidates[1].label, "English")
assertEqual(candidates[1].language, "English")
assertEqual(candidates[2].label, "spa")
assertEqual(candidates[2].language, "spa")
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
}
private static func testOpenSubtitlesNestedAttributesFilesParsing() {
let payload: [String: Any] = [
"data": [
[
"attributes": [
"language": "English",
"file_name": "episode.en.srt",
"files": [
[
"file_id": 12345,
"file_name": "nested.en.srt"
],
[
"link": "https://dl.opensubtitles.org/en/download/nested.vtt?token=secret",
"language": "eng"
]
]
]
]
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 2)
assertEqual(candidates[0].url.absoluteString, "https://api.opensubtitles.com/api/v1/download/12345")
assertEqual(candidates[0].label, "nested.en.srt")
assertEqual(candidates[0].language, "English")
assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/download/nested.vtt?token=secret")
assertEqual(candidates[1].label, "eng")
assertEqual(candidates[1].language, "eng")
}
private static func testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles() {
let payload: [String: Any] = [
"subtitles": [
[
"url": "https://opensubtitles-v3.strem.io/manifest.json_14",
"file_id": 98765,
"lang": "eng"
],
[
"url": "https://opensubtitles-v3.strem.io/manifest.json_15",
"lang": "spa"
],
"https://opensubtitles-v3.strem.io/manifest.json_16"
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 1)
assertEqual(candidates[0].url.absoluteString, "https://api.opensubtitles.com/api/v1/download/98765")
assertEqual(candidates[0].language, "eng")
}
private static func testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored() {
let payload: [String: Any] = [
"subtitles": [
[
"label": "External Subtitle",
"url": "http://www.strem.io/images/addons/opensubtitles-logo.png"
],
[
"label": "External Subtitle",
"url": "https://opensubtitles.strem.io/stremio/v1"
],
[
"label": "English",
"url": "https://opensubtitles.example.test/subtitles/movie.en.srt"
]
],
"body": "metadata https://www.strem.io/images/addons/opensubtitles-logo.png"
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 1)
assertEqual(candidates[0].url.absoluteString, "https://opensubtitles.example.test/subtitles/movie.en.srt")
assertEqual(candidates[0].label, "English")
}
private static func testStremioSubtitleDownloadURLParsing() {
let payload: [String: Any] = [
"subtitles": [
[
"label": "English",
"lang": "eng",
"url": "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941"
],
[
"label": "Not a subtitle",
"url": "https://www.strem.io/images/addons/opensubtitles-logo.png"
]
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 1)
assertEqual(candidates[0].url.absoluteString, "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941")
assertEqual(candidates[0].label, "English")
assertEqual(candidates[0].language, "eng")
assert(SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be attachable without another resolver hop")
}
private static func testOpenSubtitlesV3DownloadResponseResolution() {
let payload = """
{
"link": "https://dl.opensubtitles.org/en/download/subtitle.srt?token=secret",
"file_name": "episode.srt",
"requests": 1
}
""".data(using: .utf8)!
let original = SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download")!,
label: "English",
language: "eng"
)
let candidate = SubtitleResolver.bestPlayableCandidate(
from: payload,
responseURL: original.url,
original: original
)
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/subtitle.srt?token=secret")
assertEqual(candidate?.label, "English")
assertEqual(candidate?.language, "eng")
}
private static func testOpenSubtitlesNestedDownloadResponseResolution() {
let payload = """
{
"data": {
"attributes": {
"files": [
{
"file_name": "ignored.txt",
"link": "https://cdn.example.test/ignored.txt"
},
{
"file_name": "episode.en.ass",
"download": {
"link": "https://dl.opensubtitles.org/en/download/episode.en.ass?token=secret"
}
}
]
}
}
}
""".data(using: .utf8)!
let original = SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/987")!,
label: "English SDH",
language: "eng"
)
let candidate = SubtitleResolver.bestPlayableCandidate(
from: payload,
responseURL: original.url,
original: original
)
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/episode.en.ass?token=secret")
assertEqual(candidate?.label, "English SDH")
assertEqual(candidate?.language, "eng")
}
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/123": (
200,
URL(string: "https://api.opensubtitles.com/api/v1/download/123")!,
#"{"link":"https://dl.opensubtitles.org/en/download/movie.srt?token=secret"}"#.data(using: .utf8)!
)
]
let resolver = SubtitleResolver(session: mockSession())
let candidate = await resolver.resolve(SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/123")!,
label: "English",
language: "eng"
))
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/movie.srt?token=secret")
assertEqual(candidate?.label, "English")
assertEqual(candidate?.language, "eng")
}
private static func testSubtitleResolverRedirectToDirectSubtitle() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/redirect": (
200,
URL(string: "https://dl.opensubtitles.org/en/redirected.vtt?download=1")!,
Data()
)
]
let resolver = SubtitleResolver(session: mockSession())
let candidate = await resolver.resolve(SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/redirect")!,
label: "English",
language: "eng"
))
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/redirected.vtt?download=1")
}
private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/not-found": (
200,
URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!,
#"{"message":"not found"}"#.data(using: .utf8)!
)
]
let resolver = SubtitleResolver(session: mockSession())
let candidate = await resolver.resolve(SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!,
label: "English",
language: "eng"
))
assert(candidate == nil, "Expected non-subtitle API response to be rejected")
}
private static func testSubtitleCandidateDeduplicationPreservesLabels() {
let payload: [String: Any] = [
"subtitles": [
[
"label": "English SDH",
"lang": "eng",
"url": "https://opensubtitles.example.test/download/duplicate.srt"
],
[
"label": "Duplicate",
"language": "English",
"download": "https://opensubtitles.example.test/download/duplicate.srt"
],
"https://opensubtitles.example.test/download/duplicate.srt"
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 1)
assertEqual(candidates[0].label, "English SDH")
assertEqual(candidates[0].language, "eng")
}
private static func testSubtitleCandidateDeduplicationUpgradesLabels() {
let payload: [String: Any] = [
"subtitles": [
"https://opensubtitles.example.test/download/duplicate.srt",
[
"label": "English SDH",
"lang": "eng",
"url": "https://opensubtitles.example.test/download/duplicate.srt"
]
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 1)
assertEqual(candidates[0].label, "English SDH")
assertEqual(candidates[0].language, "eng")
}
private static func testSubtitleOptionMappingIncludesNone() { private static func testSubtitleOptionMappingIncludesNone() {
let options = SubtitleOptionMapper.options(from: [ let options = SubtitleOptionMapper.options(from: [
SubtitleTrack(id: 2, name: "English"), SubtitleTrack(id: 2, name: "English"),
@ -123,4 +440,43 @@ struct StreamResolverTests {
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) { private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line) assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
} }
private static func mockSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: configuration)
}
}
private final class MockURLProtocol: URLProtocol {
static var handlers: [String: (status: Int, url: URL, data: Data)] = [:]
override class func canInit(with request: URLRequest) -> Bool {
true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let url = request.url,
let handler = Self.handlers[url.absoluteString],
let response = HTTPURLResponse(
url: handler.url,
statusCode: handler.status,
httpVersion: "HTTP/1.1",
headerFields: nil
)
else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: handler.data)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11
skills-lock.json Normal file
View file

@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"liquid-glass-design": {
"source": "affaan-m/everything-claude-code",
"sourceType": "github",
"skillPath": "skills/liquid-glass-design/SKILL.md",
"computedHash": "cdd47ce273b84d1e630aa6ea8086baa9b4affd9f881b3f95a7b25334b67a1dc5"
}
}
}