mirror of
https://github.com/yattee/yattee.git
synced 2025-11-24 18:28:20 +00:00
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
598 lines
21 KiB
Swift
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()
|
|
}
|
|
}
|