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-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."}}

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-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}

View file

@ -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,6 +79,7 @@ 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 looksNative = (url) => {
@ -119,6 +126,25 @@ final class DreamioWebViewController: UIViewController {
} 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 rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download);
const url = absoluteURL(rawURL);
@ -131,11 +157,13 @@ 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 inspectSubtitlePayload = (payload) => {
@ -422,7 +450,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 +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
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
guard VLCNativePlaybackBackend.isAvailable else {
@ -455,8 +501,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)
@ -669,6 +717,11 @@ extension DreamioWebViewController: WKScriptMessageHandler {
return
}
if message.name == Constants.subtitleCandidateMessageHandler {
handleSubtitleCandidates(SubtitleCandidateParser.candidates(in: message.body))
return
}
#if DEBUG
guard message.name == Constants.diagnosticsMessageHandler,
let body = message.body as? [String: Any],

View file

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

View file

@ -7,6 +7,7 @@ final class NativePlayerViewController: UIViewController {
private var controlsTimer: Timer?
private var progressTimer: Timer?
private var isScrubbing = false
private var attachedSubtitleURLs: Set<URL>
var onDismiss: (() -> Void)?
private let loadingView: UIActivityIndicatorView = {
@ -94,6 +95,7 @@ final class NativePlayerViewController: UIViewController {
init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {
self.request = request
self.backend = backend
self.attachedSubtitleURLs = Set(request.subtitleCandidates.map(\.url))
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
modalTransitionStyle = .crossDissolve
@ -126,6 +128,26 @@ final class NativePlayerViewController: UIViewController {
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) {
super.viewDidDisappear(animated)
startupTimer?.invalidate()

View file

@ -58,7 +58,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
mediaPlayer.play()
attachSubtitles(request.subtitleCandidates)
addSubtitleCandidates(request.subtitleCandidates)
#else
onFailure?(NativePlaybackError.backendUnavailable)
#endif
@ -113,6 +113,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
#endif
}
@discardableResult
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
#if canImport(MobileVLCKit)
return attachSubtitles(candidates)
#else
return 0
#endif
}
func stop() {
#if canImport(MobileVLCKit)
mediaPlayer.stop()
@ -194,23 +203,33 @@ 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))")
#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?.onSubtitleTracksChange?()
}
return attachedCount
}
#endif
}

View file

@ -9,6 +9,8 @@ struct StreamResolverTests {
testRedactorHandlesPercentEncodedPath()
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
@ -110,6 +112,65 @@ 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 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"),

File diff suppressed because one or more lines are too long