Rework tvOS player controls and settings sheet

Replace the tvOS bottom action bar with Settings / Info / Comments /
Next / Close. Settings reuses QualitySelectorView (video, audio,
subtitles, speed); Comments opens TVDetailsPanel directly on the
comments tab; Close stops playback and dismisses.

Debug button is hidden by default and can be re-enabled via a new
tvOS-only Advanced Settings > Developer toggle.

Present the settings sheet as a fullScreenCover with a centered
material card, fix the "Normal" hyphenation, and restyle row selection
throughout the quality selector on tvOS: per-row rounded backgrounds
with focus tint + stroke, vertical spacing instead of dividers, and a
focusable speed-rate menu.
This commit is contained in:
Arkadiusz Fal
2026-04-14 17:34:20 +02:00
parent 4f9285686a
commit c7942ef555
11 changed files with 443 additions and 169 deletions

View File

@@ -77,65 +77,91 @@ extension QualitySelectorView {
@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())
#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
)
if availableTabs.contains(.audio) {
mediaSelectionRow(
destination: .audio,
label: String(localized: "stream.audio"),
systemImage: "speaker.wave.2",
value: currentAudioDisplayValue
)
}
.buttonStyle(.plain)
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()
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)
mediaSelectionRow(
destination: .audio,
label: String(localized: "stream.audio"),
systemImage: "speaker.wave.2",
value: currentAudioDisplayValue
)
}
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)
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
@@ -261,16 +287,19 @@ extension QualitySelectorView {
@ViewBuilder
var generalSectionContent: some View {
#if os(tvOS)
// tvOS only has the speed row here; style it to match the Settings rows.
playbackSpeedRow
#else
VStack(spacing: 0) {
playbackSpeedRow
Divider()
#if !os(tvOS)
lockControlsRow
#endif
}
.cardBackground()
#endif
}
@ViewBuilder
@@ -310,9 +339,15 @@ extension QualitySelectorView {
} label: {
Text(currentRate.displayText)
.font(.body.weight(.medium))
.frame(minWidth: 60)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.frame(minWidth: 80)
}
#if os(tvOS)
// Default menu style on tvOS renders a focusable bordered pill.
#else
.menuStyle(.borderlessButton)
#endif
Button {
if let newRate = nextRate() {
@@ -328,8 +363,18 @@ extension QualitySelectorView {
.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
}
#if !os(tvOS)
@@ -455,6 +500,19 @@ extension QualitySelectorView {
.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 {
@@ -472,6 +530,7 @@ extension QualitySelectorView {
}
}
.cardBackground()
#endif
}
}
@@ -496,6 +555,13 @@ extension QualitySelectorView {
.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 {
@@ -507,6 +573,7 @@ extension QualitySelectorView {
}
}
.cardBackground()
#endif
}
}
@@ -517,6 +584,13 @@ extension QualitySelectorView {
.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 {
@@ -528,6 +602,7 @@ extension QualitySelectorView {
}
}
.cardBackground()
#endif
}
}
}
@@ -603,6 +678,13 @@ extension QualitySelectorView {
}
.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 {
@@ -614,6 +696,7 @@ extension QualitySelectorView {
}
}
.cardBackground()
#endif
}
}
@@ -647,6 +730,29 @@ extension QualitySelectorView {
@ViewBuilder
var subtitlesSectionContent: some View {
#if os(tvOS)
VStack(spacing: 8) {
CaptionRowView(
caption: nil,
isSelected: currentCaption == nil,
isPreferred: false,
onTap: {
handleCaptionTap(nil)
}
)
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,
@@ -675,6 +781,7 @@ extension QualitySelectorView {
}
}
.cardBackground()
#endif
}
private func isCaptionPreferred(_ caption: Caption) -> Bool {
@@ -691,3 +798,27 @@ extension QualitySelectorView {
dismiss()
}
}
#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