mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
Merge ea5132c4d3 into 76433f1268
This commit is contained in:
commit
6b28287f07
17 changed files with 4013 additions and 29 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,14 @@
|
|||
{"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-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."}}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,12 @@
|
|||
{"_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-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-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}
|
||||
|
|
@ -8,6 +17,8 @@
|
|||
{"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-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-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}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -6,6 +6,7 @@ final class DreamioWebViewController: UIViewController {
|
|||
static let stremioWebURL = URL(string: "https://web.stremio.com/")!
|
||||
static let diagnosticsMessageHandler = "dreamioDiagnostics"
|
||||
static let streamCandidateMessageHandler = "dreamioStreamCandidate"
|
||||
static let subtitleCandidateMessageHandler = "dreamioSubtitleCandidate"
|
||||
}
|
||||
|
||||
private lazy var webView: WKWebView = {
|
||||
|
|
@ -18,6 +19,10 @@ final class DreamioWebViewController: UIViewController {
|
|||
WeakScriptMessageHandler(delegate: self),
|
||||
name: Constants.streamCandidateMessageHandler
|
||||
)
|
||||
configuration.userContentController.add(
|
||||
WeakScriptMessageHandler(delegate: self),
|
||||
name: Constants.subtitleCandidateMessageHandler
|
||||
)
|
||||
configuration.userContentController.addUserScript(Self.streamCandidateScript)
|
||||
#if DEBUG
|
||||
configuration.userContentController.add(
|
||||
|
|
@ -52,6 +57,7 @@ final class DreamioWebViewController: UIViewController {
|
|||
private var progressObservation: NSKeyValueObservation?
|
||||
private var userAgent: String?
|
||||
private var lastNativePlaybackURL: URL?
|
||||
private weak var currentNativePlayer: NativePlayerViewController?
|
||||
private let streamResolver: StreamResolving = StremioStreamResolver()
|
||||
|
||||
private static let streamCandidateScript = WKUserScript(
|
||||
|
|
@ -73,7 +79,9 @@ final class DreamioWebViewController: UIViewController {
|
|||
/\.mp4(?:[?#]|$)/i
|
||||
];
|
||||
const subtitleCandidates = [];
|
||||
const postedSubtitleURLs = new Set();
|
||||
const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
|
||||
const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
|
||||
|
||||
const looksNative = (url) => {
|
||||
if (!url || typeof url !== "string") {
|
||||
|
|
@ -119,8 +127,59 @@ final class DreamioWebViewController: UIViewController {
|
|||
} catch (_) {}
|
||||
};
|
||||
|
||||
const postSubtitleCandidates = (candidates, debug = {}) => {
|
||||
const discoveredCount = candidates.length;
|
||||
const fresh = candidates.filter((candidate) => {
|
||||
if (postedSubtitleURLs.has(candidate.url)) {
|
||||
return false;
|
||||
}
|
||||
postedSubtitleURLs.add(candidate.url);
|
||||
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.file || entry.download);
|
||||
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
|
||||
);
|
||||
const url = absoluteURL(rawURL);
|
||||
subtitleURLPattern.lastIndex = 0;
|
||||
if (!url || !subtitleURLPattern.test(url)) {
|
||||
|
|
@ -131,10 +190,56 @@ final class DreamioWebViewController: UIViewController {
|
|||
if (subtitleCandidates.some((candidate) => candidate.url === url)) {
|
||||
return;
|
||||
}
|
||||
subtitleCandidates.push({
|
||||
const candidate = {
|
||||
url,
|
||||
label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle",
|
||||
language: entry && (entry.lang || entry.language) || ""
|
||||
};
|
||||
subtitleCandidates.push(candidate);
|
||||
postSubtitleCandidates([candidate]);
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -161,12 +266,30 @@ final class DreamioWebViewController: UIViewController {
|
|||
}
|
||||
};
|
||||
|
||||
const inspectSubtitleText = (source, url, text) => {
|
||||
const beforeCount = subtitleCandidates.length;
|
||||
inspectSubtitlePayload(text);
|
||||
postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0);
|
||||
};
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
if (originalFetch) {
|
||||
window.fetch = async (...args) => {
|
||||
const response = await originalFetch(...args);
|
||||
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 (_) {}
|
||||
return response;
|
||||
};
|
||||
|
|
@ -175,7 +298,21 @@ final class DreamioWebViewController: UIViewController {
|
|||
const originalXHRSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.send = function(...args) {
|
||||
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 (_) {}
|
||||
return originalXHRSend.apply(this, args);
|
||||
};
|
||||
|
|
@ -201,11 +338,17 @@ final class DreamioWebViewController: UIViewController {
|
|||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (node instanceof HTMLTrackElement) {
|
||||
inspectTrack(node);
|
||||
}
|
||||
if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) {
|
||||
postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll("video, source").forEach(inspectMedia);
|
||||
node.querySelectorAll("video, source, track").forEach(inspectMedia);
|
||||
}
|
||||
if (node instanceof HTMLVideoElement) {
|
||||
inspectTextTracks(node);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -231,10 +374,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;
|
||||
Element.prototype.setAttribute = function(name, value) {
|
||||
if (String(name).toLowerCase() === "src" && (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement)) {
|
||||
postCandidate(value, this);
|
||||
if (String(name).toLowerCase() === "src") {
|
||||
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);
|
||||
};
|
||||
|
|
@ -243,9 +410,13 @@ final class DreamioWebViewController: UIViewController {
|
|||
HTMLMediaElement.prototype.load = function() {
|
||||
inspectMedia(this);
|
||||
this.querySelectorAll("source").forEach(inspectMedia);
|
||||
inspectTextTracks(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("error", (event) => inspectMedia(event.target), true);
|
||||
new MutationObserver((mutations) => {
|
||||
|
|
@ -259,7 +430,7 @@ final class DreamioWebViewController: UIViewController {
|
|||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["src"]
|
||||
attributeFilter: ["src", "label", "srclang"]
|
||||
});
|
||||
|
||||
inspectMedia(document);
|
||||
|
|
@ -422,7 +593,7 @@ final class DreamioWebViewController: UIViewController {
|
|||
|
||||
#if DEBUG
|
||||
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=\(request.subtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
|
||||
#endif
|
||||
|
||||
Task { [weak self] in
|
||||
|
|
@ -430,6 +601,27 @@ final class DreamioWebViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
private func handleSubtitleCandidates(_ candidates: [SubtitleCandidate]) {
|
||||
guard !candidates.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
|
||||
#endif
|
||||
guard let currentNativePlayer else {
|
||||
#if DEBUG
|
||||
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
let forwarded = currentNativePlayer.addSubtitleCandidates(candidates)
|
||||
#if DEBUG
|
||||
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded) reason=active-native-player")
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
|
||||
guard VLCNativePlaybackBackend.isAvailable else {
|
||||
|
|
@ -455,8 +647,10 @@ final class DreamioWebViewController: UIViewController {
|
|||
subtitleCandidates: request.subtitleCandidates
|
||||
)
|
||||
let player = NativePlayerViewController(request: resolvedRequest)
|
||||
currentNativePlayer = player
|
||||
player.onDismiss = { [weak self] in
|
||||
self?.lastNativePlaybackURL = nil
|
||||
self?.currentNativePlayer = nil
|
||||
self?.cleanUpStremioPlayerAfterNativeDismiss()
|
||||
}
|
||||
present(player, animated: true)
|
||||
|
|
@ -593,6 +787,21 @@ final class DreamioWebViewController: UIViewController {
|
|||
private func redactedURLString(_ value: String) -> String {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -669,6 +878,15 @@ extension DreamioWebViewController: WKScriptMessageHandler {
|
|||
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
|
||||
guard message.name == Constants.diagnosticsMessageHandler,
|
||||
let body = message.body as? [String: Any],
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ protocol NativePlaybackBackend: AnyObject {
|
|||
var onFailure: ((Error) -> Void)? { get set }
|
||||
var onStateChange: (() -> Void)? { get set }
|
||||
var onSubtitleTracksChange: (() -> Void)? { get set }
|
||||
var onAudioTracksChange: (() -> Void)? { get set }
|
||||
var isPlaying: Bool { get }
|
||||
var isSeekable: Bool { get }
|
||||
var duration: TimeInterval { get }
|
||||
var currentTime: TimeInterval { get }
|
||||
var remainingTime: TimeInterval { get }
|
||||
var position: Float { get }
|
||||
var audioTracks: [AudioTrack] { get }
|
||||
var selectedAudioTrackID: Int32 { get }
|
||||
var subtitleTracks: [SubtitleTrack] { get }
|
||||
var selectedSubtitleTrackID: Int32 { get }
|
||||
var subtitleDelay: TimeInterval { get }
|
||||
|
|
@ -23,11 +26,18 @@ protocol NativePlaybackBackend: AnyObject {
|
|||
func togglePlayPause()
|
||||
func seek(to position: Float)
|
||||
func jump(by seconds: TimeInterval)
|
||||
func selectAudioTrack(id: Int32)
|
||||
func selectSubtitleTrack(id: Int32)
|
||||
func adjustSubtitleDelay(by seconds: TimeInterval)
|
||||
@discardableResult
|
||||
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int
|
||||
func stop()
|
||||
}
|
||||
|
||||
protocol SubtitleResolving {
|
||||
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
|
||||
}
|
||||
|
||||
enum NativePlaybackError: LocalizedError {
|
||||
case backendUnavailable
|
||||
case startupTimedOut
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ import UIKit
|
|||
final class NativePlayerViewController: UIViewController {
|
||||
private let request: NativePlaybackRequest
|
||||
private var backend: NativePlaybackBackend
|
||||
private let subtitleResolver: SubtitleResolving
|
||||
private var startupTimer: Timer?
|
||||
private var controlsTimer: Timer?
|
||||
private var progressTimer: Timer?
|
||||
private var isScrubbing = false
|
||||
private var attachedSubtitleURLs: Set<URL>
|
||||
private var audioMenuSignature: String?
|
||||
private var captionsMenuSignature: String?
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
private let loadingView: UIActivityIndicatorView = {
|
||||
|
|
@ -31,8 +35,11 @@ final class NativePlayerViewController: UIViewController {
|
|||
private let controlsContainer: UIVisualEffectView = {
|
||||
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.layer.cornerRadius = 16
|
||||
view.layer.cornerRadius = 22
|
||||
view.clipsToBounds = true
|
||||
view.backgroundColor = UIColor.white.withAlphaComponent(0.08)
|
||||
view.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor
|
||||
view.layer.borderWidth = 1
|
||||
return view
|
||||
}()
|
||||
|
||||
|
|
@ -46,6 +53,7 @@ final class NativePlayerViewController: UIViewController {
|
|||
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")
|
||||
private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds")
|
||||
private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")
|
||||
private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Tracks")
|
||||
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions")
|
||||
|
||||
private let elapsedLabel: UILabel = {
|
||||
|
|
@ -91,9 +99,15 @@ final class NativePlayerViewController: UIViewController {
|
|||
return label
|
||||
}()
|
||||
|
||||
init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {
|
||||
init(
|
||||
request: NativePlaybackRequest,
|
||||
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
|
||||
subtitleResolver: SubtitleResolving = SubtitleResolver()
|
||||
) {
|
||||
self.request = request
|
||||
self.backend = backend
|
||||
self.subtitleResolver = subtitleResolver
|
||||
self.attachedSubtitleURLs = []
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
|
|
@ -124,6 +138,52 @@ final class NativePlayerViewController: UIViewController {
|
|||
configureLayout()
|
||||
startStartupTimer()
|
||||
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) {
|
||||
|
|
@ -135,6 +195,16 @@ final class NativePlayerViewController: UIViewController {
|
|||
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() {
|
||||
backend.prepare(in: self)
|
||||
backend.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
@ -164,6 +234,11 @@ final class NativePlayerViewController: UIViewController {
|
|||
self?.refreshControls()
|
||||
}
|
||||
}
|
||||
backend.onAudioTracksChange = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.refreshControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startStartupTimer() {
|
||||
|
|
@ -185,8 +260,9 @@ final class NativePlayerViewController: UIViewController {
|
|||
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
|
||||
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
|
||||
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
|
||||
audioButton.showsMenuAsPrimaryAction = true
|
||||
captionsButton.showsMenuAsPrimaryAction = true
|
||||
playPauseButton.layer.cornerRadius = 21
|
||||
playPauseButton.layer.cornerRadius = 24
|
||||
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
|
||||
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
|
||||
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
||||
|
|
@ -201,12 +277,19 @@ final class NativePlayerViewController: UIViewController {
|
|||
timeAndScrubRow.alignment = .center
|
||||
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.axis = .horizontal
|
||||
controlRow.alignment = .center
|
||||
controlRow.distribution = .equalSpacing
|
||||
controlRow.spacing = 14
|
||||
controlRow.distribution = .equalCentering
|
||||
controlRow.spacing = 18
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow])
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
@ -257,6 +340,9 @@ final class NativePlayerViewController: UIViewController {
|
|||
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
|
||||
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
|
||||
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
|
||||
audioButton.widthAnchor.constraint(equalToConstant: 36),
|
||||
audioButton.heightAnchor.constraint(equalToConstant: 36),
|
||||
playbackCluster.centerXAnchor.constraint(equalTo: controlRow.centerXAnchor),
|
||||
captionsButton.widthAnchor.constraint(equalToConstant: 36),
|
||||
captionsButton.heightAnchor.constraint(equalToConstant: 36)
|
||||
])
|
||||
|
|
@ -316,13 +402,28 @@ final class NativePlayerViewController: UIViewController {
|
|||
|
||||
private func captionsMenu() -> UIMenu {
|
||||
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(
|
||||
title: track.name,
|
||||
state: track.id == selectedTrackID ? .on : .off
|
||||
) { [weak self] _ in
|
||||
self?.backend.selectSubtitleTrack(id: track.id)
|
||||
self?.refreshControls()
|
||||
guard let self else {
|
||||
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: [
|
||||
UIAction(title: "Decrease 0.5s") { [weak self] _ in
|
||||
self?.backend.adjustSubtitleDelay(by: -0.5)
|
||||
self?.captionsMenuSignature = nil
|
||||
self?.refreshControls()
|
||||
},
|
||||
UIAction(title: "Increase 0.5s") { [weak self] _ in
|
||||
self?.backend.adjustSubtitleDelay(by: 0.5)
|
||||
self?.captionsMenuSignature = nil
|
||||
self?.refreshControls()
|
||||
},
|
||||
UIAction(
|
||||
|
|
@ -348,6 +451,36 @@ final class NativePlayerViewController: UIViewController {
|
|||
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() {
|
||||
progressTimer?.invalidate()
|
||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
|
|
@ -356,12 +489,14 @@ final class NativePlayerViewController: UIViewController {
|
|||
}
|
||||
|
||||
private func refreshControls() {
|
||||
let audioTracks = backend.audioTracks
|
||||
let subtitleTracks = backend.subtitleTracks
|
||||
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
|
||||
scrubber.isEnabled = backend.isSeekable
|
||||
jumpBackButton.isEnabled = backend.isSeekable
|
||||
jumpForwardButton.isEnabled = backend.isSeekable
|
||||
captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty
|
||||
captionsButton.menu = captionsMenu()
|
||||
updateAudioMenuIfNeeded(audioTracks: audioTracks)
|
||||
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
|
||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
|
||||
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
|
||||
if !isScrubbing {
|
||||
|
|
@ -370,6 +505,65 @@ final class NativePlayerViewController: UIViewController {
|
|||
[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() {
|
||||
controlsContainer.isUserInteractionEnabled = true
|
||||
closeButton.isUserInteractionEnabled = true
|
||||
|
|
@ -404,8 +598,10 @@ final class NativePlayerViewController: UIViewController {
|
|||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setImage(UIImage(systemName: systemName), for: .normal)
|
||||
button.tintColor = .white
|
||||
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
button.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||
button.layer.cornerRadius = 18
|
||||
button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor
|
||||
button.layer.borderWidth = 1
|
||||
button.accessibilityLabel = label
|
||||
return button
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,35 @@ struct SubtitleTrack: Equatable {
|
|||
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 {
|
||||
static func label(for seconds: TimeInterval) -> String {
|
||||
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 {
|
||||
let sourceKind: StreamSourceKind
|
||||
let containerGuess: StreamContainerGuess
|
||||
|
|
@ -105,8 +140,8 @@ struct StreamCandidate {
|
|||
|
||||
enum SubtitleCandidateParser {
|
||||
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 labelFields = ["label", "name", "title", "lang", "language", "id"]
|
||||
private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
|
||||
private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"]
|
||||
|
||||
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
|
||||
var results: [SubtitleCandidate] = []
|
||||
|
|
@ -129,7 +164,7 @@ enum SubtitleCandidateParser {
|
|||
if let candidate = candidate(from: dictionary) {
|
||||
results.append(candidate)
|
||||
}
|
||||
dictionary.values.forEach { collect(from: $0, into: &results) }
|
||||
orderedNestedValues(in: dictionary).forEach { collect(from: $0, into: &results) }
|
||||
case let array as [Any]:
|
||||
array.forEach { collect(from: $0, into: &results) }
|
||||
case let string as String:
|
||||
|
|
@ -159,6 +194,27 @@ enum SubtitleCandidateParser {
|
|||
)
|
||||
}
|
||||
|
||||
private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
|
||||
let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
|
||||
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? {
|
||||
guard let string,
|
||||
let url = URL(string: string),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,106 @@ 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")
|
||||
|
||||
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
|
||||
)
|
||||
} 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)
|
||||
}
|
||||
|
||||
private static func shouldResolve(_ url: URL) -> Bool {
|
||||
let lowercased = url.absoluteString.lowercased()
|
||||
return lowercased.contains("opensubtitles")
|
||||
|| lowercased.contains("/subtitle")
|
||||
|| lowercased.contains("subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
protocol StreamResolving {
|
||||
func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
var onFailure: ((Error) -> Void)?
|
||||
var onStateChange: (() -> Void)?
|
||||
var onSubtitleTracksChange: (() -> Void)?
|
||||
var onAudioTracksChange: (() -> Void)?
|
||||
|
||||
#if canImport(MobileVLCKit)
|
||||
private let mediaPlayer = VLCMediaPlayer()
|
||||
#endif
|
||||
private var attachedSubtitleURLs = Set<URL>()
|
||||
private var didAutoSelectSubtitleTrack = false
|
||||
private var didUserSelectSubtitleTrack = false
|
||||
private var autoSelectedSubtitleTrackID: Int32?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
|
@ -41,6 +45,9 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
func play(request: NativePlaybackRequest) {
|
||||
#if canImport(MobileVLCKit)
|
||||
attachedSubtitleURLs.removeAll()
|
||||
didAutoSelectSubtitleTrack = false
|
||||
didUserSelectSubtitleTrack = false
|
||||
autoSelectedSubtitleTrackID = nil
|
||||
let media = VLCMedia(url: request.playbackURL)
|
||||
let headerValue = request.headers
|
||||
.map { "\($0.key): \($0.value)" }
|
||||
|
|
@ -58,7 +65,6 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
|
||||
#endif
|
||||
mediaPlayer.play()
|
||||
attachSubtitles(request.subtitleCandidates)
|
||||
#else
|
||||
onFailure?(NativePlaybackError.backendUnavailable)
|
||||
#endif
|
||||
|
|
@ -99,20 +105,56 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
#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) {
|
||||
#if canImport(MobileVLCKit)
|
||||
didUserSelectSubtitleTrack = true
|
||||
autoSelectedSubtitleTrackID = nil
|
||||
#if DEBUG
|
||||
logSubtitleTracks(reason: "before-select-\(id)")
|
||||
#endif
|
||||
mediaPlayer.currentVideoSubTitleIndex = id
|
||||
#if DEBUG
|
||||
logSubtitleTracks(reason: "after-select-\(id)")
|
||||
#endif
|
||||
onSubtitleTracksChange?()
|
||||
#endif
|
||||
}
|
||||
|
||||
func adjustSubtitleDelay(by seconds: TimeInterval) {
|
||||
#if canImport(MobileVLCKit)
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] subtitle delay before=\(subtitleDelay) delta=\(seconds)")
|
||||
#endif
|
||||
mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000)
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] subtitle delay after=\(subtitleDelay)")
|
||||
#endif
|
||||
onSubtitleTracksChange?()
|
||||
#endif
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
|
||||
#if canImport(MobileVLCKit)
|
||||
return attachSubtitles(candidates)
|
||||
#else
|
||||
return 0
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
#if canImport(MobileVLCKit)
|
||||
mediaPlayer.stop()
|
||||
|
|
@ -165,6 +207,26 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
#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] {
|
||||
#if canImport(MobileVLCKit)
|
||||
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
|
||||
|
|
@ -194,23 +256,90 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
}
|
||||
|
||||
#if canImport(MobileVLCKit)
|
||||
private func attachSubtitles(_ candidates: [SubtitleCandidate]) {
|
||||
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
|
||||
var attachedCount = 0
|
||||
var duplicateCount = 0
|
||||
candidates.forEach { candidate in
|
||||
guard !attachedSubtitleURLs.contains(candidate.url) else {
|
||||
duplicateCount += 1
|
||||
return
|
||||
}
|
||||
attachedSubtitleURLs.insert(candidate.url)
|
||||
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
|
||||
attachedCount += 1
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
|
||||
print("[DreamioVLC] addPlaybackSlave subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased())")
|
||||
logSubtitleTracks(reason: "after-addPlaybackSlave")
|
||||
#endif
|
||||
}
|
||||
guard !candidates.isEmpty else {
|
||||
return
|
||||
#if DEBUG
|
||||
if !candidates.isEmpty {
|
||||
print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")
|
||||
}
|
||||
#endif
|
||||
guard attachedCount > 0 else {
|
||||
return attachedCount
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh")
|
||||
#if DEBUG
|
||||
self?.logSubtitleTracks(reason: "delayed-refresh")
|
||||
#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 selectInitialSubtitleTrackIfNeeded(reason: String) {
|
||||
guard !didUserSelectSubtitleTrack,
|
||||
!didAutoSelectSubtitleTrack,
|
||||
mediaPlayer.currentVideoSubTitleIndex < 0,
|
||||
let track = subtitleTracks.first(where: { $0.id >= 0 }) else {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reapplyAutoSelectedSubtitleTrackIfNeeded(reason: String) {
|
||||
guard !didUserSelectSubtitleTrack,
|
||||
let trackID = autoSelectedSubtitleTrackID,
|
||||
subtitleTracks.contains(where: { $0.id == trackID }) else {
|
||||
return
|
||||
}
|
||||
|
||||
mediaPlayer.currentVideoSubTitleIndex = trackID
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
@ -223,13 +352,21 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
|||
#endif
|
||||
switch mediaPlayer.state {
|
||||
case .buffering, .playing:
|
||||
reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))
|
||||
onReady?()
|
||||
onStateChange?()
|
||||
onAudioTracksChange?()
|
||||
case .error:
|
||||
onFailure?(NativePlaybackError.playbackFailed)
|
||||
case .paused, .stopped, .ended:
|
||||
onStateChange?()
|
||||
case .esAdded:
|
||||
selectInitialSubtitleTrackIfNeeded(reason: "esAdded")
|
||||
#if DEBUG
|
||||
logAudioTracks(reason: "esAdded")
|
||||
logSubtitleTracks(reason: "esAdded")
|
||||
#endif
|
||||
onAudioTracksChange?()
|
||||
onSubtitleTracksChange?()
|
||||
default:
|
||||
break
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ struct StreamResolverTests {
|
|||
testRedactorHandlesPercentEncodedPath()
|
||||
testPlaybackTimeFormatting()
|
||||
testSubtitleCandidateParsing()
|
||||
testOpenSubtitlesV3CandidateParsing()
|
||||
testOpenSubtitlesV3DownloadResponseResolution()
|
||||
testSubtitleCandidateDeduplicationPreservesLabels()
|
||||
testSubtitleOptionMappingIncludesNone()
|
||||
print("StreamResolverTests passed")
|
||||
}
|
||||
|
|
@ -110,6 +113,90 @@ struct StreamResolverTests {
|
|||
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[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 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 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 testSubtitleOptionMappingIncludesNone() {
|
||||
let options = SubtitleOptionMapper.options(from: [
|
||||
SubtitleTrack(id: 2, name: "English"),
|
||||
|
|
|
|||
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
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
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