mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
fix stremio subtitle handoff to vlc
This commit is contained in:
parent
c59b318d9b
commit
d3c5507763
9 changed files with 951 additions and 42 deletions
|
|
@ -57,6 +57,8 @@ final class DreamioWebViewController: UIViewController {
|
|||
private var progressObservation: NSKeyValueObservation?
|
||||
private var userAgent: String?
|
||||
private var lastNativePlaybackURL: URL?
|
||||
private var pendingSubtitleCandidatesByStreamKey: [URL: [SubtitleCandidate]] = [:]
|
||||
private var currentNativePlaybackKey: URL?
|
||||
private weak var currentNativePlayer: NativePlayerViewController?
|
||||
private let streamResolver: StreamResolving = StremioStreamResolver()
|
||||
|
||||
|
|
@ -587,17 +589,33 @@ final class DreamioWebViewController: UIViewController {
|
|||
|
||||
let duplicateKey = request.resolverURL ?? request.playbackURL
|
||||
if lastNativePlaybackURL == duplicateKey {
|
||||
mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey)
|
||||
return
|
||||
}
|
||||
lastNativePlaybackURL = duplicateKey
|
||||
currentNativePlaybackKey = duplicateKey
|
||||
mergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey)
|
||||
let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey)
|
||||
|
||||
#if DEBUG
|
||||
let classification = request.classification
|
||||
print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(request.subtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
|
||||
print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(mergedSubtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
|
||||
#endif
|
||||
|
||||
let playbackRequest = NativePlaybackRequest(
|
||||
playbackURL: request.playbackURL,
|
||||
observedURL: request.observedURL,
|
||||
resolverURL: request.resolverURL,
|
||||
pageURL: request.pageURL,
|
||||
userAgent: request.userAgent,
|
||||
referer: request.referer,
|
||||
headers: request.headers,
|
||||
classification: request.classification,
|
||||
subtitleCandidates: mergedSubtitleCandidates
|
||||
)
|
||||
|
||||
Task { [weak self] in
|
||||
await self?.resolveAndPresentNativePlayback(request)
|
||||
await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -606,12 +624,17 @@ final class DreamioWebViewController: UIViewController {
|
|||
return
|
||||
}
|
||||
|
||||
let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL
|
||||
if let streamKey {
|
||||
mergeSubtitleCandidates(candidates, for: streamKey)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
|
||||
print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) streamKey=\(streamKey.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none") candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
|
||||
#endif
|
||||
guard let currentNativePlayer else {
|
||||
#if DEBUG
|
||||
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")
|
||||
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
|
@ -623,9 +646,10 @@ final class DreamioWebViewController: UIViewController {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
|
||||
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async {
|
||||
guard VLCNativePlaybackBackend.isAvailable else {
|
||||
lastNativePlaybackURL = nil
|
||||
currentNativePlaybackKey = nil
|
||||
showNativePlaybackUnavailableAlert()
|
||||
return
|
||||
}
|
||||
|
|
@ -644,25 +668,72 @@ final class DreamioWebViewController: UIViewController {
|
|||
referer: request.referer,
|
||||
headers: resolved.headers,
|
||||
classification: request.classification,
|
||||
subtitleCandidates: request.subtitleCandidates
|
||||
subtitleCandidates: subtitleCandidates(for: streamKey)
|
||||
)
|
||||
let player = NativePlayerViewController(request: resolvedRequest)
|
||||
currentNativePlayer = player
|
||||
player.onDismiss = { [weak self] in
|
||||
self?.lastNativePlaybackURL = nil
|
||||
self?.currentNativePlaybackKey = nil
|
||||
self?.currentNativePlayer = nil
|
||||
self?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
|
||||
self?.cleanUpStremioPlayerAfterNativeDismiss()
|
||||
}
|
||||
present(player, animated: true)
|
||||
present(player, animated: true) { [weak self, weak player] in
|
||||
guard let self, let player else {
|
||||
return
|
||||
}
|
||||
self.currentNativePlayer = player
|
||||
let lateBufferedCandidates = self.subtitleCandidates(for: streamKey)
|
||||
let forwarded = player.addSubtitleCandidates(lateBufferedCandidates)
|
||||
#if DEBUG
|
||||
print("[DreamioSubtitles] presented buffered=\(lateBufferedCandidates.count) forwarded=\(forwarded) streamKey=\(URLRedactor.redactedURLString(streamKey.absoluteString))")
|
||||
#endif
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
|
||||
#endif
|
||||
lastNativePlaybackURL = nil
|
||||
currentNativePlaybackKey = nil
|
||||
pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
|
||||
showNativePlaybackResolutionFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func mergeSubtitleCandidates(_ candidates: [SubtitleCandidate], for streamKey: URL) {
|
||||
guard !candidates.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let existing = pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
|
||||
pendingSubtitleCandidatesByStreamKey[streamKey] = Self.mergedSubtitleCandidates(existing + candidates)
|
||||
}
|
||||
|
||||
private func subtitleCandidates(for streamKey: URL) -> [SubtitleCandidate] {
|
||||
pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
|
||||
}
|
||||
|
||||
private static func mergedSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> [SubtitleCandidate] {
|
||||
var orderedKeys: [String] = []
|
||||
var bestByURL: [String: SubtitleCandidate] = [:]
|
||||
candidates.forEach { candidate in
|
||||
let key = candidate.url.absoluteString
|
||||
if bestByURL[key] == nil {
|
||||
orderedKeys.append(key)
|
||||
bestByURL[key] = candidate
|
||||
} else if let current = bestByURL[key],
|
||||
subtitleCandidateScore(candidate) > subtitleCandidateScore(current) {
|
||||
bestByURL[key] = candidate
|
||||
}
|
||||
}
|
||||
return orderedKeys.compactMap { bestByURL[$0] }
|
||||
}
|
||||
|
||||
private static func subtitleCandidateScore(_ candidate: SubtitleCandidate) -> Int {
|
||||
let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != candidate.url.deletingPathExtension().lastPathComponent
|
||||
return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
|
||||
}
|
||||
|
||||
private func showNativePlaybackResolutionFailure(_ error: Error) {
|
||||
let alert = UIAlertController(
|
||||
title: "Could not open stream",
|
||||
|
|
|
|||
|
|
@ -30,10 +30,6 @@ protocol NativePlaybackBackend: AnyObject {
|
|||
func stop()
|
||||
}
|
||||
|
||||
protocol SubtitleResolving {
|
||||
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
|
||||
}
|
||||
|
||||
enum NativePlaybackError: LocalizedError {
|
||||
case backendUnavailable
|
||||
case startupTimedOut
|
||||
|
|
|
|||
|
|
@ -134,37 +134,58 @@ enum SubtitleCandidateParser {
|
|||
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
|
||||
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"]
|
||||
private struct CandidateContext {
|
||||
let label: String?
|
||||
let language: String?
|
||||
|
||||
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
|
||||
var results: [SubtitleCandidate] = []
|
||||
collect(from: payload, into: &results)
|
||||
func merged(with dictionary: [String: Any]) -> CandidateContext {
|
||||
let label = Self.firstString(in: dictionary, fields: labelFields) ?? self.label
|
||||
let language = (dictionary["lang"] as? String)
|
||||
?? (dictionary["language"] as? String)
|
||||
?? self.language
|
||||
return CandidateContext(label: label, language: language)
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
return results.filter { candidate in
|
||||
let key = candidate.url.absoluteString
|
||||
guard !seen.contains(key) else {
|
||||
return false
|
||||
}
|
||||
seen.insert(key)
|
||||
return true
|
||||
private static func firstString(in dictionary: [String: Any], fields: [String]) -> String? {
|
||||
fields.lazy.compactMap { dictionary[$0] as? String }.first { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) {
|
||||
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
|
||||
var results: [SubtitleCandidate] = []
|
||||
collect(from: payload, context: CandidateContext(label: nil, language: nil), into: &results)
|
||||
|
||||
var orderedKeys: [String] = []
|
||||
var bestByURL: [String: SubtitleCandidate] = [:]
|
||||
results.forEach { candidate in
|
||||
let key = candidate.url.absoluteString
|
||||
if bestByURL[key] == nil {
|
||||
orderedKeys.append(key)
|
||||
bestByURL[key] = candidate
|
||||
} else if let current = bestByURL[key],
|
||||
candidateScore(candidate) > candidateScore(current) {
|
||||
bestByURL[key] = candidate
|
||||
}
|
||||
}
|
||||
return orderedKeys.compactMap { bestByURL[$0] }
|
||||
}
|
||||
|
||||
private static func collect(from value: Any?, context: CandidateContext, into results: inout [SubtitleCandidate]) {
|
||||
switch value {
|
||||
case let dictionary as [String: Any]:
|
||||
if let candidate = candidate(from: dictionary) {
|
||||
let nextContext = context.merged(with: dictionary)
|
||||
if let candidate = candidate(from: dictionary, context: nextContext) {
|
||||
results.append(candidate)
|
||||
}
|
||||
orderedNestedValues(in: dictionary).forEach { collect(from: $0, into: &results) }
|
||||
orderedNestedValues(in: dictionary).forEach { collect(from: $0, context: nextContext, into: &results) }
|
||||
case let array as [Any]:
|
||||
array.forEach { collect(from: $0, into: &results) }
|
||||
array.forEach { collect(from: $0, context: context, into: &results) }
|
||||
case let string as String:
|
||||
if let url = subtitleURL(from: string) {
|
||||
results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))
|
||||
results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))
|
||||
} else {
|
||||
extractSubtitleURLs(from: string).forEach { url in
|
||||
results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))
|
||||
results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))
|
||||
}
|
||||
}
|
||||
default:
|
||||
|
|
@ -172,7 +193,7 @@ enum SubtitleCandidateParser {
|
|||
}
|
||||
}
|
||||
|
||||
private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? {
|
||||
private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? {
|
||||
guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -181,11 +202,17 @@ enum SubtitleCandidateParser {
|
|||
let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)
|
||||
return SubtitleCandidate(
|
||||
url: url,
|
||||
label: label?.isEmpty == false ? label! : defaultLabel(for: url),
|
||||
language: language
|
||||
label: label?.isEmpty == false ? label! : (context.label ?? defaultLabel(for: url)),
|
||||
language: language ?? context.language
|
||||
)
|
||||
}
|
||||
|
||||
private static func candidateScore(_ candidate: SubtitleCandidate) -> Int {
|
||||
let defaultLabel = defaultLabel(for: candidate.url)
|
||||
let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != defaultLabel
|
||||
return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
|
||||
}
|
||||
|
||||
private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
|
||||
let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
|
||||
var visitedKeys = Set<String>()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ struct ResolvedNativeStream {
|
|||
let source: String
|
||||
}
|
||||
|
||||
protocol SubtitleResolving {
|
||||
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
|
||||
}
|
||||
|
||||
enum StreamResolverError: LocalizedError {
|
||||
case noResolverURL
|
||||
case httpStatus(Int)
|
||||
|
|
@ -47,6 +51,9 @@ final class SubtitleResolver: SubtitleResolving {
|
|||
|
||||
var request = URLRequest(url: candidate.url)
|
||||
request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
|
||||
StreamClassifier.defaultHeaders(userAgent: nil).forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
|
@ -66,7 +73,12 @@ final class SubtitleResolver: SubtitleResolving {
|
|||
from: data,
|
||||
responseURL: response.url,
|
||||
original: candidate
|
||||
)
|
||||
).map { resolved in
|
||||
#if DEBUG
|
||||
print("[DreamioSubtitles] resolved candidate from=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) to=\(URLRedactor.redactedURLString(resolved.url.absoluteString))")
|
||||
#endif
|
||||
return resolved
|
||||
} ?? Self.logRejected(candidate, responseURL: response.url, data: data)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
|
||||
|
|
@ -127,6 +139,24 @@ final class SubtitleResolver: SubtitleResolving {
|
|||
|| lowercased.contains("/subtitle")
|
||||
|| lowercased.contains("subtitle")
|
||||
}
|
||||
|
||||
private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
|
||||
#if DEBUG
|
||||
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
|
||||
let bodyKind: String
|
||||
if data.isEmpty {
|
||||
bodyKind = "empty"
|
||||
} else if (try? JSONSerialization.jsonObject(with: data)) != nil {
|
||||
bodyKind = "json-without-direct-subtitle"
|
||||
} else if String(data: data, encoding: .utf8) != nil {
|
||||
bodyKind = "text-without-direct-subtitle"
|
||||
} else {
|
||||
bodyKind = "unreadable"
|
||||
}
|
||||
print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
protocol StreamResolving {
|
||||
|
|
|
|||
|
|
@ -246,12 +246,14 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
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?()
|
||||
[0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
|
||||
#if DEBUG
|
||||
self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
|
||||
#endif
|
||||
self?.onSubtitleTracksChange?()
|
||||
}
|
||||
}
|
||||
return attachedCount
|
||||
}
|
||||
|
|
@ -300,11 +302,13 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
}
|
||||
|
||||
let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex
|
||||
guard selectedTrackID != trackID || shouldLogNoop else {
|
||||
guard selectedTrackID < 0 || (selectedTrackID == trackID && shouldLogNoop) else {
|
||||
return
|
||||
}
|
||||
|
||||
mediaPlayer.currentVideoSubTitleIndex = trackID
|
||||
if selectedTrackID < 0 {
|
||||
mediaPlayer.currentVideoSubTitleIndex = trackID
|
||||
}
|
||||
#if DEBUG
|
||||
let action = selectedTrackID == trackID ? "confirm" : "recover"
|
||||
print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue