Files
yattee/Shared/Player/PlaybackSettings.swift
Arkadiusz Fal a0a54bced9 Improve layout stability and disable unwanted animations
Added height reservation to FavoriteItemView to prevent layout shifts during content loading. Changed HomeView to use LazyVStack for better performance. Converted QueueView from LazyVStack to VStack. Disabled animations on content count changes across multiple views to prevent jarring layout transitions. Added width constraint to stream button in PlaybackSettings.
2025-11-14 20:02:07 +01:00

539 lines
19 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(alignment: .center) {
controlsHeader("Rate".localized())
Spacer()
HStack(alignment: .center, 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)
Menu {
ratePicker
} label: {
Text(player.rateLabel(player.currentRate))
.foregroundColor(.primary)
.frame(width: 70)
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.foregroundColor(.primary)
.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()
#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()
#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
Menu {
playbackModePicker
} label: {
Label(player.playbackMode.description.localized(), systemImage: player.playbackMode.systemImage)
}
.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)
Menu {
qualityProfilePicker
} label: {
Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
.frame(maxWidth: 240, alignment: .trailing)
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.foregroundColor(.accentColor)
.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)
Menu {
StreamControl()
} label: {
Text(player.streamSelection?.resolutionAndFormat ?? "loading...")
.frame(width: 140, height: 40, alignment: .trailing)
.foregroundColor(player.streamSelection == nil ? .secondary : .accentColor)
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.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)
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))")
.foregroundColor(.accentColor)
} else {
if videoCaptions?.isEmpty == true {
Text("Not available")
} else {
Text("Disabled")
}
}
}
.frame(alignment: .trailing)
.frame(height: 40)
.disabled(videoCaptions?.isEmpty == true)
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.foregroundColor(.accentColor)
#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)
Menu {
audioTrackPicker
} label: {
Text(player.availableAudioTracks[player.selectedAudioTrackIndex].displayLanguage)
.frame(maxWidth: 240, alignment: .trailing)
}
.transaction { t in t.animation = .none }
.buttonStyle(.plain)
.foregroundColor(.accentColor)
.frame(maxWidth: 240, alignment: .trailing)
.frame(height: 40)
#else
ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) {
Text(player.availableAudioTracks[player.selectedAudioTrackIndex].displayLanguage)
.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()
}
}