mirror of
				https://github.com/yattee/yattee.git
				synced 2025-11-03 22:22:02 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			456 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			456 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
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
 | 
						|
        }
 | 
						|
 | 
						|
        @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")
 | 
						|
                            .imageScale(.large)
 | 
						|
                            .padding(.vertical)
 | 
						|
 | 
						|
                        #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)
 | 
						|
                }
 | 
						|
 | 
						|
                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
 | 
						|
                }
 | 
						|
 | 
						|
                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
 | 
						|
                .labelsHidden()
 | 
						|
                .frame(maxWidth: 100)
 | 
						|
        #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: {
 | 
						|
            Label("Increase rate", systemImage: "plus")
 | 
						|
                .foregroundColor(.accentColor)
 | 
						|
                .imageScale(.large)
 | 
						|
                .labelStyle(.iconOnly)
 | 
						|
            #if os(iOS)
 | 
						|
                .padding(12)
 | 
						|
                .frame(width: 40, height: 40)
 | 
						|
 | 
						|
                .background(RoundedRectangle(cornerRadius: 4).strokeBorder(Color.accentColor, lineWidth: 1))
 | 
						|
                .contentShape(Rectangle())
 | 
						|
            #endif
 | 
						|
        }
 | 
						|
        #if os(macOS)
 | 
						|
        .buttonStyle(.bordered)
 | 
						|
        #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: {
 | 
						|
            Label("Decrease rate", systemImage: "minus")
 | 
						|
                .foregroundColor(.accentColor)
 | 
						|
                .imageScale(.large)
 | 
						|
                .labelStyle(.iconOnly)
 | 
						|
            #if os(iOS)
 | 
						|
                .padding(12)
 | 
						|
                .frame(width: 40, height: 40)
 | 
						|
 | 
						|
                .background(RoundedRectangle(cornerRadius: 4).strokeBorder(Color.accentColor, lineWidth: 1))
 | 
						|
                .contentShape(Rectangle())
 | 
						|
            #endif
 | 
						|
        }
 | 
						|
        #if os(macOS)
 | 
						|
        .buttonStyle(.bordered)
 | 
						|
        #elseif os(iOS)
 | 
						|
        #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())
 | 
						|
            #if os(macOS)
 | 
						|
                .frame(maxWidth: 150)
 | 
						|
            #endif
 | 
						|
        #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
 | 
						|
                .labelsHidden()
 | 
						|
                .frame(maxWidth: 300)
 | 
						|
        #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()
 | 
						|
                .labelsHidden()
 | 
						|
                .frame(maxWidth: 300)
 | 
						|
        #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(height: 40, alignment: .trailing)
 | 
						|
        #else
 | 
						|
            StreamControl(focusedField: $focusedField)
 | 
						|
        #endif
 | 
						|
    }
 | 
						|
 | 
						|
    @ViewBuilder private var captionsButton: some View {
 | 
						|
        #if os(macOS)
 | 
						|
            captionsPicker
 | 
						|
                .labelsHidden()
 | 
						|
                .frame(maxWidth: 300)
 | 
						|
        #elseif os(iOS)
 | 
						|
            Menu {
 | 
						|
                captionsPicker
 | 
						|
            } label: {
 | 
						|
                HStack(spacing: 4) {
 | 
						|
                    Image(systemName: "text.bubble")
 | 
						|
                    if let captions = player.captions {
 | 
						|
                        Text(captions.code)
 | 
						|
                            .foregroundColor(.accentColor)
 | 
						|
                    }
 | 
						|
                }
 | 
						|
                .frame(alignment: .trailing)
 | 
						|
                .frame(height: 40)
 | 
						|
            }
 | 
						|
            .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")
 | 
						|
            } else {
 | 
						|
                Text("Disabled").tag(Captions?.none)
 | 
						|
            }
 | 
						|
            ForEach(captions) { caption in
 | 
						|
                Text(caption.description).tag(Optional(caption))
 | 
						|
            }
 | 
						|
        }
 | 
						|
        .disabled(captions.isEmpty)
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
struct PlaybackSettings_Previews: PreviewProvider {
 | 
						|
    static var previews: some View {
 | 
						|
        PlaybackSettings()
 | 
						|
    }
 | 
						|
}
 |