mirror of
https://github.com/yattee/yattee.git
synced 2026-08-06 15:21:29 +00:00
Adds a "Load subtitle from file…" row to the Subtitles section on iOS (document picker) and macOS (open panel), available when playing files from local folder, SMB, or WebDAV sources. The picked file is copied into the per-video temp subtitle directory, registered as a selectable Caption (displayed by filename), and activated through the existing loadCaption flow. The Subtitles tab and captions control button now also appear for media-source files without any subtitles.
414 lines
14 KiB
Swift
414 lines
14 KiB
Swift
//
|
|
// QualitySelectorView.swift
|
|
// Yattee
|
|
//
|
|
// Sheet for selecting video quality, audio track, and subtitles.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct QualitySelectorView: View {
|
|
// MARK: - Environment
|
|
|
|
@Environment(\.dismiss) var dismiss
|
|
@Environment(\.appEnvironment) var appEnvironment
|
|
|
|
// MARK: - Properties
|
|
|
|
let streams: [Stream]
|
|
let captions: [Caption]
|
|
let currentStream: Stream?
|
|
let currentAudioStream: Stream?
|
|
let currentCaption: Caption?
|
|
let isLoading: Bool
|
|
let currentDownload: Download?
|
|
let isLoadingOnlineStreams: Bool
|
|
let localCaptionURL: URL?
|
|
let onStreamSelected: (Stream, Stream?) -> Void
|
|
let onCaptionSelected: (Caption?) -> Void
|
|
let onLoadOnlineStreams: () -> Void
|
|
let onSwitchToOnlineStream: (Stream, Stream?) -> Void
|
|
|
|
/// Embedded (in-container) tracks reported by mpv for the loaded file.
|
|
let embeddedAudioTracks: [MPVTrack]
|
|
let embeddedSubtitleTracks: [MPVTrack]
|
|
let currentEmbeddedAudioTrackID: Int?
|
|
let currentEmbeddedSubtitleTrackID: Int?
|
|
/// The playing file's video track, used to label metadata-less local streams
|
|
let embeddedVideoTrack: MPVTrack?
|
|
let onEmbeddedAudioTrackSelected: (Int) -> Void
|
|
/// nil = subtitles off
|
|
let onEmbeddedSubtitleTrackSelected: (Int?) -> Void
|
|
|
|
/// Current playback rate
|
|
var currentRate: PlaybackRate = .x1
|
|
/// Callback when playback rate changes
|
|
var onRateChanged: ((PlaybackRate) -> Void)?
|
|
|
|
/// Whether controls are locked
|
|
var isControlsLocked: Bool = false
|
|
/// Callback when lock state changes
|
|
var onLockToggled: ((Bool) -> Void)?
|
|
|
|
/// Whether audio-only (music) mode is enabled
|
|
var isAudioMode: Bool = false
|
|
/// Callback when audio mode is toggled
|
|
var onAudioModeToggled: ((Bool) -> Void)?
|
|
|
|
/// Initial tab to show when view appears
|
|
var initialTab: QualitySelectorTab = .video
|
|
/// Whether to show the segmented tab picker (false for focused single-tab mode)
|
|
var showTabPicker: Bool = true
|
|
|
|
/// Optional dismiss callback used when the view is presented inline (e.g. as
|
|
/// the tvOS half-screen panel). When nil, falls back to `@Environment(\.dismiss)`.
|
|
var onDismiss: (() -> Void)?
|
|
|
|
#if os(tvOS)
|
|
/// Bound to the first focusable row so we can programmatically pull focus
|
|
/// into the panel on appear (the system doesn't auto-focus an inline
|
|
/// overlay the way it does for `fullScreenCover`).
|
|
@FocusState var inlinePanelInitialFocus: Bool
|
|
|
|
/// Tracks pushed destinations so the Menu-button handler can pop instead
|
|
/// of dismissing when the user has navigated into a detail screen.
|
|
@State private var navigationPath = NavigationPath()
|
|
#endif
|
|
|
|
// MARK: - State
|
|
@State var selectedTab: QualitySelectorTab = .video
|
|
@State var selectedVideoStream: Stream?
|
|
@State var selectedAudioStream: Stream?
|
|
#if os(iOS)
|
|
@State var showingSubtitleFilePicker = false
|
|
#endif
|
|
|
|
// MARK: - Settings Access
|
|
|
|
/// The preferred audio language from settings
|
|
var preferredAudioLanguage: String? {
|
|
appEnvironment?.settingsManager.preferredAudioLanguage
|
|
}
|
|
|
|
/// The preferred subtitles language from settings
|
|
var preferredSubtitlesLanguage: String? {
|
|
appEnvironment?.settingsManager.preferredSubtitlesLanguage
|
|
}
|
|
|
|
/// The preferred video quality from settings
|
|
var preferredQuality: VideoQuality {
|
|
appEnvironment?.settingsManager.preferredQuality ?? .auto
|
|
}
|
|
|
|
/// Whether to show advanced stream details (codec, bitrate, size)
|
|
var showAdvancedStreamDetails: Bool {
|
|
appEnvironment?.settingsManager.showAdvancedStreamDetails ?? false
|
|
}
|
|
|
|
var allowSoftwareDecodedFormats: Bool {
|
|
appEnvironment?.settingsManager.allowSoftwareDecodedFormats ?? false
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// Available tabs based on streams
|
|
var availableTabs: [QualitySelectorTab] {
|
|
var tabs: [QualitySelectorTab] = [.video]
|
|
if (hasVideoOnlyStreams && !audioStreams.isEmpty) || embeddedAudioTracks.count > 1 {
|
|
tabs.append(.audio)
|
|
}
|
|
if !captions.isEmpty || !embeddedSubtitleTracks.isEmpty || canLoadExternalSubtitles {
|
|
tabs.append(.subtitles)
|
|
}
|
|
return tabs
|
|
}
|
|
|
|
/// Whether the "Load subtitle from file…" row is available: media-source
|
|
/// playback (local folder/SMB/WebDAV) on platforms with a file picker.
|
|
var canLoadExternalSubtitles: Bool {
|
|
#if os(tvOS)
|
|
return false
|
|
#else
|
|
return appEnvironment?.playerService.state.currentVideo?.isFromMediaSource == true
|
|
#endif
|
|
}
|
|
|
|
/// Navigation title based on mode
|
|
var navigationTitle: String {
|
|
if showTabPicker {
|
|
return String(localized: "player.quality.settings")
|
|
} else {
|
|
switch initialTab {
|
|
case .video:
|
|
return String(localized: "player.quality.video")
|
|
case .audio:
|
|
return String(localized: "stream.audio")
|
|
case .subtitles:
|
|
return String(localized: "stream.subtitles")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether we're playing downloaded content
|
|
var isPlayingDownloadedContent: Bool {
|
|
currentDownload != nil
|
|
}
|
|
|
|
/// Whether streams are empty (not loading, but no streams available)
|
|
var hasNoStreams: Bool {
|
|
if !showTabPicker && initialTab == .subtitles {
|
|
return !isLoading && captions.isEmpty && embeddedSubtitleTracks.isEmpty
|
|
&& !isPlayingDownloadedContent && !canLoadExternalSubtitles
|
|
}
|
|
return !isLoading && streams.isEmpty && !isPlayingDownloadedContent
|
|
}
|
|
|
|
/// Whether online streams have been loaded
|
|
var hasOnlineStreams: Bool {
|
|
streams.contains { !$0.url.isFileURL }
|
|
}
|
|
|
|
/// Whether the current or selected video stream is muxed
|
|
var isCurrentStreamMuxed: Bool {
|
|
if let selected = selectedVideoStream {
|
|
return selected.isMuxed
|
|
}
|
|
return currentStream?.isMuxed ?? false
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
streams: [Stream],
|
|
captions: [Caption] = [],
|
|
currentStream: Stream?,
|
|
currentAudioStream: Stream? = nil,
|
|
currentCaption: Caption? = nil,
|
|
isLoading: Bool = false,
|
|
currentDownload: Download? = nil,
|
|
isLoadingOnlineStreams: Bool = false,
|
|
localCaptionURL: URL? = nil,
|
|
currentRate: PlaybackRate = .x1,
|
|
isControlsLocked: Bool = false,
|
|
isAudioMode: Bool = false,
|
|
initialTab: QualitySelectorTab = .video,
|
|
showTabPicker: Bool = true,
|
|
embeddedAudioTracks: [MPVTrack] = [],
|
|
embeddedSubtitleTracks: [MPVTrack] = [],
|
|
currentEmbeddedAudioTrackID: Int? = nil,
|
|
currentEmbeddedSubtitleTrackID: Int? = nil,
|
|
embeddedVideoTrack: MPVTrack? = nil,
|
|
onStreamSelected: @escaping (Stream, Stream?) -> Void,
|
|
onCaptionSelected: @escaping (Caption?) -> Void = { _ in },
|
|
onLoadOnlineStreams: @escaping () -> Void = {},
|
|
onSwitchToOnlineStream: @escaping (Stream, Stream?) -> Void = { _, _ in },
|
|
onEmbeddedAudioTrackSelected: @escaping (Int) -> Void = { _ in },
|
|
onEmbeddedSubtitleTrackSelected: @escaping (Int?) -> Void = { _ in },
|
|
onRateChanged: ((PlaybackRate) -> Void)? = nil,
|
|
onLockToggled: ((Bool) -> Void)? = nil,
|
|
onAudioModeToggled: ((Bool) -> Void)? = nil,
|
|
onDismiss: (() -> Void)? = nil
|
|
) {
|
|
self.streams = streams
|
|
self.captions = captions
|
|
self.currentStream = currentStream
|
|
self.currentAudioStream = currentAudioStream
|
|
self.currentCaption = currentCaption
|
|
self.isLoading = isLoading
|
|
self.currentDownload = currentDownload
|
|
self.isLoadingOnlineStreams = isLoadingOnlineStreams
|
|
self.localCaptionURL = localCaptionURL
|
|
self.initialTab = initialTab
|
|
self.showTabPicker = showTabPicker
|
|
self.currentRate = currentRate
|
|
self.isControlsLocked = isControlsLocked
|
|
self.isAudioMode = isAudioMode
|
|
self.embeddedAudioTracks = embeddedAudioTracks
|
|
self.embeddedSubtitleTracks = embeddedSubtitleTracks
|
|
self.currentEmbeddedAudioTrackID = currentEmbeddedAudioTrackID
|
|
self.currentEmbeddedSubtitleTrackID = currentEmbeddedSubtitleTrackID
|
|
self.embeddedVideoTrack = embeddedVideoTrack
|
|
self.onStreamSelected = onStreamSelected
|
|
self.onCaptionSelected = onCaptionSelected
|
|
self.onLoadOnlineStreams = onLoadOnlineStreams
|
|
self.onSwitchToOnlineStream = onSwitchToOnlineStream
|
|
self.onEmbeddedAudioTrackSelected = onEmbeddedAudioTrackSelected
|
|
self.onEmbeddedSubtitleTrackSelected = onEmbeddedSubtitleTrackSelected
|
|
self.onRateChanged = onRateChanged
|
|
self.onLockToggled = onLockToggled
|
|
self.onAudioModeToggled = onAudioModeToggled
|
|
self.onDismiss = onDismiss
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
@ViewBuilder
|
|
private var rootContent: some View {
|
|
if isLoading {
|
|
loadingContent
|
|
} else if isPlayingDownloadedContent {
|
|
downloadedContent
|
|
} else if hasNoStreams {
|
|
emptyContent
|
|
} else {
|
|
streamsContent
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
#if os(tvOS)
|
|
NavigationStack(path: $navigationPath) {
|
|
stackRoot
|
|
}
|
|
.onExitCommand {
|
|
// Pop the pushed detail view if present, otherwise dismiss the
|
|
// whole panel. (Without this, Menu always falls through to
|
|
// `performDismiss()` and closes the entire overlay even from a
|
|
// detail screen.)
|
|
if !navigationPath.isEmpty {
|
|
navigationPath.removeLast()
|
|
} else {
|
|
performDismiss()
|
|
}
|
|
}
|
|
#else
|
|
NavigationStack {
|
|
stackRoot
|
|
}
|
|
#if os(iOS)
|
|
// Rest at a taller-than-medium detent so the general section (playback
|
|
// speed / audio mode / lock) at the bottom of the scroll view is
|
|
// visible without dragging the sheet up to `.large`.
|
|
.presentationDetents([.fraction(0.6), .large])
|
|
#endif
|
|
#if os(macOS)
|
|
.frame(minWidth: 450, minHeight: 500)
|
|
#endif
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var stackRoot: some View {
|
|
Group {
|
|
#if os(tvOS)
|
|
// Custom title bar matches the queue panel's style.
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ZStack {
|
|
Text(navigationTitle)
|
|
.font(.system(size: 32, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 80)
|
|
.padding(.top, 32)
|
|
.padding(.bottom, 16)
|
|
|
|
rootContent
|
|
}
|
|
#else
|
|
rootContent
|
|
#endif
|
|
}
|
|
#if os(tvOS)
|
|
// On tvOS the panel is presented inline as a half-screen overlay; it
|
|
// supplies its own glass backdrop so the underlying video stays partly
|
|
// visible while the menu is readable on top of the bright frame.
|
|
.background(panelGlassBackground)
|
|
// Disable the title-safe-area inset that NavigationStack would
|
|
// otherwise apply on the trailing edge (because the panel sits at
|
|
// the physical right edge of the screen).
|
|
.ignoresSafeArea(.container, edges: .horizontal)
|
|
#else
|
|
.background(ListBackgroundStyle.grouped.color)
|
|
.navigationTitle(navigationTitle)
|
|
#endif
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
#if !os(tvOS)
|
|
.toolbar {
|
|
sheetCloseToolbarItem { performDismiss() }
|
|
}
|
|
#endif
|
|
.navigationDestination(for: QualitySelectorDestination.self) { destination in
|
|
switch destination {
|
|
case .video:
|
|
videoDetailContent
|
|
case .audio:
|
|
audioDetailContent
|
|
case .subtitles:
|
|
subtitlesDetailContent
|
|
}
|
|
}
|
|
#if os(iOS)
|
|
.sheet(isPresented: $showingSubtitleFilePicker) {
|
|
SubtitleFilePickerView { url in
|
|
handlePickedSubtitleFile(url)
|
|
}
|
|
}
|
|
#endif
|
|
.onAppear {
|
|
selectedVideoStream = currentStream
|
|
// In audio mode the audio track IS the main stream
|
|
selectedAudioStream = currentStream?.isAudioOnly == true
|
|
? currentStream
|
|
: (currentAudioStream ?? defaultAudioStream)
|
|
#if os(tvOS)
|
|
// Defer until after the slide-in transition so the focus engine
|
|
// has finished routing focus away from the (now hidden) player
|
|
// controls, then pull focus into the first row.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) {
|
|
inlinePanelInitialFocus = true
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
/// Reusable ultraThinMaterial backdrop applied to the panel root and to
|
|
/// each pushed destination view so the glass remains continuous as the
|
|
/// user navigates into Video / Audio / Subtitles detail screens.
|
|
private var panelGlassBackground: some View {
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
.ignoresSafeArea()
|
|
}
|
|
#endif
|
|
|
|
func performDismiss() {
|
|
if let onDismiss {
|
|
onDismiss()
|
|
} else {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
QualitySelectorView(
|
|
streams: [.preview, .videoOnlyPreview, .audioPreview],
|
|
captions: [.preview, .autoGeneratedPreview],
|
|
currentStream: .preview,
|
|
onStreamSelected: { _, _ in }
|
|
)
|
|
}
|
|
|
|
#Preview("Loading") {
|
|
QualitySelectorView(
|
|
streams: [],
|
|
currentStream: nil,
|
|
isLoading: true,
|
|
onStreamSelected: { _, _ in }
|
|
)
|
|
}
|
|
|
|
#Preview("Empty") {
|
|
QualitySelectorView(
|
|
streams: [],
|
|
currentStream: nil,
|
|
onStreamSelected: { _, _ in }
|
|
)
|
|
}
|