mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
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:
@@ -93,6 +93,7 @@ enum SettingsKey: String, CaseIterable {
|
|||||||
// Advanced
|
// Advanced
|
||||||
case showAdvancedStreamDetails
|
case showAdvancedStreamDetails
|
||||||
case showPlayerAreaDebug
|
case showPlayerAreaDebug
|
||||||
|
case showTVDebugButton
|
||||||
case verboseMPVLogging
|
case verboseMPVLogging
|
||||||
case verboseRemoteControlLogging
|
case verboseRemoteControlLogging
|
||||||
case mpvBufferSeconds
|
case mpvBufferSeconds
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ extension SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether to show the Debug button in the tvOS player bottom controls.
|
||||||
|
/// Opens the MPV debug overlay. Default is false (hidden).
|
||||||
|
var showTVDebugButton: Bool {
|
||||||
|
get {
|
||||||
|
if let cached = _showTVDebugButton { return cached }
|
||||||
|
return bool(for: .showTVDebugButton, default: false)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
_showTVDebugButton = newValue
|
||||||
|
set(newValue, for: .showTVDebugButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether verbose MPV rendering logging is enabled.
|
/// Whether verbose MPV rendering logging is enabled.
|
||||||
/// When enabled, logs detailed OpenGL context, framebuffer, and display link state
|
/// When enabled, logs detailed OpenGL context, framebuffer, and display link state
|
||||||
/// to help diagnose rendering issues. Default is false (disabled).
|
/// to help diagnose rendering issues. Default is false (disabled).
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ final class SettingsManager {
|
|||||||
// Advanced settings
|
// Advanced settings
|
||||||
var _showAdvancedStreamDetails: Bool?
|
var _showAdvancedStreamDetails: Bool?
|
||||||
var _showPlayerAreaDebug: Bool?
|
var _showPlayerAreaDebug: Bool?
|
||||||
|
var _showTVDebugButton: Bool?
|
||||||
var _verboseMPVLogging: Bool?
|
var _verboseMPVLogging: Bool?
|
||||||
var _verboseRemoteControlLogging: Bool?
|
var _verboseRemoteControlLogging: Bool?
|
||||||
var _mpvBufferSeconds: Double?
|
var _mpvBufferSeconds: Double?
|
||||||
@@ -465,6 +466,7 @@ final class SettingsManager {
|
|||||||
_sidebarPlaylistsLimitEnabled = nil
|
_sidebarPlaylistsLimitEnabled = nil
|
||||||
_showAdvancedStreamDetails = nil
|
_showAdvancedStreamDetails = nil
|
||||||
_showPlayerAreaDebug = nil
|
_showPlayerAreaDebug = nil
|
||||||
|
_showTVDebugButton = nil
|
||||||
_verboseMPVLogging = nil
|
_verboseMPVLogging = nil
|
||||||
_verboseRemoteControlLogging = nil
|
_verboseRemoteControlLogging = nil
|
||||||
_mpvBufferSeconds = nil
|
_mpvBufferSeconds = nil
|
||||||
|
|||||||
@@ -6523,6 +6523,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.controls.close" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Close"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"player.controls.comments" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Comments"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.controls.info" : {
|
"player.controls.info" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -6534,6 +6554,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.controls.quality" : {
|
"player.controls.quality" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -6543,7 +6564,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.controls.settings" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Settings"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.controls.subtitles" : {
|
"player.controls.subtitles" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -7052,6 +7084,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.tvos.volumeDown" : {
|
"player.tvos.volumeDown" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -7062,6 +7095,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.tvos.volumeUp" : {
|
"player.tvos.volumeUp" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -8693,6 +8727,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.advanced.debug.showTVDebugButton" : {
|
||||||
|
"comment" : "Toggle label (tvOS only)",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Show Debug Button in Player"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.advanced.debug.verboseMPV" : {
|
"settings.advanced.debug.verboseMPV" : {
|
||||||
"comment" : "Toggle label",
|
"comment" : "Toggle label",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
/// Padding used inside selection row buttons so the tvOS focus background
|
||||||
|
/// fills the whole row.
|
||||||
|
private let tvRowVerticalPadding: CGFloat = 14
|
||||||
|
private let tvRowHorizontalPadding: CGFloat = 20
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Adaptive Stream Row
|
// MARK: - Adaptive Stream Row
|
||||||
|
|
||||||
/// Row view for HLS/DASH adaptive streams.
|
/// Row view for HLS/DASH adaptive streams.
|
||||||
@@ -61,9 +68,18 @@ struct AdaptiveStreamRowView: View {
|
|||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.padding(.vertical, tvRowVerticalPadding)
|
||||||
|
.padding(.horizontal, tvRowHorizontalPadding)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
#endif
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.buttonStyle(TVSettingsRowButtonStyle())
|
||||||
|
#else
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,9 +113,18 @@ struct VideoStreamRowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minHeight: showAdvancedDetails ? nil : 36)
|
.frame(minHeight: showAdvancedDetails ? nil : 36)
|
||||||
|
#if os(tvOS)
|
||||||
|
.padding(.vertical, tvRowVerticalPadding)
|
||||||
|
.padding(.horizontal, tvRowHorizontalPadding)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
#endif
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.buttonStyle(TVSettingsRowButtonStyle())
|
||||||
|
#else
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -251,9 +276,18 @@ struct AudioStreamRowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minHeight: showAdvancedDetails ? nil : 36)
|
.frame(minHeight: showAdvancedDetails ? nil : 36)
|
||||||
|
#if os(tvOS)
|
||||||
|
.padding(.vertical, tvRowVerticalPadding)
|
||||||
|
.padding(.horizontal, tvRowHorizontalPadding)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
#endif
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.buttonStyle(TVSettingsRowButtonStyle())
|
||||||
|
#else
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -341,9 +375,18 @@ struct CaptionRowView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minHeight: 36)
|
.frame(minHeight: 36)
|
||||||
|
#if os(tvOS)
|
||||||
|
.padding(.vertical, tvRowVerticalPadding)
|
||||||
|
.padding(.horizontal, tvRowHorizontalPadding)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
#endif
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.buttonStyle(TVSettingsRowButtonStyle())
|
||||||
|
#else
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
@@ -77,65 +77,91 @@ extension QualitySelectorView {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var mediaSelectionRows: some View {
|
private var mediaSelectionRows: some View {
|
||||||
VStack(spacing: 0) {
|
#if os(tvOS)
|
||||||
NavigationLink(value: QualitySelectorDestination.video) {
|
// tvOS: each row is its own rounded card with vertical spacing so the
|
||||||
HStack {
|
// system focus hover effect has clean bounds (no clipped dividers).
|
||||||
Label(String(localized: "player.quality.video"), systemImage: "film")
|
VStack(spacing: 8) {
|
||||||
.font(.headline)
|
mediaSelectionRow(
|
||||||
Spacer()
|
destination: .video,
|
||||||
Text(currentVideoDisplayValue)
|
label: String(localized: "player.quality.video"),
|
||||||
.foregroundStyle(.secondary)
|
systemImage: "film",
|
||||||
Image(systemName: "chevron.right")
|
value: currentVideoDisplayValue
|
||||||
.font(.caption.weight(.semibold))
|
)
|
||||||
.foregroundStyle(.tertiary)
|
if availableTabs.contains(.audio) {
|
||||||
}
|
mediaSelectionRow(
|
||||||
.padding(.vertical, 12)
|
destination: .audio,
|
||||||
.padding(.horizontal, 12)
|
label: String(localized: "stream.audio"),
|
||||||
.contentShape(Rectangle())
|
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) {
|
if availableTabs.contains(.audio) {
|
||||||
Divider()
|
Divider()
|
||||||
NavigationLink(value: QualitySelectorDestination.audio) {
|
mediaSelectionRow(
|
||||||
HStack {
|
destination: .audio,
|
||||||
Label(String(localized: "stream.audio"), systemImage: "speaker.wave.2")
|
label: String(localized: "stream.audio"),
|
||||||
.font(.headline)
|
systemImage: "speaker.wave.2",
|
||||||
Spacer()
|
value: currentAudioDisplayValue
|
||||||
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) {
|
if availableTabs.contains(.subtitles) {
|
||||||
Divider()
|
Divider()
|
||||||
NavigationLink(value: QualitySelectorDestination.subtitles) {
|
mediaSelectionRow(
|
||||||
HStack {
|
destination: .subtitles,
|
||||||
Label(String(localized: "stream.subtitles"), systemImage: "captions.bubble")
|
label: String(localized: "stream.subtitles"),
|
||||||
.font(.headline)
|
systemImage: "captions.bubble",
|
||||||
Spacer()
|
value: currentSubtitlesDisplayValue
|
||||||
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()
|
.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
|
// MARK: - Display Value Computed Properties
|
||||||
@@ -261,16 +287,19 @@ extension QualitySelectorView {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var generalSectionContent: some View {
|
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) {
|
VStack(spacing: 0) {
|
||||||
playbackSpeedRow
|
playbackSpeedRow
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
lockControlsRow
|
lockControlsRow
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.cardBackground()
|
.cardBackground()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -310,9 +339,15 @@ extension QualitySelectorView {
|
|||||||
} label: {
|
} label: {
|
||||||
Text(currentRate.displayText)
|
Text(currentRate.displayText)
|
||||||
.font(.body.weight(.medium))
|
.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)
|
.menuStyle(.borderlessButton)
|
||||||
|
#endif
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if let newRate = nextRate() {
|
if let newRate = nextRate() {
|
||||||
@@ -328,8 +363,18 @@ extension QualitySelectorView {
|
|||||||
.disabled(nextRate() == nil)
|
.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(.vertical, 12)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -455,6 +500,19 @@ extension QualitySelectorView {
|
|||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.secondary)
|
.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) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(adaptiveStreams.enumerated()), id: \.element.url) { index, stream in
|
ForEach(Array(adaptiveStreams.enumerated()), id: \.element.url) { index, stream in
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
@@ -472,6 +530,7 @@ extension QualitySelectorView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.cardBackground()
|
.cardBackground()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,6 +555,13 @@ extension QualitySelectorView {
|
|||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(recommendedVideoStreams, id: \.url) { stream in
|
||||||
|
videoStreamRow(stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(recommendedVideoStreams.enumerated()), id: \.element.url) { index, stream in
|
ForEach(Array(recommendedVideoStreams.enumerated()), id: \.element.url) { index, stream in
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
@@ -507,6 +573,7 @@ extension QualitySelectorView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.cardBackground()
|
.cardBackground()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +584,13 @@ extension QualitySelectorView {
|
|||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(otherVideoStreams, id: \.url) { stream in
|
||||||
|
videoStreamRow(stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(otherVideoStreams.enumerated()), id: \.element.url) { index, stream in
|
ForEach(Array(otherVideoStreams.enumerated()), id: \.element.url) { index, stream in
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
@@ -528,6 +602,7 @@ extension QualitySelectorView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.cardBackground()
|
.cardBackground()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,6 +678,13 @@ extension QualitySelectorView {
|
|||||||
}
|
}
|
||||||
.cardBackground()
|
.cardBackground()
|
||||||
} else {
|
} else {
|
||||||
|
#if os(tvOS)
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(audioStreams, id: \.url) { stream in
|
||||||
|
audioStreamRow(stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(audioStreams.enumerated()), id: \.element.url) { index, stream in
|
ForEach(Array(audioStreams.enumerated()), id: \.element.url) { index, stream in
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
@@ -614,6 +696,7 @@ extension QualitySelectorView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.cardBackground()
|
.cardBackground()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,6 +730,29 @@ extension QualitySelectorView {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var subtitlesSectionContent: some View {
|
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) {
|
VStack(spacing: 0) {
|
||||||
CaptionRowView(
|
CaptionRowView(
|
||||||
caption: nil,
|
caption: nil,
|
||||||
@@ -675,6 +781,7 @@ extension QualitySelectorView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.cardBackground()
|
.cardBackground()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isCaptionPreferred(_ caption: Caption) -> Bool {
|
private func isCaptionPreferred(_ caption: Caption) -> Bool {
|
||||||
@@ -691,3 +798,27 @@ extension QualitySelectorView {
|
|||||||
dismiss()
|
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
|
||||||
|
|||||||
@@ -191,6 +191,11 @@ struct QualitySelectorView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
|
#if os(tvOS)
|
||||||
|
// Menu at the root dismisses; pushed detail views use NavigationStack's
|
||||||
|
// default pop-on-Menu behavior and won't hit this handler.
|
||||||
|
.onExitCommand { dismiss() }
|
||||||
|
#else
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button(role: .cancel) {
|
Button(role: .cancel) {
|
||||||
@@ -201,6 +206,7 @@ struct QualitySelectorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.navigationDestination(for: QualitySelectorDestination.self) { destination in
|
.navigationDestination(for: QualitySelectorDestination.self) { destination in
|
||||||
switch destination {
|
switch destination {
|
||||||
case .video:
|
case .video:
|
||||||
|
|||||||
@@ -12,12 +12,20 @@ import NukeUI
|
|||||||
/// Details panel that slides up from the bottom showing video information.
|
/// Details panel that slides up from the bottom showing video information.
|
||||||
struct TVDetailsPanel: View {
|
struct TVDetailsPanel: View {
|
||||||
let video: Video?
|
let video: Video?
|
||||||
|
let initialTab: TVDetailsTab
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
@Environment(\.appEnvironment) private var appEnvironment
|
@Environment(\.appEnvironment) private var appEnvironment
|
||||||
|
|
||||||
/// Tab selection for Info / Comments.
|
/// Tab selection for Info / Comments.
|
||||||
@State private var selectedTab: TVDetailsTab = .info
|
@State private var selectedTab: TVDetailsTab
|
||||||
|
|
||||||
|
init(video: Video?, initialTab: TVDetailsTab = .info, onDismiss: @escaping () -> Void) {
|
||||||
|
self.video = video
|
||||||
|
self.initialTab = initialTab
|
||||||
|
self.onDismiss = onDismiss
|
||||||
|
_selectedTab = State(initialValue: initialTab)
|
||||||
|
}
|
||||||
|
|
||||||
/// Focus state for interactive elements.
|
/// Focus state for interactive elements.
|
||||||
@FocusState private var focusedItem: TVDetailsFocusItem?
|
@FocusState private var focusedItem: TVDetailsFocusItem?
|
||||||
|
|||||||
@@ -15,16 +15,17 @@ struct TVPlayerControlsView: View {
|
|||||||
let playerService: PlayerService?
|
let playerService: PlayerService?
|
||||||
@FocusState.Binding var focusedControl: TVPlayerFocusTarget?
|
@FocusState.Binding var focusedControl: TVPlayerFocusTarget?
|
||||||
|
|
||||||
|
let onShowSettings: () -> Void
|
||||||
let onShowDetails: () -> Void
|
let onShowDetails: () -> Void
|
||||||
let onShowQuality: () -> Void
|
let onShowComments: () -> Void
|
||||||
let onShowDebug: () -> Void
|
let onShowDebug: () -> Void
|
||||||
let onDismiss: () -> Void
|
let onClose: () -> Void
|
||||||
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
|
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
|
||||||
var onScrubbingChanged: ((Bool) -> Void)?
|
var onScrubbingChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
/// Whether to show in-app volume controls (only when volume mode is .mpv)
|
/// Whether the Debug button should be visible (user-toggled in Developer settings).
|
||||||
private var showVolumeControls: Bool {
|
private var showDebugButton: Bool {
|
||||||
GlobalLayoutSettings.cached.volumeMode == .mpv
|
appEnvironment?.settingsManager.showTVDebugButton ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var playNextTapCount = 0
|
@State private var playNextTapCount = 0
|
||||||
@@ -212,47 +213,19 @@ struct TVPlayerControlsView: View {
|
|||||||
|
|
||||||
private var actionButtons: some View {
|
private var actionButtons: some View {
|
||||||
HStack(spacing: 40) {
|
HStack(spacing: 40) {
|
||||||
// Quality selector
|
// Settings (video / audio / subtitles / speed)
|
||||||
Button {
|
Button {
|
||||||
onShowQuality()
|
onShowSettings()
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
Image(systemName: "slider.horizontal.3")
|
Image(systemName: "gearshape")
|
||||||
.font(.system(size: 28))
|
.font(.system(size: 28))
|
||||||
Text("player.controls.quality")
|
Text("player.controls.settings")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(TVActionButtonStyle())
|
.buttonStyle(TVActionButtonStyle())
|
||||||
.focused($focusedControl, equals: .qualityButton)
|
.focused($focusedControl, equals: .settingsButton)
|
||||||
|
|
||||||
// Captions
|
|
||||||
Button {
|
|
||||||
// TODO: Show captions picker
|
|
||||||
} label: {
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Image(systemName: "captions.bubble")
|
|
||||||
.font(.system(size: 28))
|
|
||||||
Text(String(localized: "player.controls.subtitles"))
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(TVActionButtonStyle())
|
|
||||||
.focused($focusedControl, equals: .captionsButton)
|
|
||||||
|
|
||||||
// Debug overlay
|
|
||||||
Button {
|
|
||||||
onShowDebug()
|
|
||||||
} label: {
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Image(systemName: "ant.circle")
|
|
||||||
.font(.system(size: 28))
|
|
||||||
Text(String(localized: "player.debug.titleShort"))
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(TVActionButtonStyle())
|
|
||||||
.focused($focusedControl, equals: .debugButton)
|
|
||||||
|
|
||||||
// Info / Details
|
// Info / Details
|
||||||
Button {
|
Button {
|
||||||
@@ -268,43 +241,36 @@ struct TVPlayerControlsView: View {
|
|||||||
.buttonStyle(TVActionButtonStyle())
|
.buttonStyle(TVActionButtonStyle())
|
||||||
.focused($focusedControl, equals: .infoButton)
|
.focused($focusedControl, equals: .infoButton)
|
||||||
|
|
||||||
// Volume controls (only when in-app volume mode)
|
// Comments (opens details panel on Comments tab)
|
||||||
if showVolumeControls {
|
if playerState?.currentVideo?.supportsComments == true {
|
||||||
// Volume down
|
|
||||||
Button {
|
Button {
|
||||||
guard let state = playerState else { return }
|
onShowComments()
|
||||||
let newVolume = max(0, state.volume - 0.1)
|
|
||||||
playerService?.currentBackend?.volume = newVolume
|
|
||||||
playerService?.state.volume = newVolume
|
|
||||||
appEnvironment?.settingsManager.playerVolume = newVolume
|
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
Image(systemName: "speaker.minus")
|
Image(systemName: "bubble.left.and.bubble.right")
|
||||||
.font(.system(size: 28))
|
.font(.system(size: 28))
|
||||||
Text(String(localized: "player.tvos.volumeDown"))
|
Text("player.controls.comments")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(TVActionButtonStyle())
|
.buttonStyle(TVActionButtonStyle())
|
||||||
.focused($focusedControl, equals: .volumeDown)
|
.focused($focusedControl, equals: .commentsButton)
|
||||||
|
}
|
||||||
|
|
||||||
// Volume up
|
// Debug overlay (only when enabled in Developer settings)
|
||||||
|
if showDebugButton {
|
||||||
Button {
|
Button {
|
||||||
guard let state = playerState else { return }
|
onShowDebug()
|
||||||
let newVolume = min(1.0, state.volume + 0.1)
|
|
||||||
playerService?.currentBackend?.volume = newVolume
|
|
||||||
playerService?.state.volume = newVolume
|
|
||||||
appEnvironment?.settingsManager.playerVolume = newVolume
|
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
Image(systemName: "speaker.plus")
|
Image(systemName: "ant.circle")
|
||||||
.font(.system(size: 28))
|
.font(.system(size: 28))
|
||||||
Text(String(localized: "player.tvos.volumeUp"))
|
Text(String(localized: "player.debug.titleShort"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(TVActionButtonStyle())
|
.buttonStyle(TVActionButtonStyle())
|
||||||
.focused($focusedControl, equals: .volumeUp)
|
.focused($focusedControl, equals: .debugButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play next button (when queue has items)
|
// Play next button (when queue has items)
|
||||||
@@ -325,6 +291,20 @@ struct TVPlayerControlsView: View {
|
|||||||
.focused($focusedControl, equals: .playNext)
|
.focused($focusedControl, equals: .playNext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close (stops playback and dismisses)
|
||||||
|
Button {
|
||||||
|
onClose()
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: "xmark.circle")
|
||||||
|
.font(.system(size: 28))
|
||||||
|
Text("player.controls.close")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(TVActionButtonStyle())
|
||||||
|
.focused($focusedControl, equals: .closeButton)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Queue indicator (if videos in queue)
|
// Queue indicator (if videos in queue)
|
||||||
@@ -365,7 +345,9 @@ struct TVActionButtonStyle: ButtonStyle {
|
|||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(width: 100, height: 80)
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
.frame(width: 140, height: 80)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(isFocused ? .white.opacity(0.3) : .white.opacity(0.1))
|
.fill(isFocused ? .white.opacity(0.3) : .white.opacity(0.1))
|
||||||
|
|||||||
@@ -15,13 +15,12 @@ enum TVPlayerFocusTarget: Hashable {
|
|||||||
case playPause
|
case playPause
|
||||||
case skipForward
|
case skipForward
|
||||||
case progressBar
|
case progressBar
|
||||||
case qualityButton
|
case settingsButton
|
||||||
case captionsButton
|
|
||||||
case debugButton
|
|
||||||
case infoButton
|
case infoButton
|
||||||
case volumeDown
|
case commentsButton
|
||||||
case volumeUp
|
case debugButton
|
||||||
case playNext
|
case playNext
|
||||||
|
case closeButton
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Main tvOS fullscreen player view.
|
/// Main tvOS fullscreen player view.
|
||||||
@@ -40,6 +39,9 @@ struct TVPlayerView: View {
|
|||||||
/// Whether the details panel is shown.
|
/// Whether the details panel is shown.
|
||||||
@State private var isDetailsPanelVisible = false
|
@State private var isDetailsPanelVisible = false
|
||||||
|
|
||||||
|
/// Initial tab for the details panel when opened.
|
||||||
|
@State private var detailsPanelInitialTab: TVDetailsTab = .info
|
||||||
|
|
||||||
/// Whether user is scrubbing the progress bar.
|
/// Whether user is scrubbing the progress bar.
|
||||||
@State private var isScrubbing = false
|
@State private var isScrubbing = false
|
||||||
|
|
||||||
@@ -83,56 +85,77 @@ struct TVPlayerView: View {
|
|||||||
mpvPlayerContent
|
mpvPlayerContent
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.playerToastOverlay()
|
.playerToastOverlay()
|
||||||
// Quality selector sheet
|
// Quality / Settings selector (fullscreen cover gives tvOS enough room)
|
||||||
.sheet(isPresented: $showingQualitySheet) {
|
.fullScreenCover(isPresented: $showingQualitySheet) {
|
||||||
if let playerService {
|
qualitySheetContent
|
||||||
let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false
|
|
||||||
let supportedFormats = playerService.currentBackendType.supportedFormats
|
|
||||||
QualitySelectorView(
|
|
||||||
streams: playerService.availableStreams.filter { stream in
|
|
||||||
let format = StreamFormat.detect(from: stream)
|
|
||||||
if format == .dash && !dashEnabled {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return supportedFormats.contains(format)
|
|
||||||
},
|
|
||||||
captions: playerService.availableCaptions,
|
|
||||||
currentStream: playerState?.currentStream,
|
|
||||||
currentAudioStream: playerState?.currentAudioStream,
|
|
||||||
currentCaption: playerService.currentCaption,
|
|
||||||
isLoading: playerState?.playbackState == .loading,
|
|
||||||
currentDownload: playerService.currentDownload,
|
|
||||||
isLoadingOnlineStreams: playerService.isLoadingOnlineStreams,
|
|
||||||
localCaptionURL: playerService.currentDownload.flatMap { download in
|
|
||||||
guard let path = download.localCaptionPath else { return nil }
|
|
||||||
return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path)
|
|
||||||
},
|
|
||||||
currentRate: playerState?.rate ?? .x1,
|
|
||||||
onStreamSelected: { stream, audioStream in
|
|
||||||
switchToStream(stream, audioStream: audioStream)
|
|
||||||
},
|
|
||||||
onCaptionSelected: { caption in
|
|
||||||
playerService.loadCaption(caption)
|
|
||||||
},
|
|
||||||
onLoadOnlineStreams: {
|
|
||||||
Task {
|
|
||||||
await playerService.loadOnlineStreams()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSwitchToOnlineStream: { stream, audioStream in
|
|
||||||
Task {
|
|
||||||
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRateChanged: { rate in
|
|
||||||
playerState?.rate = rate
|
|
||||||
playerService.currentBackend?.rate = Float(rate.rawValue)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Quality Sheet Content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var qualitySheetContent: some View {
|
||||||
|
if let playerService {
|
||||||
|
let dashEnabled = appEnvironment?.settingsManager.dashEnabled ?? false
|
||||||
|
let supportedFormats = playerService.currentBackendType.supportedFormats
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// Dimmed backdrop over the video
|
||||||
|
Color.black.opacity(0.7)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
QualitySelectorView(
|
||||||
|
streams: playerService.availableStreams.filter { stream in
|
||||||
|
let format = StreamFormat.detect(from: stream)
|
||||||
|
if format == .dash && !dashEnabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return supportedFormats.contains(format)
|
||||||
|
},
|
||||||
|
captions: playerService.availableCaptions,
|
||||||
|
currentStream: playerState?.currentStream,
|
||||||
|
currentAudioStream: playerState?.currentAudioStream,
|
||||||
|
currentCaption: playerService.currentCaption,
|
||||||
|
isLoading: playerState?.playbackState == .loading,
|
||||||
|
currentDownload: playerService.currentDownload,
|
||||||
|
isLoadingOnlineStreams: playerService.isLoadingOnlineStreams,
|
||||||
|
localCaptionURL: playerService.currentDownload.flatMap { download in
|
||||||
|
guard let path = download.localCaptionPath else { return nil }
|
||||||
|
return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path)
|
||||||
|
},
|
||||||
|
currentRate: playerState?.rate ?? .x1,
|
||||||
|
onStreamSelected: { stream, audioStream in
|
||||||
|
switchToStream(stream, audioStream: audioStream)
|
||||||
|
},
|
||||||
|
onCaptionSelected: { caption in
|
||||||
|
playerService.loadCaption(caption)
|
||||||
|
},
|
||||||
|
onLoadOnlineStreams: {
|
||||||
|
Task {
|
||||||
|
await playerService.loadOnlineStreams()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSwitchToOnlineStream: { stream, audioStream in
|
||||||
|
Task {
|
||||||
|
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRateChanged: { rate in
|
||||||
|
playerState?.rate = rate
|
||||||
|
playerService.currentBackend?.rate = Float(rate.rawValue)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.frame(maxWidth: 900, maxHeight: 700)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 200)
|
||||||
|
.padding(.vertical, 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - MPV Content
|
// MARK: - MPV Content
|
||||||
|
|
||||||
/// Custom MPV player view with custom controls.
|
/// Custom MPV player view with custom controls.
|
||||||
@@ -151,10 +174,11 @@ struct TVPlayerView: View {
|
|||||||
playerState: playerState,
|
playerState: playerState,
|
||||||
playerService: playerService,
|
playerService: playerService,
|
||||||
focusedControl: $focusedControl,
|
focusedControl: $focusedControl,
|
||||||
onShowDetails: { showDetailsPanel() },
|
onShowSettings: { showQualitySheet() },
|
||||||
onShowQuality: { showQualitySheet() },
|
onShowDetails: { showDetailsPanel(tab: .info) },
|
||||||
|
onShowComments: { showDetailsPanel(tab: .comments) },
|
||||||
onShowDebug: { showDebugOverlay() },
|
onShowDebug: { showDebugOverlay() },
|
||||||
onDismiss: { dismissPlayer() },
|
onClose: { closeVideo() },
|
||||||
onScrubbingChanged: { scrubbing in
|
onScrubbingChanged: { scrubbing in
|
||||||
isScrubbing = scrubbing
|
isScrubbing = scrubbing
|
||||||
if scrubbing {
|
if scrubbing {
|
||||||
@@ -171,6 +195,7 @@ struct TVPlayerView: View {
|
|||||||
if isDetailsPanelVisible {
|
if isDetailsPanelVisible {
|
||||||
TVDetailsPanel(
|
TVDetailsPanel(
|
||||||
video: playerState?.currentVideo,
|
video: playerState?.currentVideo,
|
||||||
|
initialTab: detailsPanelInitialTab,
|
||||||
onDismiss: { hideDetailsPanel() }
|
onDismiss: { hideDetailsPanel() }
|
||||||
)
|
)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
@@ -336,8 +361,9 @@ struct TVPlayerView: View {
|
|||||||
|
|
||||||
// MARK: - Details Panel
|
// MARK: - Details Panel
|
||||||
|
|
||||||
private func showDetailsPanel() {
|
private func showDetailsPanel(tab: TVDetailsTab = .info) {
|
||||||
stopControlsTimer()
|
stopControlsTimer()
|
||||||
|
detailsPanelInitialTab = tab
|
||||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||||
isDetailsPanelVisible = true
|
isDetailsPanelVisible = true
|
||||||
controlsVisible = false
|
controlsVisible = false
|
||||||
@@ -466,6 +492,14 @@ struct TVPlayerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func closeVideo() {
|
||||||
|
playerState?.isClosingVideo = true
|
||||||
|
appEnvironment?.queueManager.clearQueue()
|
||||||
|
playerService?.stop()
|
||||||
|
appEnvironment?.navigationCoordinator.isPlayerExpanded = false
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
private func dismissPlayer() {
|
private func dismissPlayer() {
|
||||||
// Collapse the player but keep it alive so audio continues in the background
|
// Collapse the player but keep it alive so audio continues in the background
|
||||||
// and the "Now Playing" sidebar entry can restore the session. Matches the
|
// and the "Now Playing" sidebar entry can restore the session. Matches the
|
||||||
|
|||||||
@@ -156,6 +156,15 @@ struct DeveloperSettingsView: View {
|
|||||||
Label(String(localized: "settings.advanced.debug.zoomTransitions"), systemImage: "arrow.up.left.and.arrow.down.right")
|
Label(String(localized: "settings.advanced.debug.zoomTransitions"), systemImage: "arrow.up.left.and.arrow.down.right")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { settingsManager.showTVDebugButton },
|
||||||
|
set: { settingsManager.showTVDebugButton = $0 }
|
||||||
|
)) {
|
||||||
|
Label(String(localized: "settings.advanced.debug.showTVDebugButton"), systemImage: "ant.circle")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
Text(String(localized: "settings.advanced.debug.sectionTitle"))
|
Text(String(localized: "settings.advanced.debug.sectionTitle"))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user