mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
Compare commits
25 commits
76433f1268
...
0d0881638b
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d0881638b | |||
|
|
25fe0d278f | ||
| 7e9bc35e61 | |||
|
|
8355a0cf70 | ||
| 046444f9ad | |||
| 11b9c6a12a | |||
| 11ed364094 | |||
| 6008272d0a | |||
| 07741bae96 | |||
| f34d60af1b | |||
| d3c5507763 | |||
| c59b318d9b | |||
| 87686d16e9 | |||
| 28f1dc4f8e | |||
| 5fb7e6cc02 | |||
| 892196421c | |||
| d6c445e9cb | |||
| c571eb8873 | |||
| ff0ee65538 | |||
| 2cbe982a47 | |||
| d8ebc7c7f9 | |||
| fdc4444f6a | |||
| 6a29dde857 | |||
| dc11afc45f | |||
| d6bcb52e8a |
25 changed files with 7411 additions and 66 deletions
279
.agents/skills/liquid-glass-design/SKILL.md
Normal file
279
.agents/skills/liquid-glass-design/SKILL.md
Normal 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
|
||||||
|
|
@ -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."}}
|
||||||
|
|
|
||||||
|
|
@ -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
4
.gitignore
vendored
|
|
@ -9,3 +9,7 @@ node_modules/
|
||||||
|
|
||||||
# CocoaPods
|
# CocoaPods
|
||||||
Pods/
|
Pods/
|
||||||
|
|
||||||
|
# Xcode user-specific state
|
||||||
|
*.xcuserstate
|
||||||
|
xcuserdata/
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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,10 +560,34 @@ 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") {
|
||||||
postCandidate(value, this);
|
if (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement) {
|
||||||
|
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],
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
||||||
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
|
func merged(with dictionary: [String: Any]) -> CandidateContext {
|
||||||
var results: [SubtitleCandidate] = []
|
let label = Self.firstString(in: dictionary, fields: labelFields) ?? self.label
|
||||||
collect(from: payload, into: &results)
|
let language = (dictionary["lang"] as? String)
|
||||||
|
?? (dictionary["language"] as? String)
|
||||||
|
?? self.language
|
||||||
|
return CandidateContext(label: label, language: language)
|
||||||
|
}
|
||||||
|
|
||||||
var seen = Set<String>()
|
private static func firstString(in dictionary: [String: Any], fields: [String]) -> String? {
|
||||||
return results.filter { candidate in
|
fields.lazy.compactMap { dictionary[$0] as? String }.first { !$0.isEmpty }
|
||||||
let key = candidate.url.absoluteString
|
|
||||||
guard !seen.contains(key) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
seen.insert(key)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) {
|
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
|
||||||
|
var results: [SubtitleCandidate] = []
|
||||||
|
collect(from: payload, context: CandidateContext(label: nil, language: nil), into: &results)
|
||||||
|
|
||||||
|
var orderedKeys: [String] = []
|
||||||
|
var bestByURL: [String: SubtitleCandidate] = [:]
|
||||||
|
results.forEach { candidate in
|
||||||
|
let key = candidate.url.absoluteString
|
||||||
|
if bestByURL[key] == nil {
|
||||||
|
orderedKeys.append(key)
|
||||||
|
bestByURL[key] = candidate
|
||||||
|
} else if let current = bestByURL[key],
|
||||||
|
candidateScore(candidate) > candidateScore(current) {
|
||||||
|
bestByURL[key] = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orderedKeys.compactMap { bestByURL[$0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,23 +260,131 @@ 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
|
||||||
|
if !candidates.isEmpty {
|
||||||
|
print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) visible=\(subtitleTracks.filter { $0.id >= 0 }.count)")
|
||||||
|
}
|
||||||
|
#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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
|
||||||
self?.onSubtitleTracksChange?()
|
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
|
||||||
|
|
|
||||||
|
|
@ -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() {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
472
docs/turns/2026-05-25-accept-stremio-subtitle-download-urls.html
Normal file
472
docs/turns/2026-05-25-accept-stremio-subtitle-download-urls.html
Normal file
File diff suppressed because one or more lines are too long
395
docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html
Normal file
395
docs/turns/2026-05-25-auto-select-vlc-subtitle-tracks.html
Normal file
File diff suppressed because one or more lines are too long
362
docs/turns/2026-05-25-filter-false-opensubtitles-candidates.html
Normal file
362
docs/turns/2026-05-25-filter-false-opensubtitles-candidates.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
495
docs/turns/2026-05-25-fix-opensubtitles-native-captions.html
Normal file
495
docs/turns/2026-05-25-fix-opensubtitles-native-captions.html
Normal file
File diff suppressed because one or more lines are too long
659
docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html
Normal file
659
docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html
Normal file
File diff suppressed because one or more lines are too long
711
docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html
Normal file
711
docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html
Normal file
File diff suppressed because one or more lines are too long
421
docs/turns/2026-05-25-native-player-audio-tracks.html
Normal file
421
docs/turns/2026-05-25-native-player-audio-tracks.html
Normal file
File diff suppressed because one or more lines are too long
704
docs/turns/2026-05-25-prove-native-subtitle-pipeline.html
Normal file
704
docs/turns/2026-05-25-prove-native-subtitle-pipeline.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
240
docs/turns/2026-05-25-throttle-vlc-subtitle-reapply.html
Normal file
240
docs/turns/2026-05-25-throttle-vlc-subtitle-reapply.html
Normal file
File diff suppressed because one or more lines are too long
11
skills-lock.json
Normal file
11
skills-lock.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue