Files
yattee/Shared/Player/PlaybackSettings.swift
Arkadiusz Fal 65e86d30ec Fix iOS playback settings menu text disappearing and resizing issues
When tapping menus in playback settings (playback mode, quality profile,
stream, rate, captions, audio track), the selected value text would
disappear and cause unwanted resizing animations.

Implemented ZStack overlay technique for all iOS menu buttons:
- Visible static label remains on screen
- Invisible Menu overlay (.opacity(0)) handles tap interactions
- Prevents text from disappearing when menu opens
- Eliminates resizing animations on option selection
2025-11-23 14:09:14 +01:00

598 lines
21 KiB
Swift

import Combine
import Defaults
import SwiftUI
struct PlaybackSettings: View {
@ObservedObject private var player = PlayerModel.shared
private var model = PlayerControlsModel.shared
@State private var contentSize: CGSize = .zero
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
@Default(.qualityProfiles) private var qualityProfiles
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
#if os(tvOS)
enum Field: Hashable {
case qualityProfile
case stream
case increaseRate
case decreaseRate
case captions
case audioTrack
}
@FocusState private var focusedField: Field?
@State private var presentingButtonHintAlert = false
#endif
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
HStack {
Button {
withAnimation(ControlOverlaysModel.animation) {
NavigationModel.shared.presentingPlaybackSettings = false
}
} label: {
Label("Close", systemImage: "xmark")
#if os(iOS)
.labelStyle(.iconOnly)
.frame(maxWidth: 50, alignment: .leading)
#endif
}
.keyboardShortcut(.cancelAction)
Spacer()
Text("Playback Settings")
.font(.headline)
.frame(maxWidth: .infinity)
Spacer()
.frame(maxWidth: 50, alignment: .trailing)
}
.padding(.top, 10)
HStack {
controlsHeader("Playback Mode".localized())
Spacer()
playbackModeControl
}
.padding(.vertical, 10)
if player.activeBackend == .mpv || !player.avPlayerUsesSystemControls {
HStack {
controlsHeader("Rate".localized())
Spacer()
HStack(spacing: rateButtonsSpacing) {
decreaseRateButton
#if os(tvOS)
.focused($focusedField, equals: .decreaseRate)
#endif
rateButton
increaseRateButton
#if os(tvOS)
.focused($focusedField, equals: .increaseRate)
#endif
}
}
}
if player.activeBackend == .mpv {
HStack {
controlsHeader("Captions".localized())
Spacer()
captionsButton
#if os(tvOS)
.focused($focusedField, equals: .captions)
#endif
#if os(iOS)
.foregroundColor(.white)
#endif
}
}
HStack {
controlsHeader("Quality Profile".localized())
Spacer()
qualityProfileButton
#if os(tvOS)
.focused($focusedField, equals: .qualityProfile)
#endif
}
HStack {
controlsHeader("Stream".localized())
Spacer()
streamButton
#if os(tvOS)
.focused($focusedField, equals: .stream)
#endif
}
if !player.availableAudioTracks.isEmpty {
HStack {
controlsHeader("Audio Track".localized())
Spacer()
audioTrackButton
#if os(tvOS)
.focused($focusedField, equals: .audioTrack)
#endif
}
}
HStack(spacing: 8) {
controlsHeader("Backend".localized())
Spacer()
backendButtons
}
if player.activeBackend == .mpv,
showMPVPlaybackStats
{
Section(header: controlsHeader("Statistics".localized()).padding(.top, 15)) {
PlaybackStatsView()
}
}
}
#if os(iOS)
.padding(.top, verticalSizeClass == .regular ? 10 : 0)
.padding(.bottom, 15)
#else
.padding(.top)
#endif
.padding(.horizontal)
.overlay(
GeometryReader { geometry in
Color.clear.onAppear {
contentSize = geometry.size
}
}
)
}
.animation(nil, value: player.activeBackend)
.frame(alignment: .topLeading)
.ignoresSafeArea(.all, edges: .bottom)
.backport
.playbackSettingsPresentationDetents()
#if os(macOS)
.frame(width: 500)
.frame(minHeight: 350, maxHeight: 450)
#endif
}
private func controlsHeader(_ text: String) -> some View {
Text(text)
}
private var backendButtons: some View {
ForEach(PlayerBackendType.allCases, id: \.self) { backend in
backendButton(backend)
.frame(height: 40)
#if os(iOS)
.padding(12)
.frame(height: 50)
.background(RoundedRectangle(cornerRadius: 4).foregroundColor(player.activeBackend == backend ? Color.accentColor : Color.clear))
.contentShape(Rectangle())
#endif
}
}
private func backendButton(_ backend: PlayerBackendType) -> some View {
Button {
player.saveTime {
player.changeActiveBackend(from: player.activeBackend, to: backend)
model.resetTimer()
}
} label: {
Text(backend.label)
.fontWeight(player.activeBackend == backend ? .bold : .regular)
#if os(iOS)
.foregroundColor(player.activeBackend == backend ? .white : .secondary)
#else
.foregroundColor(player.activeBackend == backend ? .accentColor : .secondary)
#endif
}
#if os(macOS)
.buttonStyle(.bordered)
#endif
}
@ViewBuilder private var rateButton: some View {
#if os(macOS)
ratePicker
.modifier(SettingsPickerModifier())
.controlSize(.large)
.frame(width: 100, alignment: .center)
#elseif os(iOS)
ZStack {
Text(player.rateLabel(player.currentRate))
.foregroundColor(.primary)
.frame(width: 70)
Menu {
ratePicker
} label: {
Text(player.rateLabel(player.currentRate))
.foregroundColor(.primary)
.frame(width: 70)
.opacity(0)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
.frame(width: 70, height: 40)
#else
Text(player.rateLabel(player.currentRate))
.frame(minWidth: 120)
#endif
}
var ratePicker: some View {
Picker("Rate", selection: $player.currentRate) {
ForEach(player.backend.suggestedPlaybackRates, id: \.self) { rate in
Text(player.rateLabel(rate)).tag(rate)
}
}
.transaction { t in t.animation = .none }
}
private var increaseRateButton: some View {
let increasedRate = player.backend.suggestedPlaybackRates.first { $0 > player.currentRate }
return Button {
if let rate = increasedRate {
player.currentRate = rate
}
} label: {
#if os(macOS)
Image(systemName: "plus")
.imageScale(.large)
.frame(width: 16, height: 16)
#else
Label("Increase rate", systemImage: "plus")
.imageScale(.large)
.labelStyle(.iconOnly)
.foregroundColor(.accentColor)
.padding(12)
.frame(width: 40, height: 40)
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(Color.accentColor, lineWidth: 1))
.contentShape(Rectangle())
#endif
}
#if os(macOS)
.buttonStyle(.bordered)
.controlSize(.large)
.frame(minWidth: 32, minHeight: 28)
.fixedSize()
#elseif os(iOS)
.buttonStyle(.plain)
#else
.buttonStyle(.bordered)
.controlSize(.large)
#endif
.disabled(increasedRate.isNil)
}
private var decreaseRateButton: some View {
let decreasedRate = player.backend.suggestedPlaybackRates.last { $0 < player.currentRate }
return Button {
if let rate = decreasedRate {
player.currentRate = rate
}
} label: {
#if os(macOS)
Image(systemName: "minus")
.imageScale(.large)
.frame(width: 16, height: 16)
#else
Label("Decrease rate", systemImage: "minus")
.imageScale(.large)
.labelStyle(.iconOnly)
.foregroundColor(.accentColor)
.padding(12)
.frame(width: 40, height: 40)
.background(RoundedRectangle(cornerRadius: 4).strokeBorder(Color.accentColor, lineWidth: 1))
.contentShape(Rectangle())
#endif
}
#if os(macOS)
.buttonStyle(.bordered)
.controlSize(.large)
.frame(minWidth: 32, minHeight: 28)
.fixedSize()
#elseif os(iOS)
.buttonStyle(.plain)
#else
.buttonStyle(.bordered)
.controlSize(.large)
#endif
.disabled(decreasedRate.isNil)
}
private var rateButtonsSpacing: Double {
#if os(tvOS)
10
#else
8
#endif
}
@ViewBuilder var playbackModeControl: some View {
#if os(tvOS)
Button {
player.playbackMode = player.playbackMode.next()
} label: {
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
.transaction { t in t.animation = nil }
.frame(minWidth: 350)
}
#elseif os(macOS)
playbackModePicker
.modifier(SettingsPickerModifier())
.controlSize(.large)
.frame(width: 300, alignment: .trailing)
#else
ZStack {
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
.foregroundColor(.accentColor)
Menu {
playbackModePicker
} label: {
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
.foregroundColor(.accentColor)
.opacity(0)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
#endif
}
var playbackModePicker: some View {
Picker("Playback Mode", selection: $player.playbackMode) {
ForEach(PlayerModel.PlaybackMode.allCases, id: \.rawValue) { mode in
Label(mode.description.localized(), systemImage: mode.systemImage).tag(mode)
}
}
.labelsHidden()
}
@ViewBuilder private var qualityProfileButton: some View {
#if os(macOS)
qualityProfilePicker
.modifier(SettingsPickerModifier())
.controlSize(.large)
.frame(width: 300, alignment: .trailing)
#elseif os(iOS)
ZStack {
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
Menu {
qualityProfilePicker
} label: {
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
.opacity(0)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
.frame(maxWidth: 240, alignment: .trailing)
.frame(height: 40)
#else
ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) {
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
.lineLimit(1)
.frame(maxWidth: 320)
}
.contextMenu {
Button("Automatic") { player.qualityProfileSelection = nil }
ForEach(qualityProfiles) { qualityProfile in
Button {
player.qualityProfileSelection = qualityProfile
} label: {
Text(qualityProfile.description)
}
Button("Cancel", role: .cancel) {}
}
}
#endif
}
private var qualityProfilePicker: some View {
Picker("Quality Profile", selection: $player.qualityProfileSelection) {
Text("Automatic").tag(QualityProfile?.none)
ForEach(qualityProfiles) { qualityProfile in
Text(qualityProfile.description).tag(qualityProfile as QualityProfile?)
}
}
.transaction { t in t.animation = .none }
}
@ViewBuilder private var streamButton: some View {
#if os(macOS)
StreamControl()
.modifier(SettingsPickerModifier())
.controlSize(.large)
.frame(width: 300, alignment: .trailing)
#elseif os(iOS)
ZStack {
Text(player.streamSelection?.resolutionAndFormat ?? "loading...")
.foregroundColor(player.streamSelection == nil ? .secondary : .accentColor)
.frame(width: 140, height: 40, alignment: .trailing)
Menu {
StreamControl()
} label: {
Text(player.streamSelection?.resolutionAndFormat ?? "loading...")
.foregroundColor(player.streamSelection == nil ? .secondary : .accentColor)
.frame(width: 140, height: 40, alignment: .trailing)
.opacity(0)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
.frame(width: 140, height: 40, alignment: .trailing)
#else
StreamControl(focusedField: $focusedField)
#endif
}
@ViewBuilder private var captionsButton: some View {
let videoCaptions = player.currentVideo?.captions
#if os(macOS)
captionsPicker
.modifier(SettingsPickerModifier())
.controlSize(.large)
.frame(width: 300, alignment: .trailing)
#elseif os(iOS)
ZStack {
HStack(spacing: 4) {
Image(systemName: "text.bubble")
if let captions = player.captions,
let language = LanguageCodes(rawValue: captions.code)
{
Text("\(language.description.capitalized) (\(language.rawValue))")
} else {
if videoCaptions?.isEmpty == true {
Text("Not available")
} else {
Text("Disabled")
}
}
}
.foregroundColor(.accentColor)
.frame(alignment: .trailing)
.frame(height: 40)
Menu {
if videoCaptions?.isEmpty == false {
captionsPicker
}
} label: {
HStack(spacing: 4) {
Image(systemName: "text.bubble")
if let captions = player.captions,
let language = LanguageCodes(rawValue: captions.code)
{
Text("\(language.description.capitalized) (\(language.rawValue))")
} else {
if videoCaptions?.isEmpty == true {
Text("Not available")
} else {
Text("Disabled")
}
}
}
.foregroundColor(.accentColor)
.frame(alignment: .trailing)
.frame(height: 40)
.opacity(0)
.disabled(videoCaptions?.isEmpty == true)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
#else
ControlsOverlayButton(focusedField: $focusedField, field: .captions) {
HStack(spacing: 8) {
Image(systemName: "text.bubble")
if let captions = captionsBinding.wrappedValue {
Text(captions.code)
}
}
.frame(maxWidth: 320)
}
.contextMenu {
Button("Disabled") { captionsBinding.wrappedValue = nil }
ForEach(player.currentVideo?.captions ?? []) { caption in
Button(caption.description) { captionsBinding.wrappedValue = caption }
}
Button("Cancel", role: .cancel) {}
}
#endif
}
@ViewBuilder private var captionsPicker: some View {
let captions = player.currentVideo?.captions ?? []
Picker("Captions".localized(), selection: $player.captions) {
if captions.isEmpty {
Text("Not available").tag(Captions?.none)
} else {
Text("Disabled").tag(Captions?.none)
ForEach(captions) { caption in
Text(caption.description).tag(Optional(caption))
}
}
}
.disabled(captions.isEmpty)
}
@ViewBuilder private var audioTrackButton: some View {
#if os(macOS)
audioTrackPicker
.modifier(SettingsPickerModifier())
.controlSize(.large)
.frame(width: 300, alignment: .trailing)
#elseif os(iOS)
ZStack {
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
Menu {
audioTrackPicker
} label: {
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
.opacity(0)
}
.buttonStyle(.plain)
.transaction { t in t.animation = .none }
}
.frame(maxWidth: 240, alignment: .trailing)
.frame(height: 40)
#else
ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) {
Text(player.selectedAudioTrack?.displayLanguage ?? "Original")
.frame(maxWidth: 320)
}
.contextMenu {
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
Button(track.description) { player.selectedAudioTrackIndex = index }
}
Button("Cancel", role: .cancel) {}
}
#endif
}
private var audioTrackPicker: some View {
Picker("", selection: $player.selectedAudioTrackIndex) {
ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in
Text(track.description).tag(index)
}
}
.transaction { t in t.animation = .none }
}
}
struct PlaybackSettings_Previews: PreviewProvider {
static var previews: some View {
PlaybackSettings()
}
}