mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
694 lines
22 KiB
Swift
694 lines
22 KiB
Swift
//
|
|
// QualitySelectorView+Sections.swift
|
|
// Yattee
|
|
//
|
|
// Section content views for QualitySelectorView.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
extension QualitySelectorView {
|
|
// MARK: - Main Content Views
|
|
|
|
@ViewBuilder
|
|
var loadingContent: some View {
|
|
VStack(spacing: 16) {
|
|
Spacer()
|
|
ProgressView()
|
|
.controlSize(.large)
|
|
Text(String(localized: "player.quality.loading"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
@ViewBuilder
|
|
var emptyContent: some View {
|
|
ContentUnavailableView(
|
|
String(localized: "player.quality.unavailable"),
|
|
systemImage: "film.stack",
|
|
description: Text(String(localized: "player.quality.unavailable.description"))
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
var downloadedContent: some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
if !hasOnlineStreams {
|
|
downloadInfoSection
|
|
loadOnlineStreamsButton
|
|
} else {
|
|
onlineStreamsAfterDownload
|
|
}
|
|
|
|
if showTabPicker {
|
|
generalSectionContent
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
var streamsContent: some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
if showTabPicker && availableTabs.count > 1 {
|
|
mediaSelectionRows
|
|
} else {
|
|
tabContent
|
|
}
|
|
|
|
if showTabPicker {
|
|
generalSectionContent
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.onAppear {
|
|
selectedTab = initialTab
|
|
}
|
|
}
|
|
|
|
// MARK: - Tab Content
|
|
|
|
@ViewBuilder
|
|
private var mediaSelectionRows: some View {
|
|
VStack(spacing: 0) {
|
|
NavigationLink(value: QualitySelectorDestination.video) {
|
|
HStack {
|
|
Label(String(localized: "player.quality.video"), systemImage: "film")
|
|
.font(.headline)
|
|
Spacer()
|
|
Text(currentVideoDisplayValue)
|
|
.foregroundStyle(.secondary)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 12)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if availableTabs.contains(.audio) {
|
|
Divider()
|
|
NavigationLink(value: QualitySelectorDestination.audio) {
|
|
HStack {
|
|
Label(String(localized: "stream.audio"), systemImage: "speaker.wave.2")
|
|
.font(.headline)
|
|
Spacer()
|
|
Text(currentAudioDisplayValue)
|
|
.foregroundStyle(.secondary)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 12)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
if availableTabs.contains(.subtitles) {
|
|
Divider()
|
|
NavigationLink(value: QualitySelectorDestination.subtitles) {
|
|
HStack {
|
|
Label(String(localized: "stream.subtitles"), systemImage: "captions.bubble")
|
|
.font(.headline)
|
|
Spacer()
|
|
Text(currentSubtitlesDisplayValue)
|
|
.foregroundStyle(.secondary)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 12)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.cardBackground()
|
|
}
|
|
|
|
// MARK: - Display Value Computed Properties
|
|
|
|
private var currentVideoDisplayValue: String {
|
|
guard let stream = currentStream else {
|
|
return String(localized: "stream.subtitles.none")
|
|
}
|
|
let format = StreamFormat.detect(from: stream)
|
|
if format == .hls || format == .dash {
|
|
return format == .hls ? "HLS" : "DASH"
|
|
}
|
|
return stream.qualityLabel
|
|
}
|
|
|
|
private var currentAudioDisplayValue: String {
|
|
if isCurrentStreamMuxed {
|
|
return String(localized: "player.quality.audioFromVideo.short")
|
|
}
|
|
if let audio = selectedAudioStream ?? currentAudioStream {
|
|
return parseAudioTrackName(audio).language
|
|
}
|
|
return String(localized: "stream.audio.default")
|
|
}
|
|
|
|
private var currentSubtitlesDisplayValue: String {
|
|
currentCaption?.displayName ?? String(localized: "stream.subtitles.off")
|
|
}
|
|
|
|
// MARK: - Detail Content Views
|
|
|
|
@ViewBuilder
|
|
var videoDetailContent: some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
if !adaptiveStreams.isEmpty {
|
|
adaptiveSectionContent
|
|
}
|
|
if !videoStreams.isEmpty {
|
|
videoSectionContent
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.background(ListBackgroundStyle.grouped.color)
|
|
.navigationTitle(String(localized: "player.quality.video"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
var audioDetailContent: some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
audioSectionContent
|
|
}
|
|
.padding()
|
|
}
|
|
.background(ListBackgroundStyle.grouped.color)
|
|
.navigationTitle(String(localized: "stream.audio"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
var subtitlesDetailContent: some View {
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
subtitlesSectionContent
|
|
}
|
|
.padding()
|
|
}
|
|
.background(ListBackgroundStyle.grouped.color)
|
|
.navigationTitle(String(localized: "stream.subtitles"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var tabContent: some View {
|
|
if selectedTab == .video && !adaptiveStreams.isEmpty {
|
|
adaptiveSectionContent
|
|
}
|
|
|
|
switch selectedTab {
|
|
case .video:
|
|
if !videoStreams.isEmpty {
|
|
videoSectionContent
|
|
}
|
|
case .audio:
|
|
audioSectionContent
|
|
case .subtitles:
|
|
subtitlesSectionContent
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var onlineStreamsAfterDownload: some View {
|
|
if availableTabs.count > 1 {
|
|
mediaSelectionRows
|
|
} else {
|
|
if selectedTab == .video && !adaptiveStreams.isEmpty {
|
|
adaptiveSectionContent
|
|
}
|
|
|
|
switch selectedTab {
|
|
case .video:
|
|
if !videoStreams.isEmpty {
|
|
videoSectionContent
|
|
}
|
|
case .audio:
|
|
audioSectionContent
|
|
case .subtitles:
|
|
subtitlesSectionContent
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - General Section
|
|
|
|
@ViewBuilder
|
|
var generalSectionContent: some View {
|
|
VStack(spacing: 0) {
|
|
playbackSpeedRow
|
|
|
|
Divider()
|
|
|
|
#if !os(tvOS)
|
|
lockControlsRow
|
|
#endif
|
|
}
|
|
.cardBackground()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var playbackSpeedRow: some View {
|
|
HStack {
|
|
Label(String(localized: "player.quality.playbackSpeed"), systemImage: "gauge.with.needle")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 8) {
|
|
Button {
|
|
if let newRate = previousRate() {
|
|
onRateChanged?(newRate)
|
|
}
|
|
} label: {
|
|
Image(systemName: "minus")
|
|
.font(.body.weight(.medium))
|
|
.frame(minWidth: 18, minHeight: 18)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(previousRate() == nil)
|
|
|
|
Menu {
|
|
ForEach(PlaybackRate.allCases) { rate in
|
|
Button {
|
|
onRateChanged?(rate)
|
|
} label: {
|
|
if currentRate == rate {
|
|
Label(rate.displayText, systemImage: "checkmark")
|
|
} else {
|
|
Text(rate.displayText)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Text(currentRate.displayText)
|
|
.font(.body.weight(.medium))
|
|
.frame(minWidth: 60)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
|
|
Button {
|
|
if let newRate = nextRate() {
|
|
onRateChanged?(newRate)
|
|
}
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
.font(.body.weight(.medium))
|
|
.frame(minWidth: 18, minHeight: 18)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(nextRate() == nil)
|
|
}
|
|
}
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
@ViewBuilder
|
|
private var lockControlsRow: some View {
|
|
HStack {
|
|
Label(String(localized: "player.quality.lockControls"), systemImage: "lock")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
Toggle("", isOn: Binding(
|
|
get: { isControlsLocked },
|
|
set: { onLockToggled?($0) }
|
|
))
|
|
.labelsHidden()
|
|
}
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Download Info Section
|
|
|
|
@ViewBuilder
|
|
var downloadInfoSection: some View {
|
|
if let download = currentDownload {
|
|
// Video section
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label(String(localized: "player.quality.video"), systemImage: "film")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(spacing: 0) {
|
|
DownloadedVideoRowView(download: download, showAdvancedDetails: showAdvancedStreamDetails)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
.cardBackground()
|
|
}
|
|
|
|
// Audio section (if separate audio track)
|
|
if download.localAudioPath != nil {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label(String(localized: "stream.audio"), systemImage: "speaker.wave.2")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(spacing: 0) {
|
|
DownloadedAudioRowView(download: download, showAdvancedDetails: showAdvancedStreamDetails)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
.cardBackground()
|
|
}
|
|
}
|
|
|
|
// Subtitles section (if downloaded)
|
|
if download.localCaptionPath != nil {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label(String(localized: "stream.subtitles"), systemImage: "captions.bubble")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(spacing: 0) {
|
|
CaptionRowView(
|
|
caption: nil,
|
|
isSelected: currentCaption == nil,
|
|
isPreferred: false,
|
|
onTap: {
|
|
onCaptionSelected(nil)
|
|
dismiss()
|
|
}
|
|
)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
|
|
Divider()
|
|
|
|
DownloadedCaptionRowView(
|
|
download: download,
|
|
localCaptionURL: localCaptionURL,
|
|
currentCaption: currentCaption,
|
|
onCaptionSelected: onCaptionSelected,
|
|
onDismiss: { dismiss() }
|
|
)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
.cardBackground()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
var loadOnlineStreamsButton: some View {
|
|
Button {
|
|
onLoadOnlineStreams()
|
|
} label: {
|
|
HStack {
|
|
if isLoadingOnlineStreams {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
Text(String(localized: "player.quality.loadOnline"))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(isLoadingOnlineStreams)
|
|
}
|
|
|
|
// MARK: - Adaptive Section
|
|
|
|
@ViewBuilder
|
|
var adaptiveSectionContent: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label(String(localized: "stream.adaptive"), systemImage: "antenna.radiowaves.left.and.right")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(adaptiveStreams.enumerated()), id: \.element.url) { index, stream in
|
|
if index > 0 {
|
|
Divider()
|
|
}
|
|
AdaptiveStreamRowView(
|
|
stream: stream,
|
|
isSelected: stream.url == currentStream?.url,
|
|
onTap: {
|
|
handleAdaptiveStreamTap(stream)
|
|
}
|
|
)
|
|
.padding(.vertical, 10)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
}
|
|
.cardBackground()
|
|
}
|
|
}
|
|
|
|
private func handleAdaptiveStreamTap(_ stream: Stream) {
|
|
if isPlayingDownloadedContent {
|
|
onSwitchToOnlineStream(stream, nil)
|
|
} else {
|
|
onStreamSelected(stream, nil)
|
|
}
|
|
dismiss()
|
|
}
|
|
|
|
// MARK: - Video Section
|
|
|
|
@ViewBuilder
|
|
var videoSectionContent: some View {
|
|
VStack(spacing: 16) {
|
|
// Recommended section
|
|
if !recommendedVideoStreams.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label(String(localized: "player.quality.recommended"), systemImage: "bolt.fill")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(recommendedVideoStreams.enumerated()), id: \.element.url) { index, stream in
|
|
if index > 0 {
|
|
Divider()
|
|
}
|
|
videoStreamRow(stream)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
}
|
|
.cardBackground()
|
|
}
|
|
}
|
|
|
|
// Other section (software decode)
|
|
if showAdvancedStreamDetails && !otherVideoStreams.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label(String(localized: "player.quality.other"), systemImage: "cpu")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(otherVideoStreams.enumerated()), id: \.element.url) { index, stream in
|
|
if index > 0 {
|
|
Divider()
|
|
}
|
|
videoStreamRow(stream)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
}
|
|
.cardBackground()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func videoStreamRow(_ stream: Stream) -> some View {
|
|
let isDownloadedStream: Bool = stream.url.isFileURL
|
|
let isSelected: Bool = stream.isMuxed
|
|
? stream.url == currentStream?.url
|
|
: stream.url == selectedVideoStream?.url
|
|
let isPreferredQuality: Bool = stream.resolution == preferredQuality.maxResolution
|
|
|
|
VideoStreamRowView(
|
|
stream: stream,
|
|
isSelected: isSelected,
|
|
isPreferredQuality: isPreferredQuality,
|
|
isDownloaded: isDownloadedStream,
|
|
showAdvancedDetails: showAdvancedStreamDetails,
|
|
requiresSoftwareDecode: !stream.isMuxed && requiresSoftwareDecode(stream.videoCodec),
|
|
onTap: {
|
|
handleVideoStreamTap(stream, isDownloaded: isDownloadedStream)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func handleVideoStreamTap(_ stream: Stream, isDownloaded: Bool) {
|
|
if isDownloaded {
|
|
if stream.isMuxed {
|
|
onStreamSelected(stream, nil)
|
|
dismiss()
|
|
} else {
|
|
selectedVideoStream = stream
|
|
if let audio = selectedAudioStream {
|
|
onStreamSelected(stream, audio)
|
|
dismiss()
|
|
}
|
|
}
|
|
} else if isPlayingDownloadedContent {
|
|
let audioStream: Stream? = stream.isVideoOnly ? (selectedAudioStream ?? defaultAudioStream) : nil
|
|
onSwitchToOnlineStream(stream, audioStream)
|
|
dismiss()
|
|
} else {
|
|
if stream.isMuxed {
|
|
onStreamSelected(stream, nil)
|
|
dismiss()
|
|
} else {
|
|
selectedVideoStream = stream
|
|
if let audio = selectedAudioStream {
|
|
onStreamSelected(stream, audio)
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Audio Section
|
|
|
|
@ViewBuilder
|
|
var audioSectionContent: some View {
|
|
if isCurrentStreamMuxed {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
Image(systemName: "info.circle")
|
|
.foregroundStyle(.secondary)
|
|
Text(String(localized: "player.quality.audioFromVideo"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
.cardBackground()
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(audioStreams.enumerated()), id: \.element.url) { index, stream in
|
|
if index > 0 {
|
|
Divider()
|
|
}
|
|
audioStreamRow(stream)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
}
|
|
.cardBackground()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func audioStreamRow(_ stream: Stream) -> some View {
|
|
let isSelected: Bool = stream.url == selectedAudioStream?.url
|
|
let isPreferred: Bool = preferredAudioLanguage.map { (stream.audioLanguage ?? "").hasPrefix($0) } ?? false
|
|
let trackInfo: AudioTrackInfo = parseAudioTrackName(stream)
|
|
|
|
AudioStreamRowView(
|
|
stream: stream,
|
|
isSelected: isSelected,
|
|
isPreferred: isPreferred,
|
|
showAdvancedDetails: showAdvancedStreamDetails,
|
|
trackInfo: trackInfo,
|
|
onTap: {
|
|
handleAudioStreamTap(stream)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func handleAudioStreamTap(_ stream: Stream) {
|
|
selectedAudioStream = stream
|
|
if let video = selectedVideoStream, video.isVideoOnly {
|
|
onStreamSelected(video, stream)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
// MARK: - Subtitles Section
|
|
|
|
@ViewBuilder
|
|
var subtitlesSectionContent: some View {
|
|
VStack(spacing: 0) {
|
|
CaptionRowView(
|
|
caption: nil,
|
|
isSelected: currentCaption == nil,
|
|
isPreferred: false,
|
|
onTap: {
|
|
handleCaptionTap(nil)
|
|
}
|
|
)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
|
|
ForEach(sortedCaptions) { caption in
|
|
Divider()
|
|
|
|
CaptionRowView(
|
|
caption: caption,
|
|
isSelected: caption.id == currentCaption?.id,
|
|
isPreferred: isCaptionPreferred(caption),
|
|
onTap: {
|
|
handleCaptionTap(caption)
|
|
}
|
|
)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
}
|
|
.cardBackground()
|
|
}
|
|
|
|
private func isCaptionPreferred(_ caption: Caption) -> Bool {
|
|
guard let preferred = preferredSubtitlesLanguage else { return false }
|
|
return caption.baseLanguageCode == preferred || caption.languageCode.hasPrefix(preferred)
|
|
}
|
|
|
|
private func handleCaptionTap(_ caption: Caption?) {
|
|
if caption?.id == currentCaption?.id && caption != nil {
|
|
onCaptionSelected(nil)
|
|
} else {
|
|
onCaptionSelected(caption)
|
|
}
|
|
dismiss()
|
|
}
|
|
}
|