add native player controls and captions

This commit is contained in:
dirtydishes 2026-05-25 01:05:13 -04:00
parent 75e76e14d4
commit 419ffae415
9 changed files with 1096 additions and 4 deletions

View file

@ -72,6 +72,8 @@ final class DreamioWebViewController: UIViewController {
/\.m3u8(?:[?#]|$)/i,
/\.mp4(?:[?#]|$)/i
];
const subtitleCandidates = [];
const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
const looksNative = (url) => {
if (!url || typeof url !== "string") {
@ -111,11 +113,72 @@ final class DreamioWebViewController: UIViewController {
resolverUrl: findResolverURL(),
pageUrl: window.location.href,
tagName: element && element.tagName ? element.tagName : "",
currentSrc: element && element.currentSrc ? element.currentSrc : ""
currentSrc: element && element.currentSrc ? element.currentSrc : "",
subtitles: subtitleCandidates.slice(-20)
});
} 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 media = element instanceof HTMLVideoElement
? element
@ -387,11 +450,13 @@ final class DreamioWebViewController: UIViewController {
userAgent: request.userAgent,
referer: request.referer,
headers: resolved.headers,
classification: request.classification
classification: request.classification,
subtitleCandidates: request.subtitleCandidates
)
let player = NativePlayerViewController(request: resolvedRequest)
player.onDismiss = { [weak self] in
self?.lastNativePlaybackURL = nil
self?.cleanUpStremioPlayerAfterNativeDismiss()
}
present(player, animated: true)
} catch {
@ -423,6 +488,72 @@ final class DreamioWebViewController: UIViewController {
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
private func logDiagnostic(type: String, payload: Any, pageURL: String?) {
let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown"

View file

@ -4,9 +4,27 @@ protocol NativePlaybackBackend: AnyObject {
var view: UIView { get }
var onReady: (() -> 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 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()
}

View file

@ -4,6 +4,9 @@ final class NativePlayerViewController: UIViewController {
private let request: NativePlaybackRequest
private var backend: NativePlaybackBackend
private var startupTimer: Timer?
private var controlsTimer: Timer?
private var progressTimer: Timer?
private var isScrubbing = false
var onDismiss: (() -> Void)?
private let loadingView: UIActivityIndicatorView = {
@ -25,6 +28,49 @@ final class NativePlayerViewController: UIViewController {
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 = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
@ -74,6 +120,8 @@ final class NativePlayerViewController: UIViewController {
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
startupTimer?.invalidate()
controlsTimer?.invalidate()
progressTimer?.invalidate()
backend.stop()
onDismiss?()
}
@ -86,6 +134,9 @@ final class NativePlayerViewController: UIViewController {
self?.startupTimer?.invalidate()
self?.loadingView.stopAnimating()
self?.loadingView.isHidden = true
self?.startProgressUpdates()
self?.refreshControls()
self?.scheduleControlsHide()
}
}
backend.onFailure = { [weak self] error in
@ -94,6 +145,16 @@ final class NativePlayerViewController: UIViewController {
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() {
@ -108,8 +169,38 @@ final class NativePlayerViewController: UIViewController {
view.addSubview(backend.view)
view.addSubview(loadingView)
view.addSubview(failureLabel)
view.addSubview(controlsContainer)
view.addSubview(closeButton)
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([
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
@ -127,7 +218,25 @@ final class NativePlayerViewController: UIViewController {
closeButton.widthAnchor.constraint(equalToConstant: 44),
closeButton.heightAnchor.constraint(equalToConstant: 44),
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() {
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
}
}

View file

@ -26,6 +26,44 @@ struct NativePlaybackRequest {
let referer: String
let headers: [String: String]
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 {
@ -41,6 +79,7 @@ struct StreamCandidate {
let observedURL: URL
let resolverURL: URL?
let pageURL: URL?
let subtitleCandidates: [SubtitleCandidate]
init?(messageBody: Any) {
guard let body = messageBody as? [String: Any],
@ -52,6 +91,7 @@ struct StreamCandidate {
observedURL = observed
resolverURL = Self.url(from: body["resolverUrl"])
pageURL = Self.url(from: body["pageUrl"])
subtitleCandidates = SubtitleCandidateParser.candidates(in: body["subtitles"])
}
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 {
static let referer = "https://web.stremio.com/"
@ -83,7 +220,8 @@ enum StreamClassifier {
userAgent: userAgent,
referer: referer,
headers: Self.defaultHeaders(userAgent: userAgent),
classification: classification
classification: classification,
subtitleCandidates: candidate.subtitleCandidates
)
}

View file

@ -16,10 +16,13 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
let view = UIView()
var onReady: (() -> Void)?
var onFailure: ((Error) -> Void)?
var onStateChange: (() -> Void)?
var onSubtitleTracksChange: (() -> Void)?
#if canImport(MobileVLCKit)
private let mediaPlayer = VLCMediaPlayer()
#endif
private var attachedSubtitleURLs = Set<URL>()
override init() {
super.init()
@ -54,11 +57,61 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
mediaPlayer.play()
attachSubtitles(request.subtitleCandidates)
#else
onFailure?(NativePlaybackError.backendUnavailable)
#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() {
#if canImport(MobileVLCKit)
mediaPlayer.stop()
@ -66,6 +119,93 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
mediaPlayer.media = nil
#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)
@ -77,8 +217,13 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
switch mediaPlayer.state {
case .buffering, .playing:
onReady?()
onStateChange?()
case .error:
onFailure?(NativePlaybackError.playbackFailed)
case .paused, .stopped, .ended:
onStateChange?()
case .esAdded:
onSubtitleTracksChange?()
default:
break
}