mirror of
https://github.com/yattee/yattee.git
synced 2026-11-02 09:57:20 +00:00
Move Open Link's video resolving and auto-download into LinkVideoResolver
No behaviour change; URL scheme actions will reuse it.
This commit is contained in:
194
Yattee/Services/Navigation/LinkVideoResolver.swift
Normal file
194
Yattee/Services/Navigation/LinkVideoResolver.swift
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
//
|
||||||
|
// LinkVideoResolver.swift
|
||||||
|
// Yattee
|
||||||
|
//
|
||||||
|
// Turns a link into a video and its streams, and queues downloads from them.
|
||||||
|
// Shared by the Open Link sheet and URL scheme actions.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct LinkVideoResolver {
|
||||||
|
let appEnvironment: AppEnvironment
|
||||||
|
|
||||||
|
/// Resolves a link to a playable video with its streams, captions and
|
||||||
|
/// storyboards: YouTube/PeerTube through the matching source, direct media
|
||||||
|
/// as-is, anything else through Yattee Server's extraction.
|
||||||
|
func resolve(_ url: URL) async throws -> (Video, [Stream], [Caption], [Storyboard]) {
|
||||||
|
let router = URLRouter()
|
||||||
|
let destination = router.route(url)
|
||||||
|
|
||||||
|
switch destination {
|
||||||
|
case .video(let source, _):
|
||||||
|
guard case .id(let videoID) = source else {
|
||||||
|
throw LinkResolveError.notAVideo
|
||||||
|
}
|
||||||
|
// YouTube/PeerTube - use content-aware instance selection
|
||||||
|
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
|
||||||
|
throw LinkResolveError.noInstanceAvailable
|
||||||
|
}
|
||||||
|
let (video, streams, captions, storyboards) = try await appEnvironment.contentService
|
||||||
|
.videoWithProxyStreamsAndCaptionsAndStoryboards(
|
||||||
|
id: videoID.videoID,
|
||||||
|
instance: instance
|
||||||
|
)
|
||||||
|
return (video, streams, captions, storyboards)
|
||||||
|
|
||||||
|
case .directMedia(let mediaURL):
|
||||||
|
// Direct media URL - no extraction needed, no captions/storyboards
|
||||||
|
let video = DirectMediaHelper.createVideo(from: mediaURL)
|
||||||
|
let stream = DirectMediaHelper.createStream(from: mediaURL)
|
||||||
|
return (video, [stream], [], [])
|
||||||
|
|
||||||
|
case .externalVideo, nil:
|
||||||
|
// A file mpv plays as-is needs no extraction, no captions/storyboards
|
||||||
|
if let probe = await DirectMediaProbe.probe(url) {
|
||||||
|
let video = DirectMediaHelper.createVideo(from: url, probe: probe)
|
||||||
|
let stream = DirectMediaHelper.createStream(from: url, probe: probe)
|
||||||
|
return (video, [stream], [], [])
|
||||||
|
}
|
||||||
|
// External URL - use Yattee Server (doesn't support storyboards)
|
||||||
|
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
|
||||||
|
throw LinkResolveError.noYatteeServer
|
||||||
|
}
|
||||||
|
let (video, streams, captions) = try await appEnvironment.contentService
|
||||||
|
.extractURL(url, instance: instance)
|
||||||
|
return (video, streams, captions, [])
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw LinkResolveError.notAVideo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
/// Enqueues a single video for download with already-fetched streams.
|
||||||
|
func enqueueDownload(
|
||||||
|
video: Video,
|
||||||
|
streams: [Stream],
|
||||||
|
captions: [Caption],
|
||||||
|
storyboards: [Storyboard],
|
||||||
|
quality: DownloadQuality,
|
||||||
|
includeSubtitles: Bool
|
||||||
|
) async throws {
|
||||||
|
// Select best video stream
|
||||||
|
let videoStream = selectBestVideoStream(from: streams, maxQuality: quality)
|
||||||
|
guard let videoStream else {
|
||||||
|
throw DownloadError.noStreamAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select audio stream if needed
|
||||||
|
var audioStream: Stream?
|
||||||
|
if videoStream.isVideoOnly {
|
||||||
|
audioStream = selectBestAudioStream(
|
||||||
|
from: streams,
|
||||||
|
preferredLanguage: appEnvironment.settingsManager.preferredAudioLanguage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select caption if enabled
|
||||||
|
var caption: Caption?
|
||||||
|
if includeSubtitles, let preferredLang = appEnvironment.settingsManager.preferredSubtitlesLanguage {
|
||||||
|
caption = selectBestCaption(from: captions, preferredLanguage: preferredLang)
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioCodec = videoStream.isMuxed ? videoStream.audioCodec : audioStream?.audioCodec
|
||||||
|
let audioBitrate = videoStream.isMuxed ? nil : audioStream?.bitrate
|
||||||
|
|
||||||
|
try await appEnvironment.downloadManager.enqueue(
|
||||||
|
video,
|
||||||
|
quality: videoStream.qualityLabel,
|
||||||
|
formatID: videoStream.format,
|
||||||
|
streamURL: videoStream.url,
|
||||||
|
audioStreamURL: videoStream.isVideoOnly ? audioStream?.url : nil,
|
||||||
|
captionURL: caption?.url,
|
||||||
|
audioLanguage: audioStream?.audioLanguage,
|
||||||
|
captionLanguage: caption?.languageCode,
|
||||||
|
httpHeaders: videoStream.httpHeaders,
|
||||||
|
storyboard: storyboards.highest(),
|
||||||
|
dislikeCount: nil,
|
||||||
|
videoCodec: videoStream.videoCodec,
|
||||||
|
audioCodec: audioCodec,
|
||||||
|
videoBitrate: videoStream.bitrate,
|
||||||
|
audioBitrate: audioBitrate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stream Selection Helpers
|
||||||
|
|
||||||
|
private func selectBestVideoStream(from streams: [Stream], maxQuality: DownloadQuality) -> Stream? {
|
||||||
|
let maxRes = maxQuality.maxResolution
|
||||||
|
|
||||||
|
let videoStreams = streams
|
||||||
|
.filter { !$0.isAudioOnly && $0.resolution != nil }
|
||||||
|
.filter {
|
||||||
|
let format = StreamFormat.detect(from: $0)
|
||||||
|
return format != .hls && format != .dash
|
||||||
|
}
|
||||||
|
.sorted { s1, s2 in
|
||||||
|
let res1 = s1.resolution ?? .p360
|
||||||
|
let res2 = s2.resolution ?? .p360
|
||||||
|
if res1 != res2 { return res1 > res2 }
|
||||||
|
if s1.isMuxed != s2.isMuxed { return s1.isMuxed }
|
||||||
|
return HardwareCapabilities.shared.codecPriority(for: s1.videoCodec) >
|
||||||
|
HardwareCapabilities.shared.codecPriority(for: s2.videoCodec)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let maxRes else {
|
||||||
|
return videoStreams.first
|
||||||
|
}
|
||||||
|
|
||||||
|
if let stream = videoStreams.first(where: { ($0.resolution ?? .p360) <= maxRes }) {
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
return videoStreams.last
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectBestAudioStream(from streams: [Stream], preferredLanguage: String?) -> Stream? {
|
||||||
|
let audioStreams = streams.filter { $0.isAudioOnly }
|
||||||
|
|
||||||
|
if let preferred = preferredLanguage {
|
||||||
|
if let match = audioStreams.first(where: { ($0.audioLanguage ?? "").hasPrefix(preferred) }) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let original = audioStreams.first(where: { $0.isOriginalAudio }) {
|
||||||
|
return original
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioStreams.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectBestCaption(from captions: [Caption], preferredLanguage: String) -> Caption? {
|
||||||
|
if let exact = captions.first(where: { $0.languageCode == preferredLanguage }) {
|
||||||
|
return exact
|
||||||
|
}
|
||||||
|
if let prefix = captions.first(where: {
|
||||||
|
$0.languageCode.hasPrefix(preferredLanguage) || $0.baseLanguageCode == preferredLanguage
|
||||||
|
}) {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
enum LinkResolveError: LocalizedError {
|
||||||
|
case noInstanceAvailable
|
||||||
|
case noYatteeServer
|
||||||
|
case notAVideo
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .noInstanceAvailable:
|
||||||
|
return String(localized: "openLink.noInstance")
|
||||||
|
case .noYatteeServer:
|
||||||
|
return String(localized: "openLink.noYatteeServer")
|
||||||
|
case .notAVideo:
|
||||||
|
return String(localized: "openLink.notAVideo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -674,7 +674,7 @@ struct OpenLinkFormView: View {
|
|||||||
extractedItems[index].status = .extracting
|
extractedItems[index].status = .extracting
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (video, streams) = try await extractVideo(from: url, appEnvironment: appEnvironment)
|
let (video, streams, _, _) = try await LinkVideoResolver(appEnvironment: appEnvironment).resolve(url)
|
||||||
extractedItems[index].status = .success
|
extractedItems[index].status = .success
|
||||||
extractedItems[index].video = video
|
extractedItems[index].video = video
|
||||||
extractedItems[index].streams = streams
|
extractedItems[index].streams = streams
|
||||||
@@ -723,56 +723,6 @@ struct OpenLinkFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts video from URL, routing through appropriate API.
|
|
||||||
private func extractVideo(
|
|
||||||
from url: URL,
|
|
||||||
appEnvironment: AppEnvironment
|
|
||||||
) async throws -> (Video, [Stream]) {
|
|
||||||
let router = URLRouter()
|
|
||||||
let destination = router.route(url)
|
|
||||||
|
|
||||||
switch destination {
|
|
||||||
case .video(let source, _):
|
|
||||||
guard case .id(let videoID) = source else {
|
|
||||||
throw OpenLinkError.notAVideo
|
|
||||||
}
|
|
||||||
// YouTube/PeerTube - use content-aware instance selection
|
|
||||||
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
|
|
||||||
throw OpenLinkError.noInstanceAvailable
|
|
||||||
}
|
|
||||||
let (video, streams, _, _) = try await appEnvironment.contentService
|
|
||||||
.videoWithProxyStreamsAndCaptionsAndStoryboards(
|
|
||||||
id: videoID.videoID,
|
|
||||||
instance: instance
|
|
||||||
)
|
|
||||||
return (video, streams)
|
|
||||||
|
|
||||||
case .directMedia(let mediaURL):
|
|
||||||
// Direct media URL - no extraction needed
|
|
||||||
let video = DirectMediaHelper.createVideo(from: mediaURL)
|
|
||||||
let stream = DirectMediaHelper.createStream(from: mediaURL)
|
|
||||||
return (video, [stream])
|
|
||||||
|
|
||||||
case .externalVideo, nil:
|
|
||||||
// A file mpv plays as-is needs no extraction
|
|
||||||
if let probe = await DirectMediaProbe.probe(url) {
|
|
||||||
let video = DirectMediaHelper.createVideo(from: url, probe: probe)
|
|
||||||
let stream = DirectMediaHelper.createStream(from: url, probe: probe)
|
|
||||||
return (video, [stream])
|
|
||||||
}
|
|
||||||
// External URL - use Yattee Server
|
|
||||||
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
|
|
||||||
throw OpenLinkError.noYatteeServer
|
|
||||||
}
|
|
||||||
let (video, streams, _) = try await appEnvironment.contentService
|
|
||||||
.extractURL(url, instance: instance)
|
|
||||||
return (video, streams)
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw OpenLinkError.notAVideo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func playVideo(_ video: Video, sourceURL: URL, appEnvironment: AppEnvironment) {
|
private func playVideo(_ video: Video, sourceURL: URL, appEnvironment: AppEnvironment) {
|
||||||
// Don't pass a specific stream - let the player's selectStreamAndBackend
|
// Don't pass a specific stream - let the player's selectStreamAndBackend
|
||||||
// choose the best video+audio combination. Using streams.first would
|
// choose the best video+audio combination. Using streams.first would
|
||||||
@@ -801,7 +751,7 @@ struct OpenLinkFormView: View {
|
|||||||
extractedItems[index].status = .extracting
|
extractedItems[index].status = .extracting
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (video, streams, captions, storyboards) = try await extractVideoFull(from: url, appEnvironment: appEnvironment)
|
let (video, streams, captions, storyboards) = try await LinkVideoResolver(appEnvironment: appEnvironment).resolve(url)
|
||||||
extractedItems[index].status = .success
|
extractedItems[index].status = .success
|
||||||
extractedItems[index].video = video
|
extractedItems[index].video = video
|
||||||
extractedItems[index].streams = streams
|
extractedItems[index].streams = streams
|
||||||
@@ -811,14 +761,13 @@ struct OpenLinkFormView: View {
|
|||||||
|
|
||||||
// If auto-download is configured, enqueue immediately
|
// If auto-download is configured, enqueue immediately
|
||||||
if downloadSettings.preferredDownloadQuality != .ask {
|
if downloadSettings.preferredDownloadQuality != .ask {
|
||||||
try await enqueueDownload(
|
try await LinkVideoResolver(appEnvironment: appEnvironment).enqueueDownload(
|
||||||
video: video,
|
video: video,
|
||||||
streams: streams,
|
streams: streams,
|
||||||
captions: captions,
|
captions: captions,
|
||||||
storyboards: storyboards,
|
storyboards: storyboards,
|
||||||
quality: downloadSettings.preferredDownloadQuality,
|
quality: downloadSettings.preferredDownloadQuality,
|
||||||
includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload,
|
includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload
|
||||||
appEnvironment: appEnvironment
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -871,56 +820,6 @@ struct OpenLinkFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts video with full details (including captions and storyboards) for download.
|
|
||||||
private func extractVideoFull(
|
|
||||||
from url: URL,
|
|
||||||
appEnvironment: AppEnvironment
|
|
||||||
) async throws -> (Video, [Stream], [Caption], [Storyboard]) {
|
|
||||||
let router = URLRouter()
|
|
||||||
let destination = router.route(url)
|
|
||||||
|
|
||||||
switch destination {
|
|
||||||
case .video(let source, _):
|
|
||||||
guard case .id(let videoID) = source else {
|
|
||||||
throw OpenLinkError.notAVideo
|
|
||||||
}
|
|
||||||
// YouTube/PeerTube - use content-aware instance selection
|
|
||||||
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
|
|
||||||
throw OpenLinkError.noInstanceAvailable
|
|
||||||
}
|
|
||||||
let (video, streams, captions, storyboards) = try await appEnvironment.contentService
|
|
||||||
.videoWithProxyStreamsAndCaptionsAndStoryboards(
|
|
||||||
id: videoID.videoID,
|
|
||||||
instance: instance
|
|
||||||
)
|
|
||||||
return (video, streams, captions, storyboards)
|
|
||||||
|
|
||||||
case .directMedia(let mediaURL):
|
|
||||||
// Direct media URL - no extraction needed, no captions/storyboards
|
|
||||||
let video = DirectMediaHelper.createVideo(from: mediaURL)
|
|
||||||
let stream = DirectMediaHelper.createStream(from: mediaURL)
|
|
||||||
return (video, [stream], [], [])
|
|
||||||
|
|
||||||
case .externalVideo, nil:
|
|
||||||
// A file mpv plays as-is needs no extraction, no captions/storyboards
|
|
||||||
if let probe = await DirectMediaProbe.probe(url) {
|
|
||||||
let video = DirectMediaHelper.createVideo(from: url, probe: probe)
|
|
||||||
let stream = DirectMediaHelper.createStream(from: url, probe: probe)
|
|
||||||
return (video, [stream], [], [])
|
|
||||||
}
|
|
||||||
// External URL - use Yattee Server (doesn't support storyboards)
|
|
||||||
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
|
|
||||||
throw OpenLinkError.noYatteeServer
|
|
||||||
}
|
|
||||||
let (video, streams, captions) = try await appEnvironment.contentService
|
|
||||||
.extractURL(url, instance: instance)
|
|
||||||
return (video, streams, captions, [])
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw OpenLinkError.notAVideo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Downloads pending items after user selects quality.
|
/// Downloads pending items after user selects quality.
|
||||||
private func downloadPendingItems(quality: DownloadQuality, includeSubtitles: Bool) async {
|
private func downloadPendingItems(quality: DownloadQuality, includeSubtitles: Bool) async {
|
||||||
guard let appEnvironment else { return }
|
guard let appEnvironment else { return }
|
||||||
@@ -932,14 +831,13 @@ struct OpenLinkFormView: View {
|
|||||||
guard let video = item.video else { continue }
|
guard let video = item.video else { continue }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await enqueueDownload(
|
try await LinkVideoResolver(appEnvironment: appEnvironment).enqueueDownload(
|
||||||
video: video,
|
video: video,
|
||||||
streams: item.streams,
|
streams: item.streams,
|
||||||
captions: item.captions,
|
captions: item.captions,
|
||||||
storyboards: item.storyboards,
|
storyboards: item.storyboards,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
includeSubtitles: includeSubtitles,
|
includeSubtitles: includeSubtitles
|
||||||
appEnvironment: appEnvironment
|
|
||||||
)
|
)
|
||||||
successCount += 1
|
successCount += 1
|
||||||
} catch {
|
} catch {
|
||||||
@@ -969,139 +867,9 @@ struct OpenLinkFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueues a single video for download with already-fetched streams.
|
|
||||||
private func enqueueDownload(
|
|
||||||
video: Video,
|
|
||||||
streams: [Stream],
|
|
||||||
captions: [Caption],
|
|
||||||
storyboards: [Storyboard],
|
|
||||||
quality: DownloadQuality,
|
|
||||||
includeSubtitles: Bool,
|
|
||||||
appEnvironment: AppEnvironment
|
|
||||||
) async throws {
|
|
||||||
// Select best video stream
|
|
||||||
let videoStream = selectBestVideoStream(from: streams, maxQuality: quality)
|
|
||||||
guard let videoStream else {
|
|
||||||
throw DownloadError.noStreamAvailable
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select audio stream if needed
|
|
||||||
var audioStream: Stream?
|
|
||||||
if videoStream.isVideoOnly {
|
|
||||||
audioStream = selectBestAudioStream(
|
|
||||||
from: streams,
|
|
||||||
preferredLanguage: appEnvironment.settingsManager.preferredAudioLanguage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select caption if enabled
|
|
||||||
var caption: Caption?
|
|
||||||
if includeSubtitles, let preferredLang = appEnvironment.settingsManager.preferredSubtitlesLanguage {
|
|
||||||
caption = selectBestCaption(from: captions, preferredLanguage: preferredLang)
|
|
||||||
}
|
|
||||||
|
|
||||||
let audioCodec = videoStream.isMuxed ? videoStream.audioCodec : audioStream?.audioCodec
|
|
||||||
let audioBitrate = videoStream.isMuxed ? nil : audioStream?.bitrate
|
|
||||||
|
|
||||||
try await appEnvironment.downloadManager.enqueue(
|
|
||||||
video,
|
|
||||||
quality: videoStream.qualityLabel,
|
|
||||||
formatID: videoStream.format,
|
|
||||||
streamURL: videoStream.url,
|
|
||||||
audioStreamURL: videoStream.isVideoOnly ? audioStream?.url : nil,
|
|
||||||
captionURL: caption?.url,
|
|
||||||
audioLanguage: audioStream?.audioLanguage,
|
|
||||||
captionLanguage: caption?.languageCode,
|
|
||||||
httpHeaders: videoStream.httpHeaders,
|
|
||||||
storyboard: storyboards.highest(),
|
|
||||||
dislikeCount: nil,
|
|
||||||
videoCodec: videoStream.videoCodec,
|
|
||||||
audioCodec: audioCodec,
|
|
||||||
videoBitrate: videoStream.bitrate,
|
|
||||||
audioBitrate: audioBitrate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Stream Selection Helpers
|
|
||||||
|
|
||||||
private func selectBestVideoStream(from streams: [Stream], maxQuality: DownloadQuality) -> Stream? {
|
|
||||||
let maxRes = maxQuality.maxResolution
|
|
||||||
|
|
||||||
let videoStreams = streams
|
|
||||||
.filter { !$0.isAudioOnly && $0.resolution != nil }
|
|
||||||
.filter {
|
|
||||||
let format = StreamFormat.detect(from: $0)
|
|
||||||
return format != .hls && format != .dash
|
|
||||||
}
|
|
||||||
.sorted { s1, s2 in
|
|
||||||
let res1 = s1.resolution ?? .p360
|
|
||||||
let res2 = s2.resolution ?? .p360
|
|
||||||
if res1 != res2 { return res1 > res2 }
|
|
||||||
if s1.isMuxed != s2.isMuxed { return s1.isMuxed }
|
|
||||||
return HardwareCapabilities.shared.codecPriority(for: s1.videoCodec) >
|
|
||||||
HardwareCapabilities.shared.codecPriority(for: s2.videoCodec)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let maxRes else {
|
|
||||||
return videoStreams.first
|
|
||||||
}
|
|
||||||
|
|
||||||
if let stream = videoStreams.first(where: { ($0.resolution ?? .p360) <= maxRes }) {
|
|
||||||
return stream
|
|
||||||
}
|
|
||||||
|
|
||||||
return videoStreams.last
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectBestAudioStream(from streams: [Stream], preferredLanguage: String?) -> Stream? {
|
|
||||||
let audioStreams = streams.filter { $0.isAudioOnly }
|
|
||||||
|
|
||||||
if let preferred = preferredLanguage {
|
|
||||||
if let match = audioStreams.first(where: { ($0.audioLanguage ?? "").hasPrefix(preferred) }) {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let original = audioStreams.first(where: { $0.isOriginalAudio }) {
|
|
||||||
return original
|
|
||||||
}
|
|
||||||
|
|
||||||
return audioStreams.first
|
|
||||||
}
|
|
||||||
|
|
||||||
private func selectBestCaption(from captions: [Caption], preferredLanguage: String) -> Caption? {
|
|
||||||
if let exact = captions.first(where: { $0.languageCode == preferredLanguage }) {
|
|
||||||
return exact
|
|
||||||
}
|
|
||||||
if let prefix = captions.first(where: {
|
|
||||||
$0.languageCode.hasPrefix(preferredLanguage) || $0.baseLanguageCode == preferredLanguage
|
|
||||||
}) {
|
|
||||||
return prefix
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Errors
|
|
||||||
|
|
||||||
private enum OpenLinkError: LocalizedError {
|
|
||||||
case noInstanceAvailable
|
|
||||||
case noYatteeServer
|
|
||||||
case notAVideo
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .noInstanceAvailable:
|
|
||||||
return String(localized: "openLink.noInstance")
|
|
||||||
case .noYatteeServer:
|
|
||||||
return String(localized: "openLink.noYatteeServer")
|
|
||||||
case .notAVideo:
|
|
||||||
return String(localized: "openLink.notAVideo")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
Reference in New Issue
Block a user