feat: select Miruro playback and caption providers
This commit is contained in:
@@ -171,6 +171,41 @@ struct PlayerQualityMenu: View {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PlayerMiruroMenu: View {
|
||||
@ObservedObject var model: PlayerModel
|
||||
|
||||
var body: some View {
|
||||
if !model.miruroVideoProviders.isEmpty {
|
||||
Menu("Miruro") {
|
||||
Menu("Video Host") {
|
||||
Button(model.miruroVideoProvider.isEmpty ? "✓ Automatic" : "Automatic") {
|
||||
model.setMiruroVideoProvider("")
|
||||
}
|
||||
ForEach(model.miruroVideoProviders, id: \.self) { provider in
|
||||
Button(model.miruroVideoProvider == provider ? "✓ \(provider)" : provider) {
|
||||
model.setMiruroVideoProvider(provider)
|
||||
}
|
||||
}
|
||||
Text("Current: \(model.selectedMiruroVideoProvider ?? "None")")
|
||||
}
|
||||
Menu("Caption Provider") {
|
||||
Button(model.miruroCaptionProvider.isEmpty ? "✓ Automatic" : "Automatic") {
|
||||
model.setMiruroCaptionProvider("")
|
||||
}
|
||||
ForEach(model.miruroCaptionProviders, id: \.self) { provider in
|
||||
Button(model.miruroCaptionProvider == provider ? "✓ \(provider)" : provider) {
|
||||
model.setMiruroCaptionProvider(provider)
|
||||
}
|
||||
}
|
||||
Text("Current: \(model.selectedMiruroCaptionProvider ?? "None")")
|
||||
}
|
||||
}
|
||||
.disabled(!model.canChangeMiruroProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PlayerCommands: Commands {
|
||||
@FocusedObject private var focusedModel: PlayerModel?
|
||||
@@ -275,6 +310,7 @@ struct PlayerCommands: Commands {
|
||||
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
|
||||
}
|
||||
}
|
||||
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
||||
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
||||
model.performMenuAction(bufferInfo)
|
||||
}
|
||||
|
||||
@@ -41,10 +41,48 @@ enum StreamURL: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct MiruroSelection: Sendable {
|
||||
let source: ResolvedSource
|
||||
let videoProvider: String
|
||||
let captionProvider: String?
|
||||
}
|
||||
|
||||
struct MiruroSources: Sendable {
|
||||
fileprivate struct Candidate: Sendable {
|
||||
let provider: String
|
||||
let source: ResolvedSource
|
||||
}
|
||||
fileprivate let videos: [Candidate]
|
||||
fileprivate let captions: [Candidate]
|
||||
|
||||
let videoProviders: [String]
|
||||
let captionProviders: [String]
|
||||
|
||||
fileprivate init(videos: [Candidate], captions: [Candidate]) {
|
||||
self.videos = videos
|
||||
self.captions = captions
|
||||
videoProviders = videos.map(\.provider)
|
||||
captionProviders = captions.map(\.provider)
|
||||
}
|
||||
|
||||
func select(videoProvider: String? = nil, captionProvider: String? = nil) throws -> MiruroSelection {
|
||||
guard let video = videos.first(where: { $0.provider == videoProvider }) ??
|
||||
videos.first(where: { $0.provider == "sun" }) ?? videos.first else {
|
||||
throw StreamResolutionError(message: "Direct Miruro Japanese source is unavailable.")
|
||||
}
|
||||
let caption = captions.first(where: { $0.provider == captionProvider }) ??
|
||||
captions.first(where: { $0.provider == video.provider }) ??
|
||||
captions.first(where: { $0.provider == "sun" }) ?? captions.first
|
||||
let source = ResolvedSource(url: video.source.url, headers: video.source.headers,
|
||||
identity: video.source.identity, title: video.source.title, subtitles: caption?.source.subtitles ?? [])
|
||||
return MiruroSelection(source: source, videoProvider: video.provider, captionProvider: caption?.provider)
|
||||
}
|
||||
}
|
||||
|
||||
enum MiruroResolver {
|
||||
static let bodyLimit = 2 * 1024 * 1024
|
||||
|
||||
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource {
|
||||
static func resolve(mediaID: String, episode: Int) async throws -> MiruroSources {
|
||||
try Task.checkCancellation()
|
||||
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
|
||||
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
|
||||
@@ -100,51 +138,100 @@ enum MiruroResolver {
|
||||
}
|
||||
}
|
||||
|
||||
// Match Tubular's direct ssub/Sun projection, without provider or server retry.
|
||||
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
|
||||
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> MiruroSources {
|
||||
do {
|
||||
guard data.count <= bodyLimit,
|
||||
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
|
||||
let response = try JSONDecoder().decode(Sources.self, from: data)
|
||||
guard response.episodeNumber == episode else { throw InvalidProjection() }
|
||||
let config = response.config.sun
|
||||
guard config.player == "native", !config.proxy,
|
||||
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else {
|
||||
throw StreamResolutionError(message: "Miruro Sun requires unsupported proxy or HLS rewriting.")
|
||||
var videos: [MiruroSources.Candidate] = []
|
||||
var captions: [MiruroSources.Candidate] = []
|
||||
var videoNames = Set<String>()
|
||||
var captionNames = Set<String>()
|
||||
// Japanese variants only, preferring soft subtitles independently for each provider.
|
||||
for variant in ["ssub", "sub"] {
|
||||
for track in response.tracks.lazy.compactMap(\.value) where track.track == variant {
|
||||
for provider in track.providers.lazy.compactMap(\.value) {
|
||||
let name = provider.provider
|
||||
guard validProviderName(name), let config = response.config[name]?.value,
|
||||
config.player == "native", !config.proxy,
|
||||
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else { continue }
|
||||
let needsVideo = !videoNames.contains(name)
|
||||
let needsCaptions = !captionNames.contains(name)
|
||||
guard needsVideo || needsCaptions else { continue }
|
||||
for server in provider.servers.lazy.compactMap(\.value) where !server.hasEmbed {
|
||||
let headers = server.headers ?? [:]
|
||||
var found = false
|
||||
for stream in server.streams.lazy.compactMap(\.value) {
|
||||
guard !stream.hasEmbed,
|
||||
["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
|
||||
let video = try? ResolvedSource.validated(.init(version: 1, url: stream.url,
|
||||
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
|
||||
title: nil, subtitles: [])) else { continue }
|
||||
if videoNames.insert(name).inserted {
|
||||
videos.append(.init(provider: name, source: video))
|
||||
}
|
||||
// Caption requests retain this provider/server's headers, never the selected video's.
|
||||
let subtitles = provider.subtitles.lazy.compactMap(\.value).filter { $0.format == "vtt" }.map {
|
||||
ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language,
|
||||
label: $0.label, isDefault: $0.default, headers: headers)
|
||||
}
|
||||
if needsCaptions, !subtitles.isEmpty,
|
||||
let caption = try? ResolvedSource.validated(.init(version: 1, url: stream.url,
|
||||
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
|
||||
title: nil, subtitles: Array(subtitles))) {
|
||||
captions.append(.init(provider: name, source: caption))
|
||||
captionNames.insert(name)
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if found { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let provider = response.tracks.first(where: { $0.track == "ssub" })?
|
||||
.providers.first(where: { $0.provider == "sun" }),
|
||||
let server = provider.servers.first(where: { !$0.streams.isEmpty }),
|
||||
let stream = server.streams.first else {
|
||||
throw StreamResolutionError(message: "Direct Miruro Sun soft-subtitle source is unavailable.")
|
||||
// API order is advisory; retain response order for names omitted from it.
|
||||
let order = response.providerOrder ?? []
|
||||
func ordered(_ candidates: [MiruroSources.Candidate]) -> [MiruroSources.Candidate] {
|
||||
var remaining = Dictionary(uniqueKeysWithValues: candidates.map { ($0.provider, $0) })
|
||||
var result: [MiruroSources.Candidate] = []
|
||||
for name in order {
|
||||
if let candidate = remaining.removeValue(forKey: name) { result.append(candidate) }
|
||||
}
|
||||
result.append(contentsOf: candidates.filter { remaining[$0.provider] != nil })
|
||||
return result
|
||||
}
|
||||
guard ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
|
||||
!stream.hasEmbed else { throw InvalidProjection() }
|
||||
let headers = server.headers ?? [:]
|
||||
var subtitles: [ResolvedSubtitle] = []
|
||||
for subtitle in provider.subtitles where subtitle.format == "vtt" {
|
||||
guard subtitles.count < 32 else { throw InvalidProjection() }
|
||||
subtitles.append(ResolvedSubtitle(url: subtitle.file, format: subtitle.format, language: subtitle.language,
|
||||
label: subtitle.label, isDefault: subtitle.default, headers: headers))
|
||||
}
|
||||
return try ResolvedSource.validated(.init(version: 1, url: stream.url, headers: headers,
|
||||
identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
|
||||
title: nil, subtitles: subtitles))
|
||||
let sources = MiruroSources(videos: ordered(videos), captions: ordered(captions))
|
||||
_ = try sources.select()
|
||||
return sources
|
||||
} catch {
|
||||
if let error = error as? StreamResolutionError { throw error }
|
||||
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
|
||||
}
|
||||
}
|
||||
|
||||
private static func validProviderName(_ name: String) -> Bool {
|
||||
!name.isEmpty && name.utf8.count <= 128 && name.utf8.allSatisfy {
|
||||
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95
|
||||
}
|
||||
}
|
||||
|
||||
private struct InvalidProjection: Error {}
|
||||
// A malformed entry must not discard valid neighboring providers, servers or streams.
|
||||
private struct Entry<Value: Decodable>: Decodable {
|
||||
let value: Value?
|
||||
init(from decoder: Decoder) throws { value = try? Value(from: decoder) }
|
||||
}
|
||||
private struct Sources: Decodable {
|
||||
let episodeNumber: Int
|
||||
let tracks: [Track]
|
||||
let config: Config
|
||||
let tracks: [Entry<Track>]
|
||||
let config: [String: Entry<ProviderConfig>]
|
||||
let providerOrder: [String]?
|
||||
}
|
||||
private struct Config: Decodable { let sun: ProviderConfig }
|
||||
private struct ProviderConfig: Decodable {
|
||||
let player: String
|
||||
// Objects describe proxy work this native player cannot perform.
|
||||
let proxy: Bool
|
||||
let hls: HLS?
|
||||
}
|
||||
@@ -155,16 +242,28 @@ enum MiruroResolver {
|
||||
}
|
||||
private struct Track: Decodable {
|
||||
let track: String
|
||||
let providers: [Provider]
|
||||
let providers: [Entry<Provider>]
|
||||
}
|
||||
private struct Provider: Decodable {
|
||||
let provider: String
|
||||
let servers: [Server]
|
||||
let subtitles: [Subtitle]
|
||||
let servers: [Entry<Server>]
|
||||
let subtitles: [Entry<Subtitle>]
|
||||
}
|
||||
private struct Server: Decodable {
|
||||
let headers: [String: String]?
|
||||
let streams: [Stream]
|
||||
let streams: [Entry<Stream>]
|
||||
let hasEmbed: Bool
|
||||
enum CodingKeys: String, CodingKey { case headers, streams, embed }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
headers = try values.decodeIfPresent([String: String].self, forKey: .headers)
|
||||
streams = try values.decode([Entry<Stream>].self, forKey: .streams)
|
||||
if values.contains(.embed) {
|
||||
hasEmbed = try !values.decodeNil(forKey: .embed)
|
||||
} else {
|
||||
hasEmbed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
private struct Stream: Decodable {
|
||||
let url: String
|
||||
|
||||
+90
-19
@@ -121,6 +121,20 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var youtubeQuality: Int?
|
||||
@Published private(set) var automaticYouTubeQuality = false
|
||||
@Published private(set) var showsBufferInfo = false
|
||||
@Published private(set) var miruroVideoProvider: String
|
||||
@Published private(set) var miruroCaptionProvider: String
|
||||
@Published private(set) var selectedMiruroVideoProvider: String?
|
||||
@Published private(set) var selectedMiruroCaptionProvider: String?
|
||||
private var miruroSources: MiruroSources? {
|
||||
if case .resolved(_, let providers) = currentMedia { return providers }
|
||||
return nil
|
||||
}
|
||||
var miruroVideoProviders: [String] { miruroSources?.videoProviders ?? [] }
|
||||
var miruroCaptionProviders: [String] { miruroSources?.captionProviders ?? [] }
|
||||
var canChangeMiruroProvider: Bool {
|
||||
castDevice == nil && miruroSources != nil && canTogglePlayback &&
|
||||
!isSeeking && !transition.seekPending && !state.seek_pending
|
||||
}
|
||||
@Published private(set) var subtitleTracks: [ResolvedSubtitle] = []
|
||||
@Published private(set) var selectedSubtitle: Int?
|
||||
@Published private(set) var subtitleText = ""
|
||||
@@ -188,7 +202,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var reportingErrorBuffer = [CChar]()
|
||||
private let youtubeResolver: @MainActor (URL, Int?) async throws -> YouTubeVideo
|
||||
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
|
||||
private let miruroResolver: @MainActor (String, Int) async throws -> ResolvedSource
|
||||
private let miruroResolver: @MainActor (String, Int) async throws -> MiruroSources
|
||||
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
||||
private var reportingErrorMessage: String {
|
||||
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
||||
@@ -198,7 +212,7 @@ final class PlayerModel: ObservableObject {
|
||||
progressFile: String? = nil, streamQueue: StreamQueue? = nil,
|
||||
youtubeResolver: @escaping @MainActor (URL, Int?) async throws -> YouTubeVideo = { try await YouTubeResolver.resolve($0, height: $1) },
|
||||
youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) },
|
||||
miruroResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
|
||||
miruroResolver: @escaping @MainActor (String, Int) async throws -> MiruroSources = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
|
||||
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
||||
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
@@ -212,6 +226,8 @@ final class PlayerModel: ObservableObject {
|
||||
compactTitleProgress = configuration.compactTitleProgress
|
||||
automaticYouTubeQuality = configuration.automaticQuality
|
||||
showsBufferInfo = configuration.showBufferInfo
|
||||
miruroVideoProvider = configuration.miruroVideoProvider
|
||||
miruroCaptionProvider = configuration.miruroCaptionProvider
|
||||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
||||
playbackProgress = PlaybackProgress(defaults: defaults)
|
||||
recentMedia = playbackProgress.recentMedia
|
||||
@@ -404,11 +420,13 @@ final class PlayerModel: ObservableObject {
|
||||
title = video.title
|
||||
source = .youtube(video, height: nil)
|
||||
case .miruro(let mediaID, let episode):
|
||||
let resolved = try await miruroResolver(mediaID, episode)
|
||||
let providers = try await miruroResolver(mediaID, episode)
|
||||
let resolved = try providers.select(videoProvider: miruroVideoProvider,
|
||||
captionProvider: miruroCaptionProvider).source
|
||||
guard resolved.identity == "miruro:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) }
|
||||
url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
|
||||
title = resolved.title
|
||||
source = .resolved(resolved)
|
||||
source = .resolved(resolved, providers: providers)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard canOpen else { throw CancellationError() }
|
||||
@@ -578,6 +596,38 @@ final class PlayerModel: ObservableObject {
|
||||
resolveYouTube(url, height: height, change: change)
|
||||
}
|
||||
|
||||
func setMiruroVideoProvider(_ provider: String) {
|
||||
guard canChangeMiruroProvider, let handle,
|
||||
provider.isEmpty || miruroVideoProviders.contains(provider),
|
||||
provider != miruroVideoProvider,
|
||||
case .resolved(let source, _) = currentMedia else { return }
|
||||
let identity = source.identity.split(separator: ":")
|
||||
guard identity.count == 3, let episode = Int(identity[2]) else { return }
|
||||
miruroVideoProvider = provider
|
||||
let change = transition.qualityChange(player_get_state(handle), playing: effectivePlaying)
|
||||
resolveMiruro(mediaID: String(identity[1]), episode: episode, change: change)
|
||||
}
|
||||
|
||||
func setMiruroCaptionProvider(_ provider: String) {
|
||||
guard canChangeMiruroProvider, let providers = miruroSources,
|
||||
provider.isEmpty || miruroCaptionProviders.contains(provider) else { return }
|
||||
do {
|
||||
let selection = try providers.select(videoProvider: selectedMiruroVideoProvider,
|
||||
captionProvider: provider)
|
||||
let language = selectedSubtitle.flatMap { subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil }
|
||||
miruroCaptionProvider = provider
|
||||
selectedMiruroCaptionProvider = selection.captionProvider
|
||||
currentMedia = .resolved(selection.source, providers: providers)
|
||||
subtitleTracks = selection.source.subtitles
|
||||
let index = language.flatMap { language in subtitleTracks.firstIndex { $0.language == language } ??
|
||||
subtitleTracks.firstIndex(where: \.isDefault) }
|
||||
selectSubtitle(index)
|
||||
revealControls()
|
||||
} catch {
|
||||
commandError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) {
|
||||
guard let handle else { return }
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
@@ -594,22 +644,25 @@ final class PlayerModel: ObservableObject {
|
||||
commandError = nil
|
||||
revealControls()
|
||||
let resolver = miruroResolver
|
||||
let videoProvider = miruroVideoProvider
|
||||
let captionProvider = miruroCaptionProvider
|
||||
let task = Task { [weak self] in
|
||||
do {
|
||||
let source = try await resolver(mediaID, episode)
|
||||
let providers = try await resolver(mediaID, episode)
|
||||
let selection = try providers.select(videoProvider: videoProvider, captionProvider: captionProvider)
|
||||
let source = selection.source
|
||||
guard source.identity == "miruro:\(mediaID):\(episode)" else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.objectWillChange.send()
|
||||
guard let change = self.transition.finishResolution(request) else { return }
|
||||
self.openMedia(.resolved(source), change: change)
|
||||
self.openMedia(.resolved(source, providers: providers), change: change)
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.objectWillChange.send()
|
||||
guard self.transition.finishResolution(request) != nil else { return }
|
||||
self.commandError = error.localizedDescription
|
||||
self.revealControls()
|
||||
self.failStreamChange(change, error: error)
|
||||
}
|
||||
}
|
||||
transition.attachResolution(task, request: request)
|
||||
@@ -652,7 +705,7 @@ final class PlayerModel: ObservableObject {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.objectWillChange.send()
|
||||
guard let change = self.transition.finishResolution(request) else { return }
|
||||
self.failYouTubeChange(change, error: error)
|
||||
self.failStreamChange(change, error: error)
|
||||
}
|
||||
}
|
||||
transition.attachResolution(task, request: request)
|
||||
@@ -664,7 +717,7 @@ final class PlayerModel: ObservableObject {
|
||||
transition.cancelResolution()
|
||||
}
|
||||
|
||||
private func failYouTubeChange(_ change: MediaChange, error: Error? = nil) {
|
||||
private func failStreamChange(_ change: MediaChange, error: Error? = nil) {
|
||||
if change.automatic {
|
||||
automaticQuality.extractionFailed(at: qualityNow)
|
||||
return
|
||||
@@ -791,7 +844,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
case .resolved(let source):
|
||||
case .resolved(let source, _):
|
||||
accepted = source.identity.withCString { identity in
|
||||
source.url.withCString { url in
|
||||
source.headers.withCString { headers in
|
||||
@@ -808,9 +861,9 @@ final class PlayerModel: ObservableObject {
|
||||
case .file:
|
||||
commandError = "Could not queue the file to open. Please try again."
|
||||
case .resolved:
|
||||
commandError = "Could not queue the resolved stream to open."
|
||||
failStreamChange(change, error: CocoaError(.fileReadUnknown))
|
||||
case .youtube:
|
||||
failYouTubeChange(change)
|
||||
failStreamChange(change)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -819,6 +872,15 @@ final class PlayerModel: ObservableObject {
|
||||
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
|
||||
queueCompletionHandled = false
|
||||
currentMedia = source
|
||||
if case .resolved(_, let providers) = source,
|
||||
let selection = try? providers?.select(videoProvider: miruroVideoProvider,
|
||||
captionProvider: miruroCaptionProvider) {
|
||||
selectedMiruroVideoProvider = selection.videoProvider
|
||||
selectedMiruroCaptionProvider = selection.captionProvider
|
||||
} else {
|
||||
selectedMiruroVideoProvider = nil
|
||||
selectedMiruroCaptionProvider = nil
|
||||
}
|
||||
shouldRecordRecentMedia = !change.isQuality
|
||||
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
|
||||
switch source {
|
||||
@@ -834,7 +896,7 @@ final class PlayerModel: ObservableObject {
|
||||
youtubeHeights = video.availableHeights
|
||||
youtubeHeight = video.height
|
||||
youtubeQuality = height
|
||||
case .resolved(let source):
|
||||
case .resolved(let source, _):
|
||||
fileName = source.title
|
||||
fileIcon = nil
|
||||
youtubeHeights = []
|
||||
@@ -844,10 +906,16 @@ final class PlayerModel: ObservableObject {
|
||||
if let fileName, !source.url.isFileURL, let id = queue.currentID {
|
||||
queue.updateLabel(fileName, for: id)
|
||||
}
|
||||
let previousSubtitleLanguage = selectedSubtitle.flatMap {
|
||||
subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil
|
||||
}
|
||||
selectSubtitle(nil)
|
||||
if case .resolved(let resolved) = source {
|
||||
if case .resolved(let resolved, _) = source {
|
||||
subtitleTracks = resolved.subtitles
|
||||
selectSubtitle(subtitleTracks.firstIndex(where: \.isDefault))
|
||||
let defaultIndex = subtitleTracks.firstIndex(where: \.isDefault)
|
||||
selectSubtitle(change.isQuality ? previousSubtitleLanguage.flatMap { language in
|
||||
subtitleTracks.firstIndex { $0.language == language } ?? defaultIndex
|
||||
} : defaultIndex)
|
||||
} else {
|
||||
subtitleTracks = []
|
||||
}
|
||||
@@ -1010,6 +1078,8 @@ final class PlayerModel: ObservableObject {
|
||||
queueCompletionHandled = true
|
||||
queueCastDevice = nil
|
||||
currentMedia = nil
|
||||
selectedMiruroVideoProvider = nil
|
||||
selectedMiruroCaptionProvider = nil
|
||||
replaceMetadata(for: nil)
|
||||
shouldRecordRecentMedia = false
|
||||
selectSubtitle(nil)
|
||||
@@ -1818,7 +1888,7 @@ final class PlayerModel: ObservableObject {
|
||||
audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
|
||||
local: false, restricted: false, live: video.isLive,
|
||||
duration: duration, subtitles: [])
|
||||
case .resolved(let resolved):
|
||||
case .resolved(let resolved, _):
|
||||
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
|
||||
audioURL: nil, audioHeaders: nil, local: false, restricted: true,
|
||||
live: false, duration: duration, subtitles: resolved.subtitles)
|
||||
@@ -1860,13 +1930,13 @@ final class PlayerModel: ObservableObject {
|
||||
private enum MediaSource {
|
||||
case file(URL)
|
||||
case youtube(YouTubeVideo, height: Int?)
|
||||
case resolved(ResolvedSource)
|
||||
case resolved(ResolvedSource, providers: MiruroSources? = nil)
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .file(let url): return url
|
||||
case .youtube(let video, _): return video.webpageURL
|
||||
case .resolved(let source): return URL(string: source.identity)!
|
||||
case .resolved(let source, _): return URL(string: source.identity)!
|
||||
}
|
||||
}
|
||||
var live: Bool {
|
||||
@@ -2940,6 +3010,7 @@ private struct PlayerContent: View {
|
||||
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
|
||||
Divider()
|
||||
PlayerQualityMenu(model: model)
|
||||
PlayerMiruroMenu(model: model)
|
||||
Divider()
|
||||
if model.isCompact {
|
||||
Menu("Subtitles") {
|
||||
|
||||
Reference in New Issue
Block a user