mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
forward late subtitles to native player
This commit is contained in:
parent
66f66faf28
commit
d6bcb52e8a
8 changed files with 729 additions and 7 deletions
|
|
@ -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."}}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
563
docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html
Normal file
563
docs/turns/2026-05-25-forward-late-opensubtitles-subtitles.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue