mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +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.
1079 lines
36 KiB
Swift
1079 lines
36 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)
|
|
}
|
|
|
|
if canLoadExternalSubtitles {
|
|
Divider()
|
|
|
|
loadSubtitleFileRow
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
}
|
|
}
|
|
.cardBackground()
|
|
#endif
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
/// Row that opens a file picker to load an external subtitle file into
|
|
/// the current media-source playback.
|
|
private var loadSubtitleFileRow: some View {
|
|
Button {
|
|
#if os(iOS)
|
|
showingSubtitleFilePicker = true
|
|
#elseif os(macOS)
|
|
SubtitleFilePanel.present { url in
|
|
handlePickedSubtitleFile(url)
|
|
}
|
|
#endif
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "folder.badge.plus")
|
|
.foregroundStyle(.secondary)
|
|
Text("stream.subtitles.loadFromFile")
|
|
.font(.headline)
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
func handlePickedSubtitleFile(_ url: URL) {
|
|
appEnvironment?.playerService.loadExternalSubtitleFile(from: url)
|
|
performDismiss()
|
|
}
|
|
#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
|
|
}
|
|
}
|