mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
Media-source streams from WebDAV/SMB were invisible in the Video list: the filter required remote URLs to carry a resolution, which MediaFile.toStream never sets. Include non-adaptive remote streams without resolution (only media sources produce those), extend the mpv-track display enrichment to the currently playing stream regardless of URL scheme, and classify metadata-less streams as recommended — a nil codec previously counted as software-decoded, hiding the row behind advanced mode with a spurious warning. The warning now uses the enriched codec.
1039 lines
34 KiB
Swift
1039 lines
34 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
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
// Explicit symmetric padding so rows are evenly inset from the
|
|
// half-screen panel's edges; matches the queue panel's 80pt
|
|
// breathing room so the focus halo around row buttons doesn't
|
|
// crowd the panel boundaries.
|
|
.padding(.horizontal, 80)
|
|
.padding(.vertical, 24)
|
|
#else
|
|
.padding()
|
|
#endif
|
|
}
|
|
.onAppear {
|
|
selectedTab = initialTab
|
|
}
|
|
}
|
|
|
|
// MARK: - Tab Content
|
|
|
|
@ViewBuilder
|
|
private var mediaSelectionRows: some View {
|
|
#if os(tvOS)
|
|
// tvOS: each row is its own rounded card with vertical spacing so the
|
|
// system focus hover effect has clean bounds (no clipped dividers).
|
|
VStack(spacing: 8) {
|
|
mediaSelectionRow(
|
|
destination: .video,
|
|
label: String(localized: "player.quality.video"),
|
|
systemImage: "film",
|
|
value: currentVideoDisplayValue
|
|
)
|
|
.focused($inlinePanelInitialFocus)
|
|
if availableTabs.contains(.audio) {
|
|
mediaSelectionRow(
|
|
destination: .audio,
|
|
label: String(localized: "stream.audio"),
|
|
systemImage: "speaker.wave.2",
|
|
value: currentAudioDisplayValue
|
|
)
|
|
}
|
|
if availableTabs.contains(.subtitles) {
|
|
mediaSelectionRow(
|
|
destination: .subtitles,
|
|
label: String(localized: "stream.subtitles"),
|
|
systemImage: "captions.bubble",
|
|
value: currentSubtitlesDisplayValue
|
|
)
|
|
}
|
|
}
|
|
#else
|
|
VStack(spacing: 0) {
|
|
mediaSelectionRow(
|
|
destination: .video,
|
|
label: String(localized: "player.quality.video"),
|
|
systemImage: "film",
|
|
value: currentVideoDisplayValue
|
|
)
|
|
if availableTabs.contains(.audio) {
|
|
Divider()
|
|
mediaSelectionRow(
|
|
destination: .audio,
|
|
label: String(localized: "stream.audio"),
|
|
systemImage: "speaker.wave.2",
|
|
value: currentAudioDisplayValue
|
|
)
|
|
}
|
|
if availableTabs.contains(.subtitles) {
|
|
Divider()
|
|
mediaSelectionRow(
|
|
destination: .subtitles,
|
|
label: String(localized: "stream.subtitles"),
|
|
systemImage: "captions.bubble",
|
|
value: currentSubtitlesDisplayValue
|
|
)
|
|
}
|
|
}
|
|
.cardBackground()
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func mediaSelectionRow(
|
|
destination: QualitySelectorDestination,
|
|
label: String,
|
|
systemImage: String,
|
|
value: String
|
|
) -> some View {
|
|
NavigationLink(value: destination) {
|
|
HStack {
|
|
Label(label, systemImage: systemImage)
|
|
.font(.headline)
|
|
Spacer()
|
|
Text(value)
|
|
.foregroundStyle(.secondary)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 20)
|
|
.contentShape(Rectangle())
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(TVSettingsRowButtonStyle())
|
|
#else
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
}
|
|
|
|
// 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 displayStream(for: stream).qualityLabel
|
|
}
|
|
|
|
private var currentAudioDisplayValue: String {
|
|
if embeddedAudioTracks.count > 1,
|
|
let trackID = currentEmbeddedAudioTrackID,
|
|
let track = embeddedAudioTracks.first(where: { $0.trackID == trackID }) {
|
|
return track.displayName
|
|
}
|
|
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 {
|
|
if let trackID = currentEmbeddedSubtitleTrackID,
|
|
let track = embeddedSubtitleTracks.first(where: { $0.trackID == trackID }) {
|
|
return track.displayName
|
|
}
|
|
return currentCaption?.displayName ?? String(localized: "stream.subtitles.off")
|
|
}
|
|
|
|
// MARK: - Detail Content Views
|
|
|
|
@ViewBuilder
|
|
var videoDetailContent: some View {
|
|
detailContent(title: String(localized: "player.quality.video")) {
|
|
if !adaptiveStreams.isEmpty {
|
|
adaptiveSectionContent
|
|
}
|
|
if !videoStreams.isEmpty {
|
|
videoSectionContent
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
var audioDetailContent: some View {
|
|
detailContent(title: String(localized: "stream.audio")) {
|
|
audioSectionContent
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
var subtitlesDetailContent: some View {
|
|
detailContent(title: String(localized: "stream.subtitles")) {
|
|
subtitlesSectionContent
|
|
}
|
|
}
|
|
|
|
/// Shared layout for the pushed detail screens. On tvOS the destination
|
|
/// gets the same glass backdrop + custom title bar as the panel root so
|
|
/// the visual treatment is continuous as the user navigates in.
|
|
@ViewBuilder
|
|
private func detailContent<Content: View>(
|
|
title: String,
|
|
@ViewBuilder content: () -> Content
|
|
) -> some View {
|
|
#if os(tvOS)
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ZStack {
|
|
Text(title)
|
|
.font(.system(size: 32, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 80)
|
|
.padding(.top, 32)
|
|
.padding(.bottom, 16)
|
|
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
content()
|
|
}
|
|
.padding(.horizontal, 80)
|
|
.padding(.vertical, 24)
|
|
}
|
|
}
|
|
.background(
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
.ignoresSafeArea()
|
|
)
|
|
.ignoresSafeArea(.container, edges: .horizontal)
|
|
#else
|
|
ScrollView {
|
|
VStack(spacing: 16) {
|
|
content()
|
|
}
|
|
.padding()
|
|
}
|
|
.background(ListBackgroundStyle.grouped.color)
|
|
.navigationTitle(title)
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
#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 {
|
|
#if os(tvOS)
|
|
// tvOS rows are styled individually to match the Settings rows.
|
|
VStack(spacing: 8) {
|
|
playbackSpeedRow
|
|
|
|
audioModeRow
|
|
}
|
|
#else
|
|
VStack(spacing: 0) {
|
|
playbackSpeedRow
|
|
|
|
Divider()
|
|
|
|
audioModeRow
|
|
|
|
Divider()
|
|
|
|
lockControlsRow
|
|
}
|
|
.cardBackground()
|
|
#endif
|
|
}
|
|
|
|
@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))
|
|
.lineLimit(1)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.frame(minWidth: 80)
|
|
}
|
|
.playbackSpeedMenuStyle()
|
|
|
|
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)
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
.padding(.vertical, 14)
|
|
.padding(.horizontal, 20)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(Color.white.opacity(0.08))
|
|
)
|
|
#else
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 12)
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var audioModeRow: some View {
|
|
#if os(tvOS)
|
|
Button {
|
|
onAudioModeToggled?(!isAudioMode)
|
|
} label: {
|
|
HStack {
|
|
Label(String(localized: "player.quality.audioMode"), systemImage: "music.note")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
Text(isAudioMode ? String(localized: "common.on") : String(localized: "common.off"))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 14)
|
|
.padding(.horizontal, 20)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(TVSettingsRowButtonStyle())
|
|
#else
|
|
HStack {
|
|
Label(String(localized: "player.quality.audioMode"), systemImage: "music.note")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
Toggle("", isOn: Binding(
|
|
get: { isAudioMode },
|
|
set: { onAudioModeToggled?($0) }
|
|
))
|
|
.labelsHidden()
|
|
}
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 12)
|
|
#endif
|
|
}
|
|
|
|
#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)
|
|
performDismiss()
|
|
}
|
|
)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
|
|
Divider()
|
|
|
|
DownloadedCaptionRowView(
|
|
download: download,
|
|
localCaptionURL: localCaptionURL,
|
|
currentCaption: currentCaption,
|
|
onCaptionSelected: onCaptionSelected,
|
|
onDismiss: { performDismiss() }
|
|
)
|
|
.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)
|
|
|
|
#if os(tvOS)
|
|
VStack(spacing: 8) {
|
|
ForEach(adaptiveStreams, id: \.url) { stream in
|
|
AdaptiveStreamRowView(
|
|
stream: stream,
|
|
isSelected: stream.url == currentStream?.url,
|
|
onTap: {
|
|
handleAdaptiveStreamTap(stream)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
#else
|
|
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()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private func handleAdaptiveStreamTap(_ stream: Stream) {
|
|
if isPlayingDownloadedContent {
|
|
onSwitchToOnlineStream(stream, nil)
|
|
} else {
|
|
onStreamSelected(stream, nil)
|
|
}
|
|
performDismiss()
|
|
}
|
|
|
|
// 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)
|
|
|
|
#if os(tvOS)
|
|
VStack(spacing: 8) {
|
|
ForEach(recommendedVideoStreams, id: \.url) { stream in
|
|
videoStreamRow(stream)
|
|
}
|
|
}
|
|
#else
|
|
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()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
#if os(tvOS)
|
|
VStack(spacing: 8) {
|
|
ForEach(otherVideoStreams, id: \.url) { stream in
|
|
videoStreamRow(stream)
|
|
}
|
|
}
|
|
#else
|
|
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()
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A media-source Stream (local folder, WebDAV, SMB) carries no
|
|
/// resolution/codec/fps metadata (`MediaFile.toStream` cannot know them
|
|
/// before demux), which would label the row "Unknown". Once mpv reports
|
|
/// the loaded file's video track, fill those fields for display. Gated to
|
|
/// local files and the currently playing stream — the track info describes
|
|
/// whatever mpv has loaded. The enriched copy is display-only — selection
|
|
/// and tap handling keep using the original stream (compared by URL).
|
|
func displayStream(for stream: Stream) -> Stream {
|
|
guard stream.resolution == nil,
|
|
!stream.isAudioOnly,
|
|
stream.url.isFileURL || stream.url == currentStream?.url,
|
|
let track = embeddedVideoTrack,
|
|
let width = track.width,
|
|
let height = track.height else {
|
|
return stream
|
|
}
|
|
return Stream(
|
|
url: stream.url,
|
|
resolution: StreamResolution(width: width, height: height),
|
|
format: stream.format,
|
|
videoCodec: track.codec ?? stream.videoCodec,
|
|
audioCodec: stream.audioCodec,
|
|
bitrate: stream.bitrate,
|
|
fileSize: stream.fileSize,
|
|
isLive: stream.isLive,
|
|
mimeType: stream.mimeType,
|
|
httpHeaders: stream.httpHeaders,
|
|
fps: track.fps.map { Int($0.rounded()) }
|
|
)
|
|
}
|
|
|
|
@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
|
|
// Metadata-less media-source streams get resolution/codec/fps filled
|
|
// from the mpv-reported video track for display; the warning must use
|
|
// the enriched codec too or a nil codec reads as software-decoded
|
|
let display: Stream = displayStream(for: stream)
|
|
|
|
VideoStreamRowView(
|
|
stream: display,
|
|
isSelected: isSelected,
|
|
isPreferredQuality: isPreferredQuality,
|
|
isDownloaded: isDownloadedStream,
|
|
showAdvancedDetails: showAdvancedStreamDetails,
|
|
requiresSoftwareDecode: !display.isMuxed && display.videoCodec != nil && requiresSoftwareDecode(display.videoCodec),
|
|
onTap: {
|
|
handleVideoStreamTap(stream, isDownloaded: isDownloadedStream)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func handleVideoStreamTap(_ stream: Stream, isDownloaded: Bool) {
|
|
if isDownloaded {
|
|
if stream.isMuxed {
|
|
onStreamSelected(stream, nil)
|
|
performDismiss()
|
|
} else {
|
|
selectedVideoStream = stream
|
|
if let audio = selectedAudioStream {
|
|
onStreamSelected(stream, audio)
|
|
performDismiss()
|
|
}
|
|
}
|
|
} else if isPlayingDownloadedContent {
|
|
let audioStream: Stream? = stream.isVideoOnly ? (selectedAudioStream ?? defaultAudioStream) : nil
|
|
onSwitchToOnlineStream(stream, audioStream)
|
|
performDismiss()
|
|
} else {
|
|
if stream.isMuxed {
|
|
onStreamSelected(stream, nil)
|
|
performDismiss()
|
|
} else {
|
|
selectedVideoStream = stream
|
|
if let audio = selectedAudioStream {
|
|
onStreamSelected(stream, audio)
|
|
performDismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Audio Section
|
|
|
|
@ViewBuilder
|
|
var audioSectionContent: some View {
|
|
if embeddedAudioTracks.count > 1 {
|
|
embeddedAudioTracksContent
|
|
} else 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 {
|
|
#if os(tvOS)
|
|
VStack(spacing: 8) {
|
|
ForEach(audioStreams, id: \.url) { stream in
|
|
audioStreamRow(stream)
|
|
}
|
|
}
|
|
#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()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// Embedded (in-container) audio tracks, switched live via mpv `aid`.
|
|
@ViewBuilder
|
|
private var embeddedAudioTracksContent: some View {
|
|
#if os(tvOS)
|
|
VStack(spacing: 8) {
|
|
ForEach(embeddedAudioTracks) { track in
|
|
embeddedAudioTrackRow(track)
|
|
}
|
|
}
|
|
#else
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(embeddedAudioTracks.enumerated()), id: \.element.id) { index, track in
|
|
if index > 0 {
|
|
Divider()
|
|
}
|
|
embeddedAudioTrackRow(track)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
}
|
|
.cardBackground()
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func embeddedAudioTrackRow(_ track: MPVTrack) -> some View {
|
|
EmbeddedTrackRowView(
|
|
track: track,
|
|
isSelected: track.trackID == currentEmbeddedAudioTrackID,
|
|
isPreferred: track.matchesLanguage(preferredAudioLanguage),
|
|
showAdvancedDetails: showAdvancedStreamDetails,
|
|
onTap: {
|
|
onEmbeddedAudioTrackSelected(track.trackID)
|
|
performDismiss()
|
|
}
|
|
)
|
|
}
|
|
|
|
@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 currentStream?.isAudioOnly == true {
|
|
// Audio mode / audio-only content: the tapped track IS the main stream
|
|
onStreamSelected(stream, nil)
|
|
performDismiss()
|
|
} else if let video = selectedVideoStream, video.isVideoOnly {
|
|
onStreamSelected(video, stream)
|
|
performDismiss()
|
|
}
|
|
}
|
|
|
|
// MARK: - Subtitles Section
|
|
|
|
@ViewBuilder
|
|
var subtitlesSectionContent: some View {
|
|
#if os(tvOS)
|
|
VStack(spacing: 8) {
|
|
CaptionRowView(
|
|
caption: nil,
|
|
isSelected: currentCaption == nil && currentEmbeddedSubtitleTrackID == nil,
|
|
isPreferred: false,
|
|
onTap: {
|
|
handleCaptionTap(nil)
|
|
}
|
|
)
|
|
|
|
ForEach(embeddedSubtitleTracks) { track in
|
|
embeddedSubtitleTrackRow(track)
|
|
}
|
|
|
|
ForEach(sortedCaptions) { caption in
|
|
CaptionRowView(
|
|
caption: caption,
|
|
isSelected: caption.id == currentCaption?.id,
|
|
isPreferred: isCaptionPreferred(caption),
|
|
onTap: {
|
|
handleCaptionTap(caption)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
#else
|
|
VStack(spacing: 0) {
|
|
CaptionRowView(
|
|
caption: nil,
|
|
isSelected: currentCaption == nil && currentEmbeddedSubtitleTrackID == nil,
|
|
isPreferred: false,
|
|
onTap: {
|
|
handleCaptionTap(nil)
|
|
}
|
|
)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
|
|
ForEach(embeddedSubtitleTracks) { track in
|
|
Divider()
|
|
|
|
embeddedSubtitleTrackRow(track)
|
|
.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()
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func embeddedSubtitleTrackRow(_ track: MPVTrack) -> some View {
|
|
EmbeddedTrackRowView(
|
|
track: track,
|
|
isSelected: track.trackID == currentEmbeddedSubtitleTrackID,
|
|
isPreferred: track.matchesLanguage(preferredSubtitlesLanguage),
|
|
showAdvancedDetails: showAdvancedStreamDetails,
|
|
onTap: {
|
|
// Re-tapping the selected track turns subtitles off (parity
|
|
// with external caption rows)
|
|
if track.trackID == currentEmbeddedSubtitleTrackID {
|
|
onEmbeddedSubtitleTrackSelected(nil)
|
|
} else {
|
|
onEmbeddedSubtitleTrackSelected(track.trackID)
|
|
}
|
|
performDismiss()
|
|
}
|
|
)
|
|
}
|
|
|
|
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?) {
|
|
// Note: the Off row needs no embedded-track handling — caption "off"
|
|
// sets sid=no and clears the embedded intent in PlayerService.
|
|
if caption?.id == currentCaption?.id && caption != nil {
|
|
onCaptionSelected(nil)
|
|
} else {
|
|
onCaptionSelected(caption)
|
|
}
|
|
performDismiss()
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
/// Row button style for Settings-style navigation/selection rows on tvOS.
|
|
/// Avoids the default focus lift/scale so rows stay aligned; focus state is
|
|
/// communicated via a background tint and a thin stroke.
|
|
struct TVSettingsRowButtonStyle: ButtonStyle {
|
|
@Environment(\.isFocused) private var isFocused
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.foregroundStyle(.white)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(isFocused ? Color.white.opacity(0.22) : Color.white.opacity(0.08))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.stroke(isFocused ? Color.white.opacity(0.4) : .clear, lineWidth: 2)
|
|
)
|
|
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.animation(.easeInOut(duration: 0.15), value: isFocused)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private extension View {
|
|
@ViewBuilder
|
|
func playbackSpeedMenuStyle() -> some View {
|
|
#if os(tvOS)
|
|
self
|
|
#else
|
|
// On macOS Sequoia the borderlessButton menu stretches to fill the
|
|
// available width unless it is pinned to its intrinsic size.
|
|
self.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
#endif
|
|
}
|
|
}
|