// // 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() } }