From 997de6468d50d43527217ce7fc86d34e445a25df Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 22 Nov 2025 23:39:55 +0100 Subject: [PATCH] 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. --- Shared/Player/Controls/ControlsOverlay.swift | 59 +++++++++----------- Shared/Player/StreamControl.swift | 16 ++++-- tvOS/ControlsOverlayButton.swift | 41 +++++++++++--- 3 files changed, 72 insertions(+), 44 deletions(-) diff --git a/Shared/Player/Controls/ControlsOverlay.swift b/Shared/Player/Controls/ControlsOverlay.swift index 728b22b0..b5846fae 100644 --- a/Shared/Player/Controls/ControlsOverlay.swift +++ b/Shared/Player/Controls/ControlsOverlay.swift @@ -21,7 +21,10 @@ struct ControlsOverlay: View { } @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 var body: some View { @@ -102,19 +105,9 @@ struct ControlsOverlay: View { #if os(tvOS) .padding(.horizontal, 40) #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) - .alert(isPresented: $presentingButtonHintAlert) { - Alert(title: Text("Press and hold to open this menu")) - } .onAppear { focusedField = .qualityProfile } @@ -125,14 +118,6 @@ struct ControlsOverlay: View { 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 { Text(text) .font(.system(.caption)) @@ -277,23 +262,25 @@ struct ControlsOverlay: View { .modifier(ControlBackgroundModifier()) .mask(RoundedRectangle(cornerRadius: 3)) #else - ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) { + ControlsOverlayButton( + focusedField: $focusedField, + field: .qualityProfile, + onSelect: { presentingQualityProfileMenu = true } + ) { Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) .lineLimit(1) .frame(maxWidth: 320) } - .contextMenu { + .alert("Quality Profile", isPresented: $presentingQualityProfileMenu) { Button("Automatic") { player.qualityProfileSelection = nil } ForEach(qualityProfiles) { qualityProfile in - Button { + Button(qualityProfile.description) { player.qualityProfileSelection = qualityProfile - } label: { - Text(qualityProfile.description) } - - Button("Cancel", role: .cancel) {} } + + Button("Cancel", role: .cancel) {} } #endif } @@ -328,7 +315,7 @@ struct ControlsOverlay: View { .modifier(ControlBackgroundModifier()) .mask(RoundedRectangle(cornerRadius: 3)) #else - StreamControl(focusedField: $focusedField) + StreamControl(focusedField: $focusedField, presentingStreamMenu: $presentingStreamMenu) #endif } @@ -367,7 +354,11 @@ struct ControlsOverlay: View { .modifier(ControlBackgroundModifier()) .mask(RoundedRectangle(cornerRadius: 3)) #else - ControlsOverlayButton(focusedField: $focusedField, field: .captions) { + ControlsOverlayButton( + focusedField: $focusedField, + field: .captions, + onSelect: { presentingCaptionsMenu = true } + ) { HStack(spacing: 8) { Image(systemName: "text.bubble") if let captions = captionsBinding.wrappedValue, @@ -386,7 +377,7 @@ struct ControlsOverlay: View { } .frame(maxWidth: 320) } - .contextMenu { + .alert("Captions", isPresented: $presentingCaptionsMenu) { Button("Disabled") { captionsBinding.wrappedValue = nil } ForEach(player.currentVideo?.captions ?? []) { caption in @@ -440,11 +431,15 @@ struct ControlsOverlay: View { .frame(maxWidth: 240, alignment: .trailing) .frame(height: 40) #else - ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) { + ControlsOverlayButton( + focusedField: $focusedField, + field: .audioTrack, + onSelect: { presentingAudioTrackMenu = true } + ) { Text(player.selectedAudioTrack?.displayLanguage ?? "Original") .frame(maxWidth: 320) } - .contextMenu { + .alert("Audio Track", isPresented: $presentingAudioTrackMenu) { ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in Button(track.description) { player.selectedAudioTrackIndex = index } } diff --git a/Shared/Player/StreamControl.swift b/Shared/Player/StreamControl.swift index 659bb5e4..17d6fcc1 100644 --- a/Shared/Player/StreamControl.swift +++ b/Shared/Player/StreamControl.swift @@ -3,9 +3,11 @@ import SwiftUI struct StreamControl: View { #if os(tvOS) var focusedField: FocusState.Binding? + @Binding var presentingStreamMenu: Bool - init(focusedField: FocusState.Binding?) { + init(focusedField: FocusState.Binding?, presentingStreamMenu: Binding) { self.focusedField = focusedField + _presentingStreamMenu = presentingStreamMenu } #endif @@ -45,16 +47,20 @@ struct StreamControl: View { .fixedSize() #endif #else - ControlsOverlayButton(focusedField: focusedField!, field: .stream) { + ControlsOverlayButton( + focusedField: focusedField!, + field: .stream, + onSelect: { presentingStreamMenu = true } + ) { Text(player.streamSelection?.shortQuality ?? "loading") .frame(maxWidth: 320) } - .contextMenu { + .alert("Stream Quality", isPresented: $presentingStreamMenu) { ForEach(streams) { stream in Button(stream.description) { player.streamSelection = stream } } - Button("Close", role: .cancel) {} + Button("Cancel", role: .cancel) {} } #endif } @@ -79,7 +85,7 @@ struct StreamControl: View { struct StreamControl_Previews: PreviewProvider { static var previews: some View { #if os(tvOS) - StreamControl(focusedField: .none) + StreamControl(focusedField: .none, presentingStreamMenu: .constant(false)) .injectFixtureEnvironmentObjects() #else StreamControl() diff --git a/tvOS/ControlsOverlayButton.swift b/tvOS/ControlsOverlayButton.swift index 1bc2ac6e..3ce643bf 100644 --- a/tvOS/ControlsOverlayButton.swift +++ b/tvOS/ControlsOverlayButton.swift @@ -4,25 +4,52 @@ struct ControlsOverlayButton: View { var focusedField: FocusState.Binding var field: ControlsOverlay.Field let label: LabelView + var onSelect: (() -> Void)? init( focusedField: FocusState.Binding, field: ControlsOverlay.Field, + onSelect: (() -> Void)? = nil, @ViewBuilder label: @escaping () -> LabelView ) { self.focusedField = focusedField self.field = field + self.onSelect = onSelect self.label = label() } var body: some View { - label - .padding() - .frame(width: 400) - .focusable() + let isFocused = focusedField.wrappedValue == field + + if let onSelect { + Button(action: onSelect) { + label + .padding() + .frame(width: 400) + } + .buttonStyle(TVButtonStyle(isFocused: isFocused)) .focused(focusedField, equals: field) - .background(focusedField.wrappedValue == field ? Color.white : Color.secondary) - .foregroundColor(focusedField.wrappedValue == field ? Color.black : Color.white) - .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + label + .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) } }