mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +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:
@@ -12,12 +12,20 @@ import NukeUI
|
||||
/// Details panel that slides up from the bottom showing video information.
|
||||
struct TVDetailsPanel: View {
|
||||
let video: Video?
|
||||
let initialTab: TVDetailsTab
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
/// 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.
|
||||
@FocusState private var focusedItem: TVDetailsFocusItem?
|
||||
|
||||
@@ -15,16 +15,17 @@ struct TVPlayerControlsView: View {
|
||||
let playerService: PlayerService?
|
||||
@FocusState.Binding var focusedControl: TVPlayerFocusTarget?
|
||||
|
||||
let onShowSettings: () -> Void
|
||||
let onShowDetails: () -> Void
|
||||
let onShowQuality: () -> Void
|
||||
let onShowComments: () -> Void
|
||||
let onShowDebug: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
let onClose: () -> Void
|
||||
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
|
||||
var onScrubbingChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Whether to show in-app volume controls (only when volume mode is .mpv)
|
||||
private var showVolumeControls: Bool {
|
||||
GlobalLayoutSettings.cached.volumeMode == .mpv
|
||||
/// Whether the Debug button should be visible (user-toggled in Developer settings).
|
||||
private var showDebugButton: Bool {
|
||||
appEnvironment?.settingsManager.showTVDebugButton ?? false
|
||||
}
|
||||
|
||||
@State private var playNextTapCount = 0
|
||||
@@ -212,47 +213,19 @@ struct TVPlayerControlsView: View {
|
||||
|
||||
private var actionButtons: some View {
|
||||
HStack(spacing: 40) {
|
||||
// Quality selector
|
||||
// Settings (video / audio / subtitles / speed)
|
||||
Button {
|
||||
onShowQuality()
|
||||
onShowSettings()
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
Image(systemName: "gearshape")
|
||||
.font(.system(size: 28))
|
||||
Text("player.controls.quality")
|
||||
Text("player.controls.settings")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.buttonStyle(TVActionButtonStyle())
|
||||
.focused($focusedControl, equals: .qualityButton)
|
||||
|
||||
// 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)
|
||||
.focused($focusedControl, equals: .settingsButton)
|
||||
|
||||
// Info / Details
|
||||
Button {
|
||||
@@ -268,43 +241,36 @@ struct TVPlayerControlsView: View {
|
||||
.buttonStyle(TVActionButtonStyle())
|
||||
.focused($focusedControl, equals: .infoButton)
|
||||
|
||||
// Volume controls (only when in-app volume mode)
|
||||
if showVolumeControls {
|
||||
// Volume down
|
||||
// Comments (opens details panel on Comments tab)
|
||||
if playerState?.currentVideo?.supportsComments == true {
|
||||
Button {
|
||||
guard let state = playerState else { return }
|
||||
let newVolume = max(0, state.volume - 0.1)
|
||||
playerService?.currentBackend?.volume = newVolume
|
||||
playerService?.state.volume = newVolume
|
||||
appEnvironment?.settingsManager.playerVolume = newVolume
|
||||
onShowComments()
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "speaker.minus")
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
.font(.system(size: 28))
|
||||
Text(String(localized: "player.tvos.volumeDown"))
|
||||
Text("player.controls.comments")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.buttonStyle(TVActionButtonStyle())
|
||||
.focused($focusedControl, equals: .volumeDown)
|
||||
.focused($focusedControl, equals: .commentsButton)
|
||||
}
|
||||
|
||||
// Volume up
|
||||
// Debug overlay (only when enabled in Developer settings)
|
||||
if showDebugButton {
|
||||
Button {
|
||||
guard let state = playerState else { return }
|
||||
let newVolume = min(1.0, state.volume + 0.1)
|
||||
playerService?.currentBackend?.volume = newVolume
|
||||
playerService?.state.volume = newVolume
|
||||
appEnvironment?.settingsManager.playerVolume = newVolume
|
||||
onShowDebug()
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "speaker.plus")
|
||||
Image(systemName: "ant.circle")
|
||||
.font(.system(size: 28))
|
||||
Text(String(localized: "player.tvos.volumeUp"))
|
||||
Text(String(localized: "player.debug.titleShort"))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.buttonStyle(TVActionButtonStyle())
|
||||
.focused($focusedControl, equals: .volumeUp)
|
||||
.focused($focusedControl, equals: .debugButton)
|
||||
}
|
||||
|
||||
// Play next button (when queue has items)
|
||||
@@ -325,6 +291,20 @@ struct TVPlayerControlsView: View {
|
||||
.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()
|
||||
|
||||
// Queue indicator (if videos in queue)
|
||||
@@ -365,7 +345,9 @@ struct TVActionButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 100, height: 80)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.frame(width: 140, height: 80)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isFocused ? .white.opacity(0.3) : .white.opacity(0.1))
|
||||
|
||||
@@ -15,13 +15,12 @@ enum TVPlayerFocusTarget: Hashable {
|
||||
case playPause
|
||||
case skipForward
|
||||
case progressBar
|
||||
case qualityButton
|
||||
case captionsButton
|
||||
case debugButton
|
||||
case settingsButton
|
||||
case infoButton
|
||||
case volumeDown
|
||||
case volumeUp
|
||||
case commentsButton
|
||||
case debugButton
|
||||
case playNext
|
||||
case closeButton
|
||||
}
|
||||
|
||||
/// Main tvOS fullscreen player view.
|
||||
@@ -40,6 +39,9 @@ struct TVPlayerView: View {
|
||||
/// Whether the details panel is shown.
|
||||
@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.
|
||||
@State private var isScrubbing = false
|
||||
|
||||
@@ -83,56 +85,77 @@ struct TVPlayerView: View {
|
||||
mpvPlayerContent
|
||||
.ignoresSafeArea()
|
||||
.playerToastOverlay()
|
||||
// Quality selector sheet
|
||||
.sheet(isPresented: $showingQualitySheet) {
|
||||
if let playerService {
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
// Quality / Settings selector (fullscreen cover gives tvOS enough room)
|
||||
.fullScreenCover(isPresented: $showingQualitySheet) {
|
||||
qualitySheetContent
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
/// Custom MPV player view with custom controls.
|
||||
@@ -151,10 +174,11 @@ struct TVPlayerView: View {
|
||||
playerState: playerState,
|
||||
playerService: playerService,
|
||||
focusedControl: $focusedControl,
|
||||
onShowDetails: { showDetailsPanel() },
|
||||
onShowQuality: { showQualitySheet() },
|
||||
onShowSettings: { showQualitySheet() },
|
||||
onShowDetails: { showDetailsPanel(tab: .info) },
|
||||
onShowComments: { showDetailsPanel(tab: .comments) },
|
||||
onShowDebug: { showDebugOverlay() },
|
||||
onDismiss: { dismissPlayer() },
|
||||
onClose: { closeVideo() },
|
||||
onScrubbingChanged: { scrubbing in
|
||||
isScrubbing = scrubbing
|
||||
if scrubbing {
|
||||
@@ -171,6 +195,7 @@ struct TVPlayerView: View {
|
||||
if isDetailsPanelVisible {
|
||||
TVDetailsPanel(
|
||||
video: playerState?.currentVideo,
|
||||
initialTab: detailsPanelInitialTab,
|
||||
onDismiss: { hideDetailsPanel() }
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
@@ -336,8 +361,9 @@ struct TVPlayerView: View {
|
||||
|
||||
// MARK: - Details Panel
|
||||
|
||||
private func showDetailsPanel() {
|
||||
private func showDetailsPanel(tab: TVDetailsTab = .info) {
|
||||
stopControlsTimer()
|
||||
detailsPanelInitialTab = tab
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
isDetailsPanelVisible = true
|
||||
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() {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user