mirror of
https://github.com/yattee/yattee.git
synced 2025-11-25 18:58:21 +00:00
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:
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user