mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 22:04:19 +00:00
The Settings (quality/audio/subtitles) and Queue panels now slide in from the right and occupy the right half of the screen, matching the info/comments details panel introduced in 92cc8b79f. Video stays visible on the left so the user retains visual context while browsing. Both panels supply their own ultraThinMaterial backdrop and use a custom title bar (replacing NavigationStack's auto-title on tvOS) so the title styling and symmetric padding match across panels and across pushed destination screens. The Menu button now pops the quality panel's pushed Video/Audio/Subtitles detail screens before dismissing the panel itself. Removes the background Button from the focus tree while either panel is open so D-pad left/right inside a row no longer escapes focus into the player and triggers a seek. Initial focus is steered into the first row programmatically since tvOS doesn't auto-focus inline overlays the way it does for fullScreenCover. Doubles the queue thumbnail size on tvOS (160x90) for readability at the half-screen panel width.
355 lines
11 KiB
Swift
355 lines
11 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) private 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
|
|
|
|
/// 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)?
|
|
|
|
/// 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?
|
|
|
|
// 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 {
|
|
tabs.append(.audio)
|
|
}
|
|
if !captions.isEmpty {
|
|
tabs.append(.subtitles)
|
|
}
|
|
return tabs
|
|
}
|
|
|
|
/// 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 && !isPlayingDownloadedContent
|
|
}
|
|
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,
|
|
initialTab: QualitySelectorTab = .video,
|
|
showTabPicker: Bool = true,
|
|
onStreamSelected: @escaping (Stream, Stream?) -> Void,
|
|
onCaptionSelected: @escaping (Caption?) -> Void = { _ in },
|
|
onLoadOnlineStreams: @escaping () -> Void = {},
|
|
onSwitchToOnlineStream: @escaping (Stream, Stream?) -> Void = { _, _ in },
|
|
onRateChanged: ((PlaybackRate) -> Void)? = nil,
|
|
onLockToggled: ((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.onStreamSelected = onStreamSelected
|
|
self.onCaptionSelected = onCaptionSelected
|
|
self.onLoadOnlineStreams = onLoadOnlineStreams
|
|
self.onSwitchToOnlineStream = onSwitchToOnlineStream
|
|
self.onRateChanged = onRateChanged
|
|
self.onLockToggled = onLockToggled
|
|
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
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
#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 {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(role: .cancel) {
|
|
performDismiss()
|
|
} label: {
|
|
Label(String(localized: "common.close"), systemImage: "xmark")
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
.navigationDestination(for: QualitySelectorDestination.self) { destination in
|
|
switch destination {
|
|
case .video:
|
|
videoDetailContent
|
|
case .audio:
|
|
audioDetailContent
|
|
case .subtitles:
|
|
subtitlesDetailContent
|
|
}
|
|
}
|
|
.onAppear {
|
|
selectedVideoStream = currentStream
|
|
selectedAudioStream = 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 }
|
|
)
|
|
}
|