Files
yattee/Yattee/Views/Player/QualitySelectorView+Sections.swift
Arkadiusz Fal 8faf20c9e9 Add loading external subtitle files for media-source playback
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.
2026-08-03 23:34:06 +02:00

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
}
}