feat: play resolved Miruro HLS with external VTT and stable episode progress
This commit is contained in:
@@ -37,6 +37,12 @@ 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 subtitleTracks: [ResolvedSubtitle] = []
|
||||
@Published private(set) var selectedSubtitle: Int?
|
||||
@Published private(set) var subtitleText = ""
|
||||
@Published private(set) var subtitleError: String?
|
||||
private var subtitleDocument: WebVTTDocument?
|
||||
private var subtitleTask: Task<Void, Never>?
|
||||
@Published var seekPosition = 0.0
|
||||
@Published var isSeeking = false
|
||||
@Published private(set) var volume = 1.0
|
||||
@@ -218,6 +224,45 @@ final class PlayerModel: ObservableObject {
|
||||
resolveYouTube(url, height: nil)
|
||||
}
|
||||
|
||||
func openResolvedSource(_ file: URL) {
|
||||
do {
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
cancelYouTubeResolution()
|
||||
openMedia(.resolved(source))
|
||||
} catch {
|
||||
commandError = error.localizedDescription
|
||||
revealControls()
|
||||
}
|
||||
}
|
||||
|
||||
func selectSubtitle(_ index: Int?) {
|
||||
subtitleTask?.cancel()
|
||||
subtitleTask = nil
|
||||
subtitleDocument = nil
|
||||
subtitleText = ""
|
||||
subtitleError = nil
|
||||
selectedSubtitle = index
|
||||
guard let index, subtitleTracks.indices.contains(index) else { return }
|
||||
let track = subtitleTracks[index]
|
||||
subtitleTask = Task { [weak self] in
|
||||
do {
|
||||
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.subtitleDocument = document
|
||||
self.updateSubtitle()
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.subtitleError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSubtitle() {
|
||||
subtitleText = state.seek_pending || transition.seekPending || isBusy ||
|
||||
state.status == PLAYER_EMPTY || state.status == PLAYER_ERROR ? "" :
|
||||
subtitleDocument?.text(at: state.position) ?? ""
|
||||
}
|
||||
|
||||
func setShowsBufferInfo(_ enabled: Bool) {
|
||||
showsBufferInfo = enabled
|
||||
revealControls()
|
||||
@@ -338,11 +383,24 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
case .resolved(let source):
|
||||
accepted = source.identity.withCString { identity in
|
||||
source.url.withCString { url in
|
||||
source.headers.withCString { headers in
|
||||
var input = PlayerNetworkSource(video_url: url, audio_url: nil,
|
||||
video_headers: headers, audio_headers: nil, duration: 0, is_live: false,
|
||||
media_url: identity, initial_position: savedPosition ?? 0)
|
||||
return player_open_network(handle, &input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard accepted else {
|
||||
switch source {
|
||||
case .file:
|
||||
commandError = "Could not queue the file to open. Please try again."
|
||||
case .resolved:
|
||||
commandError = "Could not queue the resolved stream to open."
|
||||
case .youtube:
|
||||
failYouTubeChange(change)
|
||||
}
|
||||
@@ -363,6 +421,19 @@ final class PlayerModel: ObservableObject {
|
||||
youtubeHeights = video.availableHeights
|
||||
youtubeHeight = video.height
|
||||
youtubeQuality = height
|
||||
case .resolved(let source):
|
||||
fileName = source.title
|
||||
fileIcon = nil
|
||||
youtubeHeights = []
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
}
|
||||
selectSubtitle(nil)
|
||||
if case .resolved(let resolved) = source {
|
||||
subtitleTracks = resolved.subtitles
|
||||
selectSubtitle(subtitleTracks.firstIndex(where: \.isDefault))
|
||||
} else {
|
||||
subtitleTracks = []
|
||||
}
|
||||
needsVideoFit = !change.isQuality
|
||||
commandError = transition.needsLocalRestoration || !change.playing || player_play(handle) ? nil :
|
||||
@@ -429,6 +500,8 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
transition.closeMedia()
|
||||
selectSubtitle(nil)
|
||||
subtitleTracks = []
|
||||
compactIntent = nil
|
||||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||
fileName = nil
|
||||
@@ -744,6 +817,7 @@ final class PlayerModel: ObservableObject {
|
||||
guard observation.ready else { return }
|
||||
defer { systemPlayback?.update() }
|
||||
state = next
|
||||
updateSubtitle()
|
||||
if let playing = observation.playback { setPlayback(playing: playing) }
|
||||
if !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) {
|
||||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||
@@ -792,6 +866,7 @@ final class PlayerModel: ObservableObject {
|
||||
func shutdown() {
|
||||
saveProgress()
|
||||
cancelYouTubeResolution()
|
||||
selectSubtitle(nil)
|
||||
systemPlayback?.shutdown()
|
||||
systemPlayback = nil
|
||||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||
@@ -834,11 +909,13 @@ final class PlayerModel: ObservableObject {
|
||||
private enum MediaSource {
|
||||
case file(URL)
|
||||
case youtube(YouTubeVideo, height: Int?)
|
||||
case resolved(ResolvedSource)
|
||||
|
||||
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)!
|
||||
}
|
||||
}
|
||||
var live: Bool {
|
||||
@@ -1555,6 +1632,40 @@ private struct PlayerContent: View {
|
||||
if model.isCompact {
|
||||
CompactDragSurface(model: model).accessibilityHidden(true)
|
||||
}
|
||||
if !model.subtitleText.isEmpty {
|
||||
Text(model.subtitleText)
|
||||
.font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.75), in: RoundedRectangle(cornerRadius: 4))
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, model.controlsVisible ? 110 : 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
if !model.subtitleTracks.isEmpty && (model.controlsVisible || model.subtitleError != nil) {
|
||||
VStack(alignment: .trailing) {
|
||||
Menu {
|
||||
Button("Off") { model.selectSubtitle(nil) }
|
||||
ForEach(model.subtitleTracks.indices, id: \.self) { index in
|
||||
Button {
|
||||
model.selectSubtitle(index)
|
||||
} label: {
|
||||
Label(model.subtitleTracks[index].label + " (" + model.subtitleTracks[index].language + ")",
|
||||
systemImage: model.selectedSubtitle == index ? "checkmark" : "text.bubble")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "captions.bubble").accessibilityLabel("Subtitles")
|
||||
}
|
||||
if let error = model.subtitleError {
|
||||
Text(error).font(.caption).foregroundStyle(.yellow)
|
||||
}
|
||||
}
|
||||
.padding(.top, 44).padding(.trailing, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
if let error = model.errorText {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow)
|
||||
@@ -1918,6 +2029,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let url = URL(string: arguments[index + 1]) {
|
||||
PlayerModel.shared.open(url)
|
||||
}
|
||||
if let index = arguments.firstIndex(of: "--source-file"), index + 1 < arguments.count {
|
||||
PlayerModel.shared.openResolvedSource(URL(fileURLWithPath: arguments[index + 1]))
|
||||
}
|
||||
}
|
||||
|
||||
// Let SwiftUI create the initial window for a Finder/document launch.
|
||||
|
||||
Reference in New Issue
Block a user