Improve tvOS controls overlay with single-press menus

Changed context menus from press-and-hold to single-press for better UX:
- Quality profile selection
- Stream quality selection
- Captions selection
- Audio track selection

Updated ControlsOverlayButton to support tap actions via new onSelect parameter.
Replaced .contextMenu modifiers with .alert for instant menu access on tvOS.
Removed hint text and unnecessary 80px padding as single-press is self-evident.
This commit is contained in:
Arkadiusz Fal
2025-11-22 23:39:55 +01:00
parent 1397a2fee6
commit 997de6468d
3 changed files with 72 additions and 44 deletions

View File

@@ -21,7 +21,10 @@ struct ControlsOverlay: View {
} }
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@State private var presentingButtonHintAlert = false @State private var presentingQualityProfileMenu = false
@State private var presentingStreamMenu = false
@State private var presentingCaptionsMenu = false
@State private var presentingAudioTrackMenu = false
#endif #endif
var body: some View { var body: some View {
@@ -102,19 +105,9 @@ struct ControlsOverlay: View {
#if os(tvOS) #if os(tvOS)
.padding(.horizontal, 40) .padding(.horizontal, 40)
#endif #endif
#if os(tvOS)
Text("Press and hold remote button to open captions and quality menus")
.frame(maxWidth: 400)
.font(.caption)
.foregroundColor(.secondary)
#endif
} }
.frame(maxHeight: overlayHeight) .frame(maxHeight: contentSize.height)
#if os(tvOS) #if os(tvOS)
.alert(isPresented: $presentingButtonHintAlert) {
Alert(title: Text("Press and hold to open this menu"))
}
.onAppear { .onAppear {
focusedField = .qualityProfile focusedField = .qualityProfile
} }
@@ -125,14 +118,6 @@ struct ControlsOverlay: View {
player.activeBackend == .mpv ? "Rate & Captions" : "Playback Rate" player.activeBackend == .mpv ? "Rate & Captions" : "Playback Rate"
} }
private var overlayHeight: Double {
#if os(tvOS)
contentSize.height + 80.0
#else
contentSize.height
#endif
}
private func controlsHeader(_ text: String) -> some View { private func controlsHeader(_ text: String) -> some View {
Text(text) Text(text)
.font(.system(.caption)) .font(.system(.caption))
@@ -277,23 +262,25 @@ struct ControlsOverlay: View {
.modifier(ControlBackgroundModifier()) .modifier(ControlBackgroundModifier())
.mask(RoundedRectangle(cornerRadius: 3)) .mask(RoundedRectangle(cornerRadius: 3))
#else #else
ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) { ControlsOverlayButton(
focusedField: $focusedField,
field: .qualityProfile,
onSelect: { presentingQualityProfileMenu = true }
) {
Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
.lineLimit(1) .lineLimit(1)
.frame(maxWidth: 320) .frame(maxWidth: 320)
} }
.contextMenu { .alert("Quality Profile", isPresented: $presentingQualityProfileMenu) {
Button("Automatic") { player.qualityProfileSelection = nil } Button("Automatic") { player.qualityProfileSelection = nil }
ForEach(qualityProfiles) { qualityProfile in ForEach(qualityProfiles) { qualityProfile in
Button { Button(qualityProfile.description) {
player.qualityProfileSelection = qualityProfile player.qualityProfileSelection = qualityProfile
} label: {
Text(qualityProfile.description)
} }
Button("Cancel", role: .cancel) {}
} }
Button("Cancel", role: .cancel) {}
} }
#endif #endif
} }
@@ -328,7 +315,7 @@ struct ControlsOverlay: View {
.modifier(ControlBackgroundModifier()) .modifier(ControlBackgroundModifier())
.mask(RoundedRectangle(cornerRadius: 3)) .mask(RoundedRectangle(cornerRadius: 3))
#else #else
StreamControl(focusedField: $focusedField) StreamControl(focusedField: $focusedField, presentingStreamMenu: $presentingStreamMenu)
#endif #endif
} }
@@ -367,7 +354,11 @@ struct ControlsOverlay: View {
.modifier(ControlBackgroundModifier()) .modifier(ControlBackgroundModifier())
.mask(RoundedRectangle(cornerRadius: 3)) .mask(RoundedRectangle(cornerRadius: 3))
#else #else
ControlsOverlayButton(focusedField: $focusedField, field: .captions) { ControlsOverlayButton(
focusedField: $focusedField,
field: .captions,
onSelect: { presentingCaptionsMenu = true }
) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "text.bubble") Image(systemName: "text.bubble")
if let captions = captionsBinding.wrappedValue, if let captions = captionsBinding.wrappedValue,
@@ -386,7 +377,7 @@ struct ControlsOverlay: View {
} }
.frame(maxWidth: 320) .frame(maxWidth: 320)
} }
.contextMenu { .alert("Captions", isPresented: $presentingCaptionsMenu) {
Button("Disabled") { captionsBinding.wrappedValue = nil } Button("Disabled") { captionsBinding.wrappedValue = nil }
ForEach(player.currentVideo?.captions ?? []) { caption in ForEach(player.currentVideo?.captions ?? []) { caption in
@@ -440,11 +431,15 @@ struct ControlsOverlay: View {
.frame(maxWidth: 240, alignment: .trailing) .frame(maxWidth: 240, alignment: .trailing)
.frame(height: 40) .frame(height: 40)
#else #else
ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) { ControlsOverlayButton(
focusedField: $focusedField,
field: .audioTrack,
onSelect: { presentingAudioTrackMenu = true }
) {
Text(player.selectedAudioTrack?.displayLanguage ?? "Original") Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
.frame(maxWidth: 320) .frame(maxWidth: 320)
} }
.contextMenu { .alert("Audio Track", isPresented: $presentingAudioTrackMenu) {
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
Button(track.description) { player.selectedAudioTrackIndex = index } Button(track.description) { player.selectedAudioTrackIndex = index }
} }

View File

@@ -3,9 +3,11 @@ import SwiftUI
struct StreamControl: View { struct StreamControl: View {
#if os(tvOS) #if os(tvOS)
var focusedField: FocusState<ControlsOverlay.Field?>.Binding? var focusedField: FocusState<ControlsOverlay.Field?>.Binding?
@Binding var presentingStreamMenu: Bool
init(focusedField: FocusState<ControlsOverlay.Field?>.Binding?) { init(focusedField: FocusState<ControlsOverlay.Field?>.Binding?, presentingStreamMenu: Binding<Bool>) {
self.focusedField = focusedField self.focusedField = focusedField
_presentingStreamMenu = presentingStreamMenu
} }
#endif #endif
@@ -45,16 +47,20 @@ struct StreamControl: View {
.fixedSize() .fixedSize()
#endif #endif
#else #else
ControlsOverlayButton(focusedField: focusedField!, field: .stream) { ControlsOverlayButton(
focusedField: focusedField!,
field: .stream,
onSelect: { presentingStreamMenu = true }
) {
Text(player.streamSelection?.shortQuality ?? "loading") Text(player.streamSelection?.shortQuality ?? "loading")
.frame(maxWidth: 320) .frame(maxWidth: 320)
} }
.contextMenu { .alert("Stream Quality", isPresented: $presentingStreamMenu) {
ForEach(streams) { stream in ForEach(streams) { stream in
Button(stream.description) { player.streamSelection = stream } Button(stream.description) { player.streamSelection = stream }
} }
Button("Close", role: .cancel) {} Button("Cancel", role: .cancel) {}
} }
#endif #endif
} }
@@ -79,7 +85,7 @@ struct StreamControl: View {
struct StreamControl_Previews: PreviewProvider { struct StreamControl_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
#if os(tvOS) #if os(tvOS)
StreamControl(focusedField: .none) StreamControl(focusedField: .none, presentingStreamMenu: .constant(false))
.injectFixtureEnvironmentObjects() .injectFixtureEnvironmentObjects()
#else #else
StreamControl() StreamControl()

View File

@@ -4,25 +4,52 @@ struct ControlsOverlayButton<LabelView: View>: View {
var focusedField: FocusState<ControlsOverlay.Field?>.Binding var focusedField: FocusState<ControlsOverlay.Field?>.Binding
var field: ControlsOverlay.Field var field: ControlsOverlay.Field
let label: LabelView let label: LabelView
var onSelect: (() -> Void)?
init( init(
focusedField: FocusState<ControlsOverlay.Field?>.Binding, focusedField: FocusState<ControlsOverlay.Field?>.Binding,
field: ControlsOverlay.Field, field: ControlsOverlay.Field,
onSelect: (() -> Void)? = nil,
@ViewBuilder label: @escaping () -> LabelView @ViewBuilder label: @escaping () -> LabelView
) { ) {
self.focusedField = focusedField self.focusedField = focusedField
self.field = field self.field = field
self.onSelect = onSelect
self.label = label() self.label = label()
} }
var body: some View { var body: some View {
label let isFocused = focusedField.wrappedValue == field
.padding()
.frame(width: 400) if let onSelect {
.focusable() Button(action: onSelect) {
label
.padding()
.frame(width: 400)
}
.buttonStyle(TVButtonStyle(isFocused: isFocused))
.focused(focusedField, equals: field) .focused(focusedField, equals: field)
.background(focusedField.wrappedValue == field ? Color.white : Color.secondary) } else {
.foregroundColor(focusedField.wrappedValue == field ? Color.black : Color.white) label
.clipShape(RoundedRectangle(cornerRadius: 4)) .padding()
.frame(width: 400)
.focusable()
.focused(focusedField, equals: field)
.background(isFocused ? Color.white : Color.secondary)
.foregroundColor(isFocused ? Color.black : Color.white)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
}
struct TVButtonStyle: ButtonStyle {
let isFocused: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(isFocused ? Color.white : Color.secondary)
.foregroundColor(isFocused ? Color.black : Color.white)
.clipShape(RoundedRectangle(cornerRadius: 4))
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
} }
} }