mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
add native player controls and captions
This commit is contained in:
parent
75e76e14d4
commit
419ffae415
9 changed files with 1096 additions and 4 deletions
|
|
@ -8,3 +8,4 @@
|
||||||
{"id":"int-76aa54ba","kind":"field_change","created_at":"2026-05-25T03:51:39.198446Z","actor":"dirtydishes","issue_id":"dreamio-8vi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
{"id":"int-76aa54ba","kind":"field_change","created_at":"2026-05-25T03:51:39.198446Z","actor":"dirtydishes","issue_id":"dreamio-8vi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
|
||||||
{"id":"int-74805ffd","kind":"field_change","created_at":"2026-05-25T04:21:42.440755Z","actor":"dirtydishes","issue_id":"dreamio-2k5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added native backend availability guard, installed CocoaPods, generated workspace metadata, documented setup, and validated available checks."}}
|
{"id":"int-74805ffd","kind":"field_change","created_at":"2026-05-25T04:21:42.440755Z","actor":"dirtydishes","issue_id":"dreamio-2k5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added native backend availability guard, installed CocoaPods, generated workspace metadata, documented setup, and validated available checks."}}
|
||||||
{"id":"int-27a61615","kind":"field_change","created_at":"2026-05-25T04:44:35.633997Z","actor":"dirtydishes","issue_id":"dreamio-ija","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install."}}
|
{"id":"int-27a61615","kind":"field_change","created_at":"2026-05-25T04:44:35.633997Z","actor":"dirtydishes","issue_id":"dreamio-ija","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install."}}
|
||||||
|
{"id":"int-fad68cb4","kind":"field_change","created_at":"2026-05-25T05:04:55.103302Z","actor":"dirtydishes","issue_id":"dreamio-mj8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup."}}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,6 @@
|
||||||
{"_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-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-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-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-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}
|
||||||
{"_type":"issue","id":"dreamio-evt","title":"Enable WebView inspection and playback diagnostics","description":"Add development-only WKWebView inspection and token-safe playback diagnostics so Dreamio can debug hosted Stremio media failures without changing app navigation, login, or playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T02:30:26Z","created_by":"dirtydishes","updated_at":"2026-05-25T02:34:55Z","started_at":"2026-05-25T02:30:32Z","closed_at":"2026-05-25T02:34:55Z","close_reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"dreamio-evt","title":"Enable WebView inspection and playback diagnostics","description":"Add development-only WKWebView inspection and token-safe playback diagnostics so Dreamio can debug hosted Stremio media failures without changing app navigation, login, or playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T02:30:26Z","created_by":"dirtydishes","updated_at":"2026-05-25T02:34:55Z","started_at":"2026-05-25T02:30:32Z","closed_at":"2026-05-25T02:34:55Z","close_reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"dreamio-a5b","title":"Track HTML diff rendering tooling as dev dependency","description":"Move the HTML diff rendering package into devDependencies and ignore installed Node modules so the repo tracks reproducible tooling without vendoring dependencies.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:12:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:12:44Z","started_at":"2026-05-25T01:12:14Z","closed_at":"2026-05-25T01:12:44Z","close_reason":"Moved @pierre/diffs to devDependencies and ignored node_modules.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"dreamio-a5b","title":"Track HTML diff rendering tooling as dev dependency","description":"Move the HTML diff rendering package into devDependencies and ignore installed Node modules so the repo tracks reproducible tooling without vendoring dependencies.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:12:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:12:44Z","started_at":"2026-05-25T01:12:14Z","closed_at":"2026-05-25T01:12:44Z","close_reason":"Moved @pierre/diffs to devDependencies and ignored node_modules.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ final class DreamioWebViewController: UIViewController {
|
||||||
/\.m3u8(?:[?#]|$)/i,
|
/\.m3u8(?:[?#]|$)/i,
|
||||||
/\.mp4(?:[?#]|$)/i
|
/\.mp4(?:[?#]|$)/i
|
||||||
];
|
];
|
||||||
|
const subtitleCandidates = [];
|
||||||
|
const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
|
||||||
|
|
||||||
const looksNative = (url) => {
|
const looksNative = (url) => {
|
||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
|
|
@ -111,11 +113,72 @@ final class DreamioWebViewController: UIViewController {
|
||||||
resolverUrl: findResolverURL(),
|
resolverUrl: findResolverURL(),
|
||||||
pageUrl: window.location.href,
|
pageUrl: window.location.href,
|
||||||
tagName: element && element.tagName ? element.tagName : "",
|
tagName: element && element.tagName ? element.tagName : "",
|
||||||
currentSrc: element && element.currentSrc ? element.currentSrc : ""
|
currentSrc: element && element.currentSrc ? element.currentSrc : "",
|
||||||
|
subtitles: subtitleCandidates.slice(-20)
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} 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);
|
||||||
|
if (!url || !subtitleURLPattern.test(url)) {
|
||||||
|
subtitleURLPattern.lastIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
subtitleURLPattern.lastIndex = 0;
|
||||||
|
if (subtitleCandidates.some((candidate) => candidate.url === url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
subtitleCandidates.push({
|
||||||
|
url,
|
||||||
|
label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle",
|
||||||
|
language: entry && (entry.lang || entry.language) || ""
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const inspectSubtitlePayload = (payload) => {
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof payload === "string") {
|
||||||
|
const matches = payload.match(subtitleURLPattern) || [];
|
||||||
|
subtitleURLPattern.lastIndex = 0;
|
||||||
|
matches.forEach(addSubtitleCandidate);
|
||||||
|
try {
|
||||||
|
inspectSubtitlePayload(JSON.parse(payload));
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
payload.forEach(inspectSubtitlePayload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof payload === "object") {
|
||||||
|
addSubtitleCandidate(payload);
|
||||||
|
Object.values(payload).forEach(inspectSubtitlePayload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
if (originalFetch) {
|
||||||
|
window.fetch = async (...args) => {
|
||||||
|
const response = await originalFetch(...args);
|
||||||
|
try {
|
||||||
|
response.clone().text().then(inspectSubtitlePayload).catch(() => {});
|
||||||
|
} catch (_) {}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalXHRSend = XMLHttpRequest.prototype.send;
|
||||||
|
XMLHttpRequest.prototype.send = function(...args) {
|
||||||
|
try {
|
||||||
|
this.addEventListener("load", () => inspectSubtitlePayload(this.responseText));
|
||||||
|
} catch (_) {}
|
||||||
|
return originalXHRSend.apply(this, args);
|
||||||
|
};
|
||||||
|
|
||||||
const stopNativeHandledMedia = (element) => {
|
const stopNativeHandledMedia = (element) => {
|
||||||
const media = element instanceof HTMLVideoElement
|
const media = element instanceof HTMLVideoElement
|
||||||
? element
|
? element
|
||||||
|
|
@ -387,11 +450,13 @@ final class DreamioWebViewController: UIViewController {
|
||||||
userAgent: request.userAgent,
|
userAgent: request.userAgent,
|
||||||
referer: request.referer,
|
referer: request.referer,
|
||||||
headers: resolved.headers,
|
headers: resolved.headers,
|
||||||
classification: request.classification
|
classification: request.classification,
|
||||||
|
subtitleCandidates: request.subtitleCandidates
|
||||||
)
|
)
|
||||||
let player = NativePlayerViewController(request: resolvedRequest)
|
let player = NativePlayerViewController(request: resolvedRequest)
|
||||||
player.onDismiss = { [weak self] in
|
player.onDismiss = { [weak self] in
|
||||||
self?.lastNativePlaybackURL = nil
|
self?.lastNativePlaybackURL = nil
|
||||||
|
self?.cleanUpStremioPlayerAfterNativeDismiss()
|
||||||
}
|
}
|
||||||
present(player, animated: true)
|
present(player, animated: true)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -423,6 +488,72 @@ final class DreamioWebViewController: UIViewController {
|
||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func cleanUpStremioPlayerAfterNativeDismiss() {
|
||||||
|
let script = #"""
|
||||||
|
(() => {
|
||||||
|
const stopMedia = () => {
|
||||||
|
document.querySelectorAll("video, audio").forEach((media) => {
|
||||||
|
try { media.pause(); } catch (_) {}
|
||||||
|
try { media.removeAttribute("src"); } catch (_) {}
|
||||||
|
try { media.querySelectorAll("source").forEach((source) => source.removeAttribute("src")); } catch (_) {}
|
||||||
|
try { media.load(); } catch (_) {}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const clickVisible = (selectors) => {
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const nodes = Array.from(document.querySelectorAll(selector));
|
||||||
|
const match = nodes.find((node) => {
|
||||||
|
const style = window.getComputedStyle(node);
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
try { match.click(); return true; } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
stopMedia();
|
||||||
|
const clicked = clickVisible([
|
||||||
|
"[aria-label*='Close' i]",
|
||||||
|
"[aria-label*='Back' i]",
|
||||||
|
"button[class*='close' i]",
|
||||||
|
"button[class*='back' i]",
|
||||||
|
".player button",
|
||||||
|
"[role='button']"
|
||||||
|
]);
|
||||||
|
const stillPlayer = /player|stream|buffer|prepar/i.test(document.body.innerText || "");
|
||||||
|
return { clicked, stillPlayer, href: window.location.href };
|
||||||
|
})();
|
||||||
|
"""#
|
||||||
|
|
||||||
|
webView.evaluateJavaScript(script) { [weak self] result, error in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
if let error {
|
||||||
|
print("[DreamioCloseFlow] cleanup error=\(URLRedactor.redactedURLString(error.localizedDescription))")
|
||||||
|
} else {
|
||||||
|
print("[DreamioCloseFlow] cleanup result=\(String(describing: result))")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
guard error == nil else {
|
||||||
|
self.loadDreamio()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if self.webView.canGoBack {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
|
||||||
|
self.webView.evaluateJavaScript("(/player|stream|buffer|prepar/i).test(document.body.innerText || '')") { result, _ in
|
||||||
|
if (result as? Bool) == true {
|
||||||
|
self.webView.goBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private func logDiagnostic(type: String, payload: Any, pageURL: String?) {
|
private func logDiagnostic(type: String, payload: Any, pageURL: String?) {
|
||||||
let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown"
|
let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown"
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,27 @@ protocol NativePlaybackBackend: AnyObject {
|
||||||
var view: UIView { get }
|
var view: UIView { get }
|
||||||
var onReady: (() -> Void)? { get set }
|
var onReady: (() -> Void)? { get set }
|
||||||
var onFailure: ((Error) -> Void)? { get set }
|
var onFailure: ((Error) -> Void)? { get set }
|
||||||
|
var onStateChange: (() -> Void)? { get set }
|
||||||
|
var onSubtitleTracksChange: (() -> Void)? { get set }
|
||||||
|
var isPlaying: Bool { get }
|
||||||
|
var isSeekable: Bool { get }
|
||||||
|
var duration: TimeInterval { get }
|
||||||
|
var currentTime: TimeInterval { get }
|
||||||
|
var remainingTime: TimeInterval { get }
|
||||||
|
var position: Float { get }
|
||||||
|
var subtitleTracks: [SubtitleTrack] { get }
|
||||||
|
var selectedSubtitleTrackID: Int32 { get }
|
||||||
|
var subtitleDelay: TimeInterval { get }
|
||||||
|
|
||||||
func prepare(in viewController: UIViewController)
|
func prepare(in viewController: UIViewController)
|
||||||
func play(request: NativePlaybackRequest)
|
func play(request: NativePlaybackRequest)
|
||||||
|
func play()
|
||||||
|
func pause()
|
||||||
|
func togglePlayPause()
|
||||||
|
func seek(to position: Float)
|
||||||
|
func jump(by seconds: TimeInterval)
|
||||||
|
func selectSubtitleTrack(id: Int32)
|
||||||
|
func adjustSubtitleDelay(by seconds: TimeInterval)
|
||||||
func stop()
|
func stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ final class NativePlayerViewController: UIViewController {
|
||||||
private let request: NativePlaybackRequest
|
private let request: NativePlaybackRequest
|
||||||
private var backend: NativePlaybackBackend
|
private var backend: NativePlaybackBackend
|
||||||
private var startupTimer: Timer?
|
private var startupTimer: Timer?
|
||||||
|
private var controlsTimer: Timer?
|
||||||
|
private var progressTimer: Timer?
|
||||||
|
private var isScrubbing = false
|
||||||
var onDismiss: (() -> Void)?
|
var onDismiss: (() -> Void)?
|
||||||
|
|
||||||
private let loadingView: UIActivityIndicatorView = {
|
private let loadingView: UIActivityIndicatorView = {
|
||||||
|
|
@ -25,6 +28,49 @@ final class NativePlayerViewController: UIViewController {
|
||||||
return button
|
return button
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private let controlsContainer: UIVisualEffectView = {
|
||||||
|
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.layer.cornerRadius = 12
|
||||||
|
view.clipsToBounds = true
|
||||||
|
return view
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")
|
||||||
|
private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds")
|
||||||
|
private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")
|
||||||
|
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions")
|
||||||
|
|
||||||
|
private let elapsedLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
label.textColor = .white
|
||||||
|
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
|
||||||
|
label.text = "0:00"
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let remainingLabel: UILabel = {
|
||||||
|
let label = UILabel()
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
label.textColor = .white
|
||||||
|
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
|
||||||
|
label.textAlignment = .right
|
||||||
|
label.text = "-0:00"
|
||||||
|
return label
|
||||||
|
}()
|
||||||
|
|
||||||
|
private let scrubber: UISlider = {
|
||||||
|
let slider = UISlider()
|
||||||
|
slider.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
slider.minimumValue = 0
|
||||||
|
slider.maximumValue = 1
|
||||||
|
slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1)
|
||||||
|
slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3)
|
||||||
|
slider.thumbTintColor = .white
|
||||||
|
return slider
|
||||||
|
}()
|
||||||
|
|
||||||
private let failureLabel: UILabel = {
|
private let failureLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
@ -74,6 +120,8 @@ final class NativePlayerViewController: UIViewController {
|
||||||
override func viewDidDisappear(_ animated: Bool) {
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
super.viewDidDisappear(animated)
|
super.viewDidDisappear(animated)
|
||||||
startupTimer?.invalidate()
|
startupTimer?.invalidate()
|
||||||
|
controlsTimer?.invalidate()
|
||||||
|
progressTimer?.invalidate()
|
||||||
backend.stop()
|
backend.stop()
|
||||||
onDismiss?()
|
onDismiss?()
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +134,9 @@ final class NativePlayerViewController: UIViewController {
|
||||||
self?.startupTimer?.invalidate()
|
self?.startupTimer?.invalidate()
|
||||||
self?.loadingView.stopAnimating()
|
self?.loadingView.stopAnimating()
|
||||||
self?.loadingView.isHidden = true
|
self?.loadingView.isHidden = true
|
||||||
|
self?.startProgressUpdates()
|
||||||
|
self?.refreshControls()
|
||||||
|
self?.scheduleControlsHide()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
backend.onFailure = { [weak self] error in
|
backend.onFailure = { [weak self] error in
|
||||||
|
|
@ -94,6 +145,16 @@ final class NativePlayerViewController: UIViewController {
|
||||||
self?.showFailure(error)
|
self?.showFailure(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
backend.onStateChange = { [weak self] in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.refreshControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backend.onSubtitleTracksChange = { [weak self] in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.refreshControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startStartupTimer() {
|
private func startStartupTimer() {
|
||||||
|
|
@ -108,8 +169,38 @@ final class NativePlayerViewController: UIViewController {
|
||||||
view.addSubview(backend.view)
|
view.addSubview(backend.view)
|
||||||
view.addSubview(loadingView)
|
view.addSubview(loadingView)
|
||||||
view.addSubview(failureLabel)
|
view.addSubview(failureLabel)
|
||||||
|
view.addSubview(controlsContainer)
|
||||||
view.addSubview(closeButton)
|
view.addSubview(closeButton)
|
||||||
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
|
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
|
||||||
|
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
|
||||||
|
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
|
||||||
|
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
|
||||||
|
captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside)
|
||||||
|
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
|
||||||
|
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
|
||||||
|
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
||||||
|
|
||||||
|
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))
|
||||||
|
tap.cancelsTouchesInView = false
|
||||||
|
view.addGestureRecognizer(tap)
|
||||||
|
|
||||||
|
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])
|
||||||
|
controlRow.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
controlRow.axis = .horizontal
|
||||||
|
controlRow.alignment = .center
|
||||||
|
controlRow.distribution = .equalCentering
|
||||||
|
controlRow.spacing = 18
|
||||||
|
|
||||||
|
let timeRow = UIStackView(arrangedSubviews: [elapsedLabel, remainingLabel])
|
||||||
|
timeRow.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
timeRow.axis = .horizontal
|
||||||
|
timeRow.distribution = .fillEqually
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [scrubber, timeRow, controlRow])
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 8
|
||||||
|
controlsContainer.contentView.addSubview(stack)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
|
@ -127,7 +218,25 @@ final class NativePlayerViewController: UIViewController {
|
||||||
closeButton.widthAnchor.constraint(equalToConstant: 44),
|
closeButton.widthAnchor.constraint(equalToConstant: 44),
|
||||||
closeButton.heightAnchor.constraint(equalToConstant: 44),
|
closeButton.heightAnchor.constraint(equalToConstant: 44),
|
||||||
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
||||||
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12)
|
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
|
||||||
|
|
||||||
|
controlsContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18),
|
||||||
|
controlsContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18),
|
||||||
|
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),
|
||||||
|
|
||||||
|
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 16),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -16),
|
||||||
|
stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 14),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -14),
|
||||||
|
|
||||||
|
jumpBackButton.widthAnchor.constraint(equalToConstant: 44),
|
||||||
|
jumpBackButton.heightAnchor.constraint(equalToConstant: 44),
|
||||||
|
playPauseButton.widthAnchor.constraint(equalToConstant: 54),
|
||||||
|
playPauseButton.heightAnchor.constraint(equalToConstant: 54),
|
||||||
|
jumpForwardButton.widthAnchor.constraint(equalToConstant: 44),
|
||||||
|
jumpForwardButton.heightAnchor.constraint(equalToConstant: 44),
|
||||||
|
captionsButton.widthAnchor.constraint(equalToConstant: 44),
|
||||||
|
captionsButton.heightAnchor.constraint(equalToConstant: 44)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,4 +253,123 @@ final class NativePlayerViewController: UIViewController {
|
||||||
@objc private func close() {
|
@objc private func close() {
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func togglePlayPause() {
|
||||||
|
backend.togglePlayPause()
|
||||||
|
revealControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func jumpBack() {
|
||||||
|
backend.jump(by: -15)
|
||||||
|
revealControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func jumpForward() {
|
||||||
|
backend.jump(by: 15)
|
||||||
|
revealControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func scrubbingStarted() {
|
||||||
|
isScrubbing = true
|
||||||
|
controlsTimer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func scrubberChanged() {
|
||||||
|
elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func scrubbingEnded() {
|
||||||
|
backend.seek(to: scrubber.value)
|
||||||
|
isScrubbing = false
|
||||||
|
revealControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func toggleControlsVisibility() {
|
||||||
|
if controlsContainer.alpha < 1 {
|
||||||
|
revealControls()
|
||||||
|
} else if backend.isPlaying {
|
||||||
|
hideControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showCaptions() {
|
||||||
|
revealControls()
|
||||||
|
let alert = UIAlertController(title: "Captions", message: nil, preferredStyle: .actionSheet)
|
||||||
|
SubtitleOptionMapper.options(from: backend.subtitleTracks).forEach { track in
|
||||||
|
let prefix = track.id == backend.selectedSubtitleTrackID ? "Selected: " : ""
|
||||||
|
alert.addAction(UIAlertAction(title: "\(prefix)\(track.name)", style: .default) { [weak self] _ in
|
||||||
|
self?.backend.selectSubtitleTrack(id: track.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "Delay -0.5s", style: .default) { [weak self] _ in
|
||||||
|
self?.backend.adjustSubtitleDelay(by: -0.5)
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default) { [weak self] _ in
|
||||||
|
self?.backend.adjustSubtitleDelay(by: 0.5)
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: "Current Delay: \(String(format: "%.1fs", backend.subtitleDelay))", style: .default))
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
if let popover = alert.popoverPresentationController {
|
||||||
|
popover.sourceView = captionsButton
|
||||||
|
popover.sourceRect = captionsButton.bounds
|
||||||
|
}
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startProgressUpdates() {
|
||||||
|
progressTimer?.invalidate()
|
||||||
|
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||||
|
self?.refreshControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshControls() {
|
||||||
|
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
|
||||||
|
scrubber.isEnabled = backend.isSeekable
|
||||||
|
jumpBackButton.isEnabled = backend.isSeekable
|
||||||
|
jumpForwardButton.isEnabled = backend.isSeekable
|
||||||
|
captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty
|
||||||
|
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
|
||||||
|
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
|
||||||
|
if !isScrubbing {
|
||||||
|
scrubber.value = backend.position
|
||||||
|
}
|
||||||
|
[scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revealControls() {
|
||||||
|
UIView.animate(withDuration: 0.18) {
|
||||||
|
self.controlsContainer.alpha = 1
|
||||||
|
self.closeButton.alpha = 1
|
||||||
|
}
|
||||||
|
scheduleControlsHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideControls() {
|
||||||
|
UIView.animate(withDuration: 0.24) {
|
||||||
|
self.controlsContainer.alpha = 0
|
||||||
|
self.closeButton.alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleControlsHide() {
|
||||||
|
controlsTimer?.invalidate()
|
||||||
|
guard backend.isPlaying else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
|
||||||
|
self?.hideControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func iconButton(systemName: String, label: String) -> UIButton {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
button.setImage(UIImage(systemName: systemName), for: .normal)
|
||||||
|
button.tintColor = .white
|
||||||
|
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
|
button.layer.cornerRadius = 22
|
||||||
|
button.accessibilityLabel = label
|
||||||
|
return button
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,44 @@ struct NativePlaybackRequest {
|
||||||
let referer: String
|
let referer: String
|
||||||
let headers: [String: String]
|
let headers: [String: String]
|
||||||
let classification: StreamClassification
|
let classification: StreamClassification
|
||||||
|
let subtitleCandidates: [SubtitleCandidate]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubtitleCandidate: Equatable {
|
||||||
|
let url: URL
|
||||||
|
let label: String
|
||||||
|
let language: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubtitleTrack: Equatable {
|
||||||
|
let id: Int32
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PlaybackTimeFormatter {
|
||||||
|
static func label(for seconds: TimeInterval) -> String {
|
||||||
|
guard seconds.isFinite, seconds > 0 else {
|
||||||
|
return "0:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
let roundedSeconds = Int(seconds.rounded())
|
||||||
|
let hours = roundedSeconds / 3600
|
||||||
|
let minutes = (roundedSeconds % 3600) / 60
|
||||||
|
let seconds = roundedSeconds % 60
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
return String(format: "%d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubtitleOptionMapper {
|
||||||
|
static let offTrack = SubtitleTrack(id: -1, name: "Off")
|
||||||
|
|
||||||
|
static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] {
|
||||||
|
[offTrack] + tracks.filter { $0.id >= 0 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StreamClassification {
|
struct StreamClassification {
|
||||||
|
|
@ -41,6 +79,7 @@ struct StreamCandidate {
|
||||||
let observedURL: URL
|
let observedURL: URL
|
||||||
let resolverURL: URL?
|
let resolverURL: URL?
|
||||||
let pageURL: URL?
|
let pageURL: URL?
|
||||||
|
let subtitleCandidates: [SubtitleCandidate]
|
||||||
|
|
||||||
init?(messageBody: Any) {
|
init?(messageBody: Any) {
|
||||||
guard let body = messageBody as? [String: Any],
|
guard let body = messageBody as? [String: Any],
|
||||||
|
|
@ -52,6 +91,7 @@ struct StreamCandidate {
|
||||||
observedURL = observed
|
observedURL = observed
|
||||||
resolverURL = Self.url(from: body["resolverUrl"])
|
resolverURL = Self.url(from: body["resolverUrl"])
|
||||||
pageURL = Self.url(from: body["pageUrl"])
|
pageURL = Self.url(from: body["pageUrl"])
|
||||||
|
subtitleCandidates = SubtitleCandidateParser.candidates(in: body["subtitles"])
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func url(from value: Any?) -> URL? {
|
private static func url(from value: Any?) -> URL? {
|
||||||
|
|
@ -63,6 +103,103 @@ struct StreamCandidate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SubtitleCandidateParser {
|
||||||
|
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
|
||||||
|
private static let urlFields = ["url", "href", "src", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
|
||||||
|
private static let labelFields = ["label", "name", "title", "lang", "language", "id"]
|
||||||
|
|
||||||
|
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
|
||||||
|
var results: [SubtitleCandidate] = []
|
||||||
|
collect(from: payload, into: &results)
|
||||||
|
|
||||||
|
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 collect(from value: Any?, into results: inout [SubtitleCandidate]) {
|
||||||
|
switch value {
|
||||||
|
case let dictionary as [String: Any]:
|
||||||
|
if let candidate = candidate(from: dictionary) {
|
||||||
|
results.append(candidate)
|
||||||
|
}
|
||||||
|
dictionary.values.forEach { collect(from: $0, into: &results) }
|
||||||
|
case let array as [Any]:
|
||||||
|
array.forEach { collect(from: $0, into: &results) }
|
||||||
|
case let string as String:
|
||||||
|
if let url = subtitleURL(from: string) {
|
||||||
|
results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))
|
||||||
|
} else {
|
||||||
|
extractSubtitleURLs(from: string).forEach { url in
|
||||||
|
results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? {
|
||||||
|
guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = labelFields.lazy.compactMap { dictionary[$0] as? String }.first
|
||||||
|
let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)
|
||||||
|
return SubtitleCandidate(
|
||||||
|
url: url,
|
||||||
|
label: label?.isEmpty == false ? label! : defaultLabel(for: url),
|
||||||
|
language: language
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func subtitleURL(from string: String?) -> URL? {
|
||||||
|
guard let string,
|
||||||
|
let url = URL(string: string),
|
||||||
|
["http", "https"].contains(url.scheme?.lowercased())
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let lowercased = url.absoluteString.lowercased()
|
||||||
|
guard supportedExtensions.contains(url.pathExtension.lowercased())
|
||||||
|
|| supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") })
|
||||||
|
|| lowercased.contains("subtitle")
|
||||||
|
|| lowercased.contains("opensubtitles")
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultLabel(for url: URL) -> String {
|
||||||
|
let lastPathComponent = url.deletingPathExtension().lastPathComponent
|
||||||
|
return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractSubtitleURLs(from string: String) -> [URL] {
|
||||||
|
let pattern = #"https?://[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*"#
|
||||||
|
let range = NSRange(string.startIndex..<string.endIndex, in: string)
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return regex.matches(in: string, range: range).compactMap { match in
|
||||||
|
guard let range = Range(match.range, in: string) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return subtitleURL(from: String(string[range]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum StreamClassifier {
|
enum StreamClassifier {
|
||||||
static let referer = "https://web.stremio.com/"
|
static let referer = "https://web.stremio.com/"
|
||||||
|
|
||||||
|
|
@ -83,7 +220,8 @@ enum StreamClassifier {
|
||||||
userAgent: userAgent,
|
userAgent: userAgent,
|
||||||
referer: referer,
|
referer: referer,
|
||||||
headers: Self.defaultHeaders(userAgent: userAgent),
|
headers: Self.defaultHeaders(userAgent: userAgent),
|
||||||
classification: classification
|
classification: classification,
|
||||||
|
subtitleCandidates: candidate.subtitleCandidates
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,13 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
var onReady: (() -> Void)?
|
var onReady: (() -> Void)?
|
||||||
var onFailure: ((Error) -> Void)?
|
var onFailure: ((Error) -> Void)?
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onSubtitleTracksChange: (() -> Void)?
|
||||||
|
|
||||||
#if canImport(MobileVLCKit)
|
#if canImport(MobileVLCKit)
|
||||||
private let mediaPlayer = VLCMediaPlayer()
|
private let mediaPlayer = VLCMediaPlayer()
|
||||||
#endif
|
#endif
|
||||||
|
private var attachedSubtitleURLs = Set<URL>()
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
|
|
@ -54,11 +57,61 @@ 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)
|
||||||
#else
|
#else
|
||||||
onFailure?(NativePlaybackError.backendUnavailable)
|
onFailure?(NativePlaybackError.backendUnavailable)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
mediaPlayer.play()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
mediaPlayer.pause()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayPause() {
|
||||||
|
isPlaying ? pause() : play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to position: Float) {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
guard isSeekable else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mediaPlayer.position = max(0, min(1, position))
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func jump(by seconds: TimeInterval) {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
guard isSeekable else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let nextTime = max(0, min(duration, currentTime + seconds))
|
||||||
|
mediaPlayer.time = VLCTime(int: Int32(nextTime * 1000))
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectSubtitleTrack(id: Int32) {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
mediaPlayer.currentVideoSubTitleIndex = id
|
||||||
|
onSubtitleTracksChange?()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func adjustSubtitleDelay(by seconds: TimeInterval) {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000)
|
||||||
|
onSubtitleTracksChange?()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
#if canImport(MobileVLCKit)
|
#if canImport(MobileVLCKit)
|
||||||
mediaPlayer.stop()
|
mediaPlayer.stop()
|
||||||
|
|
@ -66,6 +119,93 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
mediaPlayer.media = nil
|
mediaPlayer.media = nil
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isPlaying: Bool {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
mediaPlayer.isPlaying
|
||||||
|
#else
|
||||||
|
false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var isSeekable: Bool {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
mediaPlayer.isSeekable
|
||||||
|
#else
|
||||||
|
false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration: TimeInterval {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
TimeInterval(max(0, mediaPlayer.media?.length.intValue ?? 0)) / 1000
|
||||||
|
#else
|
||||||
|
0
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentTime: TimeInterval {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
TimeInterval(max(0, mediaPlayer.time.intValue)) / 1000
|
||||||
|
#else
|
||||||
|
0
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingTime: TimeInterval {
|
||||||
|
max(0, duration - currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
var position: Float {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
mediaPlayer.position
|
||||||
|
#else
|
||||||
|
0
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var subtitleTracks: [SubtitleTrack] {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
|
||||||
|
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
|
||||||
|
return zip(indexes, names).map { index, name in
|
||||||
|
SubtitleTrack(id: index.int32Value, name: name)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
[]
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedSubtitleTrackID: Int32 {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
mediaPlayer.currentVideoSubTitleIndex
|
||||||
|
#else
|
||||||
|
-1
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var subtitleDelay: TimeInterval {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
TimeInterval(mediaPlayer.currentVideoSubTitleDelay) / 1_000_000
|
||||||
|
#else
|
||||||
|
0
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
private func attachSubtitles(_ candidates: [SubtitleCandidate]) {
|
||||||
|
candidates.forEach { candidate in
|
||||||
|
guard !attachedSubtitleURLs.contains(candidate.url) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attachedSubtitleURLs.insert(candidate.url)
|
||||||
|
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(MobileVLCKit)
|
#if canImport(MobileVLCKit)
|
||||||
|
|
@ -77,8 +217,13 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
||||||
switch mediaPlayer.state {
|
switch mediaPlayer.state {
|
||||||
case .buffering, .playing:
|
case .buffering, .playing:
|
||||||
onReady?()
|
onReady?()
|
||||||
|
onStateChange?()
|
||||||
case .error:
|
case .error:
|
||||||
onFailure?(NativePlaybackError.playbackFailed)
|
onFailure?(NativePlaybackError.playbackFailed)
|
||||||
|
case .paused, .stopped, .ended:
|
||||||
|
onStateChange?()
|
||||||
|
case .esAdded:
|
||||||
|
onSubtitleTracksChange?()
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ struct StreamResolverTests {
|
||||||
testResolverSelectsUnsupportedDirectURLAndHeaders()
|
testResolverSelectsUnsupportedDirectURLAndHeaders()
|
||||||
testResolverRejectsHLSOnlyResponse()
|
testResolverRejectsHLSOnlyResponse()
|
||||||
testRedactorHandlesPercentEncodedPath()
|
testRedactorHandlesPercentEncodedPath()
|
||||||
|
testPlaybackTimeFormatting()
|
||||||
|
testSubtitleCandidateParsing()
|
||||||
|
testSubtitleOptionMappingIncludesOff()
|
||||||
print("StreamResolverTests passed")
|
print("StreamResolverTests passed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +78,48 @@ struct StreamResolverTests {
|
||||||
assertEqual(redacted, "https://cdn.example.test/video/%5Bredacted%5D/%E2%9C%93.mp4")
|
assertEqual(redacted, "https://cdn.example.test/video/%5Bredacted%5D/%E2%9C%93.mp4")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func testPlaybackTimeFormatting() {
|
||||||
|
assertEqual(PlaybackTimeFormatter.label(for: 0), "0:00")
|
||||||
|
assertEqual(PlaybackTimeFormatter.label(for: 65), "1:05")
|
||||||
|
assertEqual(PlaybackTimeFormatter.label(for: 3_725), "1:02:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func testSubtitleCandidateParsing() {
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"subtitles": [
|
||||||
|
[
|
||||||
|
"lang": "eng",
|
||||||
|
"url": "https://opensubtitles.example.test/download/subtitle.srt?token=secret"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"language": "Spanish",
|
||||||
|
"file": "https://cdn.example.test/movie.es.vtt"
|
||||||
|
],
|
||||||
|
"https://cdn.example.test/ignored.txt"
|
||||||
|
],
|
||||||
|
"nested": [
|
||||||
|
"body": "metadata https://cdn.example.test/movie.fr.ass?download=1"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
let candidates = SubtitleCandidateParser.candidates(in: payload)
|
||||||
|
|
||||||
|
assertEqual(candidates.count, 3)
|
||||||
|
assertEqual(candidates[0].language, "eng")
|
||||||
|
assertEqual(candidates[1].label, "Spanish")
|
||||||
|
assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func testSubtitleOptionMappingIncludesOff() {
|
||||||
|
let options = SubtitleOptionMapper.options(from: [
|
||||||
|
SubtitleTrack(id: 2, name: "English"),
|
||||||
|
SubtitleTrack(id: 5, name: "Spanish")
|
||||||
|
])
|
||||||
|
|
||||||
|
assertEqual(options.map(\.name), ["Off", "English", "Spanish"])
|
||||||
|
assertEqual(options.first?.id, -1)
|
||||||
|
}
|
||||||
|
|
||||||
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
|
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
|
||||||
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
|
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue