forward late subtitles to native player

This commit is contained in:
dirtydishes 2026-05-25 06:43:53 -04:00
parent 66f66faf28
commit d6bcb52e8a
8 changed files with 729 additions and 7 deletions

View file

@ -13,3 +13,4 @@
{"id":"int-5d355e9b","kind":"field_change","created_at":"2026-05-25T09:51:17.04306Z","actor":"dirtydishes","issue_id":"dreamio-wgk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} {"id":"int-5d355e9b","kind":"field_change","created_at":"2026-05-25T09:51:17.04306Z","actor":"dirtydishes","issue_id":"dreamio-wgk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
{"id":"int-9ddb7b1a","kind":"field_change","created_at":"2026-05-25T10:18:30.826897Z","actor":"dirtydishes","issue_id":"dreamio-7w6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build."}} {"id":"int-9ddb7b1a","kind":"field_change","created_at":"2026-05-25T10:18:30.826897Z","actor":"dirtydishes","issue_id":"dreamio-7w6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build."}}
{"id":"int-2a84633f","kind":"field_change","created_at":"2026-05-25T10:25:22.649574Z","actor":"dirtydishes","issue_id":"dreamio-88m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation."}} {"id":"int-2a84633f","kind":"field_change","created_at":"2026-05-25T10:25:22.649574Z","actor":"dirtydishes","issue_id":"dreamio-88m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation."}}
{"id":"int-38a97132","kind":"field_change","created_at":"2026-05-25T10:43:21.805452Z","actor":"dirtydishes","issue_id":"dreamio-lw6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented late subtitle forwarding into active native playback, added VLC append path and parser tests."}}

View file

@ -1,3 +1,4 @@
{"_type":"issue","id":"dreamio-lw6","title":"forward late opensubtitles subtitles to native player","description":"Native playback only receives subtitle candidates discovered before the stream candidate is posted. OpenSubtitles V3 candidates can arrive later through addon/network responses, so the active native player needs an append path for newly discovered external subtitles.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:40:28Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:43:22Z","started_at":"2026-05-25T10:40:36Z","closed_at":"2026-05-25T10:43:22Z","close_reason":"Implemented late subtitle forwarding into active native playback, added VLC append path and parser tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-poo","title":"Native player controls captions and close flow","description":"Add and validate VLC-backed native playback transport controls, subtitle track controls, external subtitle discovery, and Stremio Web close cleanup after native playback dismisses.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:47:56Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:49:40Z","started_at":"2026-05-25T09:48:00Z","closed_at":"2026-05-25T09:49:40Z","close_reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-poo","title":"Native player controls captions and close flow","description":"Add and validate VLC-backed native playback transport controls, subtitle track controls, external subtitle discovery, and Stremio Web close cleanup after native playback dismisses.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:47:56Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:49:40Z","started_at":"2026-05-25T09:48:00Z","closed_at":"2026-05-25T09:49:40Z","close_reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-wgk","title":"Fix native player controls tap-to-show","description":"Native player controls can be hidden by tapping, but subsequent taps on the player do not bring them back. Investigate the overlay gesture handling and restore reliable tap-to-show/tap-to-hide behavior.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:27:58Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:51:17Z","started_at":"2026-05-25T09:28:11Z","closed_at":"2026-05-25T09:51:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-wgk","title":"Fix native player controls tap-to-show","description":"Native player controls can be hidden by tapping, but subsequent taps on the player do not bring them back. Investigate the overlay gesture handling and restore reliable tap-to-show/tap-to-hide behavior.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:27:58Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:51:17Z","started_at":"2026-05-25T09:28:11Z","closed_at":"2026-05-25T09:51:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-ija","title":"Fix MobileVLCKit linker dependency","description":"Dreamio fails to link because the MobileVLCKit framework is not found. Investigate how the dependency is configured and update the repository so the framework is available to Xcode builds.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:40:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T04:44:36Z","started_at":"2026-05-25T04:40:57Z","closed_at":"2026-05-25T04:44:36Z","close_reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ija","title":"Fix MobileVLCKit linker dependency","description":"Dreamio fails to link because the MobileVLCKit framework is not found. Investigate how the dependency is configured and update the repository so the framework is available to Xcode builds.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:40:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T04:44:36Z","started_at":"2026-05-25T04:40:57Z","closed_at":"2026-05-25T04:44:36Z","close_reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install.","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -6,6 +6,7 @@ final class DreamioWebViewController: UIViewController {
static let stremioWebURL = URL(string: "https://web.stremio.com/")! static let stremioWebURL = URL(string: "https://web.stremio.com/")!
static let diagnosticsMessageHandler = "dreamioDiagnostics" static let diagnosticsMessageHandler = "dreamioDiagnostics"
static let streamCandidateMessageHandler = "dreamioStreamCandidate" static let streamCandidateMessageHandler = "dreamioStreamCandidate"
static let subtitleCandidateMessageHandler = "dreamioSubtitleCandidate"
} }
private lazy var webView: WKWebView = { private lazy var webView: WKWebView = {
@ -18,6 +19,10 @@ final class DreamioWebViewController: UIViewController {
WeakScriptMessageHandler(delegate: self), WeakScriptMessageHandler(delegate: self),
name: Constants.streamCandidateMessageHandler name: Constants.streamCandidateMessageHandler
) )
configuration.userContentController.add(
WeakScriptMessageHandler(delegate: self),
name: Constants.subtitleCandidateMessageHandler
)
configuration.userContentController.addUserScript(Self.streamCandidateScript) configuration.userContentController.addUserScript(Self.streamCandidateScript)
#if DEBUG #if DEBUG
configuration.userContentController.add( configuration.userContentController.add(
@ -52,6 +57,7 @@ final class DreamioWebViewController: UIViewController {
private var progressObservation: NSKeyValueObservation? private var progressObservation: NSKeyValueObservation?
private var userAgent: String? private var userAgent: String?
private var lastNativePlaybackURL: URL? private var lastNativePlaybackURL: URL?
private weak var currentNativePlayer: NativePlayerViewController?
private let streamResolver: StreamResolving = StremioStreamResolver() private let streamResolver: StreamResolving = StremioStreamResolver()
private static let streamCandidateScript = WKUserScript( private static let streamCandidateScript = WKUserScript(
@ -73,6 +79,7 @@ final class DreamioWebViewController: UIViewController {
/\.mp4(?:[?#]|$)/i /\.mp4(?:[?#]|$)/i
]; ];
const subtitleCandidates = []; const subtitleCandidates = [];
const postedSubtitleURLs = new Set();
const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig; const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
const looksNative = (url) => { const looksNative = (url) => {
@ -119,6 +126,25 @@ final class DreamioWebViewController: UIViewController {
} catch (_) {} } catch (_) {}
}; };
const postSubtitleCandidates = (candidates) => {
const fresh = candidates.filter((candidate) => {
if (postedSubtitleURLs.has(candidate.url)) {
return false;
}
postedSubtitleURLs.add(candidate.url);
return true;
});
if (fresh.length === 0) {
return;
}
try {
window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({
pageUrl: window.location.href,
subtitles: fresh
});
} catch (_) {}
};
const addSubtitleCandidate = (entry) => { 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.file || entry.download);
const url = absoluteURL(rawURL); const url = absoluteURL(rawURL);
@ -131,11 +157,13 @@ final class DreamioWebViewController: UIViewController {
if (subtitleCandidates.some((candidate) => candidate.url === url)) { if (subtitleCandidates.some((candidate) => candidate.url === url)) {
return; return;
} }
subtitleCandidates.push({ const candidate = {
url, url,
label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle", label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle",
language: entry && (entry.lang || entry.language) || "" language: entry && (entry.lang || entry.language) || ""
}); };
subtitleCandidates.push(candidate);
postSubtitleCandidates([candidate]);
}; };
const inspectSubtitlePayload = (payload) => { const inspectSubtitlePayload = (payload) => {
@ -422,7 +450,7 @@ final class DreamioWebViewController: UIViewController {
#if DEBUG #if DEBUG
let classification = request.classification let classification = request.classification
print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")") print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(request.subtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
#endif #endif
Task { [weak self] in Task { [weak self] in
@ -430,6 +458,24 @@ final class DreamioWebViewController: UIViewController {
} }
} }
private func handleSubtitleCandidates(_ candidates: [SubtitleCandidate]) {
guard !candidates.isEmpty else {
return
}
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)")
#endif
}
@MainActor @MainActor
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async { private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
guard VLCNativePlaybackBackend.isAvailable else { guard VLCNativePlaybackBackend.isAvailable else {
@ -455,8 +501,10 @@ final class DreamioWebViewController: UIViewController {
subtitleCandidates: request.subtitleCandidates subtitleCandidates: request.subtitleCandidates
) )
let player = NativePlayerViewController(request: resolvedRequest) let player = NativePlayerViewController(request: resolvedRequest)
currentNativePlayer = player
player.onDismiss = { [weak self] in player.onDismiss = { [weak self] in
self?.lastNativePlaybackURL = nil self?.lastNativePlaybackURL = nil
self?.currentNativePlayer = nil
self?.cleanUpStremioPlayerAfterNativeDismiss() self?.cleanUpStremioPlayerAfterNativeDismiss()
} }
present(player, animated: true) present(player, animated: true)
@ -669,6 +717,11 @@ extension DreamioWebViewController: WKScriptMessageHandler {
return return
} }
if message.name == Constants.subtitleCandidateMessageHandler {
handleSubtitleCandidates(SubtitleCandidateParser.candidates(in: message.body))
return
}
#if DEBUG #if DEBUG
guard message.name == Constants.diagnosticsMessageHandler, guard message.name == Constants.diagnosticsMessageHandler,
let body = message.body as? [String: Any], let body = message.body as? [String: Any],

View file

@ -25,6 +25,8 @@ protocol NativePlaybackBackend: AnyObject {
func jump(by seconds: TimeInterval) func jump(by seconds: TimeInterval)
func selectSubtitleTrack(id: Int32) func selectSubtitleTrack(id: Int32)
func adjustSubtitleDelay(by seconds: TimeInterval) func adjustSubtitleDelay(by seconds: TimeInterval)
@discardableResult
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int
func stop() func stop()
} }

View file

@ -7,6 +7,7 @@ final class NativePlayerViewController: UIViewController {
private var controlsTimer: Timer? private var controlsTimer: Timer?
private var progressTimer: Timer? private var progressTimer: Timer?
private var isScrubbing = false private var isScrubbing = false
private var attachedSubtitleURLs: Set<URL>
var onDismiss: (() -> Void)? var onDismiss: (() -> Void)?
private let loadingView: UIActivityIndicatorView = { private let loadingView: UIActivityIndicatorView = {
@ -94,6 +95,7 @@ final class NativePlayerViewController: UIViewController {
init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) { init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {
self.request = request self.request = request
self.backend = backend self.backend = backend
self.attachedSubtitleURLs = Set(request.subtitleCandidates.map(\.url))
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen modalPresentationStyle = .fullScreen
modalTransitionStyle = .crossDissolve modalTransitionStyle = .crossDissolve
@ -126,6 +128,26 @@ final class NativePlayerViewController: UIViewController {
backend.play(request: request) backend.play(request: request)
} }
@discardableResult
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
let newCandidates = candidates.filter { candidate in
guard !attachedSubtitleURLs.contains(candidate.url) else {
return false
}
attachedSubtitleURLs.insert(candidate.url)
return true
}
let attachedCount = backend.addSubtitleCandidates(newCandidates)
if attachedCount > 0 {
refreshControls()
}
#if DEBUG
let duplicateCount = candidates.count - newCandidates.count
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) forwarded=\(newCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")
#endif
return attachedCount
}
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
startupTimer?.invalidate() startupTimer?.invalidate()

View file

@ -58,7 +58,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif #endif
mediaPlayer.play() mediaPlayer.play()
attachSubtitles(request.subtitleCandidates) addSubtitleCandidates(request.subtitleCandidates)
#else #else
onFailure?(NativePlaybackError.backendUnavailable) onFailure?(NativePlaybackError.backendUnavailable)
#endif #endif
@ -113,6 +113,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
#endif #endif
} }
@discardableResult
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
#if canImport(MobileVLCKit)
return attachSubtitles(candidates)
#else
return 0
#endif
}
func stop() { func stop() {
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
mediaPlayer.stop() mediaPlayer.stop()
@ -194,23 +203,33 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
} }
#if canImport(MobileVLCKit) #if canImport(MobileVLCKit)
private func attachSubtitles(_ candidates: [SubtitleCandidate]) { private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
candidates.forEach { candidate in candidates.forEach { candidate in
guard !attachedSubtitleURLs.contains(candidate.url) else { guard !attachedSubtitleURLs.contains(candidate.url) else {
duplicateCount += 1
return return
} }
attachedSubtitleURLs.insert(candidate.url) attachedSubtitleURLs.insert(candidate.url)
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false) mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
#if DEBUG #if DEBUG
print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))") print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
#endif #endif
} }
guard !candidates.isEmpty else { #if DEBUG
return 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 DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.onSubtitleTracksChange?() self?.onSubtitleTracksChange?()
} }
return attachedCount
} }
#endif #endif
} }

