diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl
index fc1d466..e392a3d 100644
--- a/.beads/interactions.jsonl
+++ b/.beads/interactions.jsonl
@@ -16,3 +16,4 @@
{"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."}}
diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index 76b395e..3675ba6 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -11,6 +11,7 @@
{"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-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-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}
diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift
index 03d20f7..bc1ccf0 100644
--- a/Dreamio/DreamioWebViewController.swift
+++ b/Dreamio/DreamioWebViewController.swift
@@ -127,6 +127,7 @@ final class DreamioWebViewController: UIViewController {
};
const postSubtitleCandidates = (candidates) => {
+ const discoveredCount = candidates.length;
const fresh = candidates.filter((candidate) => {
if (postedSubtitleURLs.has(candidate.url)) {
return false;
@@ -135,12 +136,28 @@ final class DreamioWebViewController: UIViewController {
return true;
});
if (fresh.length === 0) {
+ try {
+ window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({
+ pageUrl: window.location.href,
+ subtitles: [],
+ debug: {
+ discovered: discoveredCount,
+ deduped: 0,
+ forwarded: 0
+ }
+ });
+ } catch (_) {}
return;
}
try {
window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({
pageUrl: window.location.href,
- subtitles: fresh
+ subtitles: fresh,
+ debug: {
+ discovered: discoveredCount,
+ deduped: fresh.length,
+ forwarded: fresh.length
+ }
});
} catch (_) {}
};
@@ -480,6 +497,9 @@ final class DreamioWebViewController: UIViewController {
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")
@@ -489,7 +509,7 @@ final class DreamioWebViewController: UIViewController {
let forwarded = currentNativePlayer.addSubtitleCandidates(candidates)
#if DEBUG
- print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded)")
+ print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded) reason=active-native-player")
#endif
}
@@ -658,6 +678,16 @@ 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 pageURL = dictionary?["pageUrl"] as? String
+ print("[DreamioSubtitles] bridge discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) playerActive=\(currentNativePlayer != nil) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")
+ }
#endif
}
@@ -735,7 +765,11 @@ extension DreamioWebViewController: WKScriptMessageHandler {
}
if message.name == Constants.subtitleCandidateMessageHandler {
- handleSubtitleCandidates(SubtitleCandidateParser.candidates(in: message.body))
+ let candidates = SubtitleCandidateParser.candidates(in: message.body)
+#if DEBUG
+ logSubtitleBridgeMessage(message.body, parsedCandidates: candidates)
+#endif
+ handleSubtitleCandidates(candidates)
return
}
diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift
index e7f5b90..3a94caf 100644
--- a/Dreamio/NativePlayerViewController.swift
+++ b/Dreamio/NativePlayerViewController.swift
@@ -140,7 +140,7 @@ final class NativePlayerViewController: UIViewController {
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)")
+ 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
}
@@ -155,7 +155,7 @@ final class NativePlayerViewController: UIViewController {
await MainActor.run {
guard !resolvedCandidates.isEmpty else {
#if DEBUG
- print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0")
+ 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
}
@@ -172,7 +172,7 @@ final class NativePlayerViewController: UIViewController {
}
#if DEBUG
let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count
- print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")
+ 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
}
}
diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift
index 3c9def5..7b2f209 100644
--- a/Dreamio/StreamCandidate.swift
+++ b/Dreamio/StreamCandidate.swift
@@ -40,6 +40,33 @@ struct SubtitleTrack: Equatable {
let name: String
}
+#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 {
diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift
index fa0a1ef..84ef193 100644
--- a/Dreamio/VLCNativePlaybackBackend.swift
+++ b/Dreamio/VLCNativePlaybackBackend.swift
@@ -214,7 +214,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
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
}
#if DEBUG
@@ -226,10 +227,21 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
return attachedCount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
+ #if DEBUG
+ self?.logSubtitleTracks(reason: "delayed-refresh")
+ #endif
self?.onSubtitleTracksChange?()
}
return attachedCount
}
+
+#if DEBUG
+ 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
#endif
}
@@ -248,6 +260,9 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
case .paused, .stopped, .ended:
onStateChange?()
case .esAdded:
+#if DEBUG
+ logSubtitleTracks(reason: "esAdded")
+#endif
onSubtitleTracksChange?()
default:
break
diff --git a/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html
new file mode 100644
index 0000000..fd877b3
--- /dev/null
+++ b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html
@@ -0,0 +1,388 @@
+
+
+
+
+
+ Prove Native Subtitle Pipeline
+
+
+
+
+
+
Turn document, 2026-05-25
+
Prove Native Subtitle Pipeline
+
Added targeted DEBUG-only logging across subtitle discovery, web-to-native forwarding, native resolution, VLC subtitle attachment, and VLC track exposure. The change is diagnostic only and keeps URL output redacted.
The native subtitle path now reports enough DEBUG data to tell whether subtitles disappear during web discovery, bridge forwarding, native player timing, subtitle resolution, VLC attachment, or VLC track enumeration.
+
+
+
+
Changes Made
+
+
Added web bridge metadata for subtitle discovery, dedupe, and post counts.
+
Logged subtitle bridge messages after native parsing, including whether a native player is active.
+
Logged native player forwarding, resolution, duplicate filtering, attachment counts, resulting subtitle tracks, and selected track id.
+
Logged VLC track state after addPlaybackSlave, after the delayed refresh, and when VLC reports .esAdded.
+
Added DEBUG-only formatting helpers for subtitle candidates and tracks that include labels, languages, extensions, names, indexes, and selected ids without exposing full subtitle URLs.
+
+
+
+
+
Context
+
The current failure mode has already shown native playback starting with subtitles=0. This pass avoids behavior changes and instead makes the next Xcode run produce proof about which stage has zero subtitles or loses them.
+
+
+
+
Important Implementation Details
+
+
Swift logs are wrapped in #if DEBUG, so release behavior is unchanged.
+
The injected web script now includes debug count metadata in subtitle bridge messages. It still sends the same subtitle payload shape used by native parsing.
+
Full URLs remain redacted through existing URLRedactor where URLs are printed. Candidate summaries intentionally show only label, language, and file extension.
+
The VLC logs print videoSubTitlesNames, videoSubTitlesIndexes, and currentVideoSubTitleIndex at the moments most relevant to track exposure.
+
+
+
+
+
Relevant Diff Snippets
+
Rendered with @pierre/diffs/ssr using one file diff per render.
No user-facing behavior should change. Debug builds should provide clearer Xcode logs for the next playback attempt, making it faster to identify the actual subtitle failure point before changing playback behavior.
xcodebuild test -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'platform=iOS Simulator,name=iPhone 16' could not run because the Dreamio scheme is not configured for the test action.
+
+
+
+
+
Issues, Limitations, and Mitigations
+
+
This does not fix subtitle playback by design. It proves where the next fix belongs.
+
The web script can only log subtitle candidates it detects through the existing discovery paths. If the next run shows no bridge discovery logs, Stremio or OpenSubtitles payload discovery is still the likely target.
+
Duplicate-only subtitle bridge messages now post an empty subtitle list with debug metadata. Native handling ignores empty parsed candidates, so playback behavior remains unchanged.
+
+
+
+
+
Follow-up Work
+
+
If logs show no [DreamioSubtitles] bridge discovered entries, inspect the current Stremio/OpenSubtitles payload shape and extend discovery.
+
If logs show playerActive=false before native playback exists, add a small pending subtitle queue rather than dropping early subtitle messages.
+
If logs show resolution failures, improve resolver support for the failing OpenSubtitles response format.
+
If logs show VLC attachment succeeds but track arrays stay empty, test VLC subtitle slave behavior for the resolved file type and timing.