View file

@ -9,6 +9,8 @@ struct StreamResolverTests {
testRedactorHandlesPercentEncodedPath() testRedactorHandlesPercentEncodedPath()
testPlaybackTimeFormatting() testPlaybackTimeFormatting()
testSubtitleCandidateParsing() testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleOptionMappingIncludesNone() testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed") print("StreamResolverTests passed")
} }
@ -110,6 +112,65 @@ struct StreamResolverTests {
assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1") assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1")
} }
private static func testOpenSubtitlesV3CandidateParsing() {
let payload: [String: Any] = [
"subtitles": [
[
"language": "English",
"download": "https://api.opensubtitles.com/api/v1/download/subtitle-file",
"nested": [
[
"file": "https://dl.opensubtitles.org/en/subtitle.vtt?download=1"
]
]
],
[
"lang": "spa",
"url": "https://opensubtitles.example.test/download/episode.srt"
]
],
"body": "alternate https://cdn.example.test/from-string.ass?source=opensubtitles",
"ignored": [
"https://cdn.example.test/poster.jpg",
["file": "https://cdn.example.test/video.mkv"]
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 4)
assertEqual(candidates[0].label, "English")
assertEqual(candidates[0].language, "English")
assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/subtitle.vtt?download=1")
assertEqual(candidates[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 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() { private static func testSubtitleOptionMappingIncludesNone() {
let options = SubtitleOptionMapper.options(from: [ let options = SubtitleOptionMapper.options(from: [
SubtitleTrack(id: 2, name: "English"), SubtitleTrack(id: 2, name: "English"),

File diff suppressed because one or more lines are too long