Files
yattee/Yattee/Views/Settings/PlaybackSettingsView.swift
Arkadiusz Fal 6a343311ea Add tvOS display frame rate and dynamic range matching
Lets the Apple TV switch its HDMI output to match the playing video's
frame rate and dynamic range via AVDisplayManager.preferredDisplayCriteria,
driven from MPV's container-fps and video-params/gamma. Two opt-in toggles
(default off) live under Playback → Display on tvOS; both are no-ops on
other platforms. Anchor an AVKit class symbol so the linker keeps AVKit
linked — Swift only autolinks AVFoundation here, and without AVKit the
UIWindow.avDisplayManager category isn't loaded at runtime.
2026-05-10 15:28:11 +02:00

417 lines
12 KiB
Swift

//
// PlaybackSettingsView.swift
// Yattee
//
// Playback settings view with quality and behavior preferences.
//
import SwiftUI
struct PlaybackSettingsView: View {
@Environment(\.appEnvironment) private var appEnvironment
var body: some View {
SettingsFormContainer {
if let settings = appEnvironment?.settingsManager {
QualitySection(settings: settings)
AudioSection(settings: settings)
SubtitlesSection(settings: settings)
BehaviorSection(settings: settings)
#if os(tvOS)
TVDisplayMatchingSection(settings: settings)
#endif
QueueSection(settings: settings)
#if os(iOS)
OrientationSection(settings: settings)
#endif
}
}
#if !os(tvOS)
.navigationTitle(String(localized: "settings.playback.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
// MARK: - Quality Section
private struct QualitySection: View {
@Bindable var settings: SettingsManager
var body: some View {
SettingsFormSection(
"settings.playback.video.header",
footer: "settings.playback.quality.allowSoftwareDecoded.footer"
) {
PlatformMenuPicker(
String(localized: "settings.playback.quality.preferred"),
selection: $settings.preferredQuality
) {
ForEach(VideoQuality.allCases, id: \.self) { quality in
Text(quality.displayName).tag(quality)
}
}
#if os(iOS)
Picker(
String(localized: "settings.playback.quality.cellular"),
selection: $settings.cellularQuality
) {
ForEach(VideoQuality.allCases, id: \.self) { quality in
Text(quality.displayName).tag(quality)
}
}
#endif
Toggle(
String(localized: "settings.playback.quality.allowSoftwareDecoded"),
isOn: $settings.allowSoftwareDecodedFormats
)
}
}
}
// MARK: - Audio Section
private struct AudioSection: View {
@Bindable var settings: SettingsManager
// All YouTube-supported language codes, sorted alphabetically by localized name
static let languageCodes: [String] = [
"af", // Afrikaans
"am", // Amharic
"ar", // Arabic
"az", // Azerbaijani
"be", // Belarusian
"bg", // Bulgarian
"bn", // Bengali
"bs", // Bosnian
"ca", // Catalan
"cs", // Czech
"cy", // Welsh
"da", // Danish
"de", // German
"el", // Greek
"en", // English
"es", // Spanish
"et", // Estonian
"eu", // Basque
"fa", // Persian
"fi", // Finnish
"fil", // Filipino
"fr", // French
"gl", // Galician
"gu", // Gujarati
"he", // Hebrew
"hi", // Hindi
"hr", // Croatian
"hu", // Hungarian
"hy", // Armenian
"id", // Indonesian
"is", // Icelandic
"it", // Italian
"ja", // Japanese
"ka", // Georgian
"kk", // Kazakh
"km", // Khmer
"kn", // Kannada
"ko", // Korean
"ky", // Kyrgyz
"lo", // Lao
"lt", // Lithuanian
"lv", // Latvian
"mk", // Macedonian
"ml", // Malayalam
"mn", // Mongolian
"mr", // Marathi
"ms", // Malay
"my", // Burmese
"ne", // Nepali
"nl", // Dutch
"no", // Norwegian
"or", // Odia
"pa", // Punjabi
"pl", // Polish
"pt", // Portuguese
"ro", // Romanian
"ru", // Russian
"si", // Sinhala
"sk", // Slovak
"sl", // Slovenian
"sq", // Albanian
"sr", // Serbian
"sv", // Swedish
"sw", // Swahili
"ta", // Tamil
"te", // Telugu
"th", // Thai
"tr", // Turkish
"uk", // Ukrainian
"ur", // Urdu
"uz", // Uzbek
"vi", // Vietnamese
"zh", // Chinese
"zu", // Zulu
]
// Generate sorted list of (code, localizedName) tuples
private var sortedLanguages: [(code: String, name: String)] {
Self.languageCodes
.map { code in
let name = Locale.current.localizedString(forLanguageCode: code) ?? code.uppercased()
return (code: code, name: name)
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
var body: some View {
SettingsFormSection("settings.playback.audio.header") {
Picker(
String(localized: "settings.playback.audio.preferredLanguage"),
selection: Binding(
get: { settings.preferredAudioLanguage ?? "" },
set: { settings.preferredAudioLanguage = $0.isEmpty ? nil : $0 }
)
) {
Text(String(localized: "settings.playback.audio.original"))
.tag("")
Divider()
ForEach(sortedLanguages, id: \.code) { language in
Text(language.name).tag(language.code)
}
}
}
}
}
// Volume and System Controls settings are in Player Controls settings
// MARK: - Subtitles Section
private struct SubtitlesSection: View {
@Bindable var settings: SettingsManager
// Same language codes as audio
private static let languageCodes = AudioSection.languageCodes
// Generate sorted list of (code, localizedName) tuples
private var sortedLanguages: [(code: String, name: String)] {
Self.languageCodes
.map { code in
let name = Locale.current.localizedString(forLanguageCode: code) ?? code.uppercased()
return (code: code, name: name)
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
var body: some View {
SettingsFormSection("settings.playback.subtitles.header") {
Picker(
String(localized: "settings.playback.subtitles.preferredLanguage"),
selection: Binding(
get: { settings.preferredSubtitlesLanguage ?? "" },
set: { settings.preferredSubtitlesLanguage = $0.isEmpty ? nil : $0 }
)
) {
Text(String(localized: "settings.playback.subtitles.off"))
.tag("")
Divider()
ForEach(sortedLanguages, id: \.code) { language in
Text(language.name).tag(language.code)
}
}
NavigationLink {
SubtitlesSettingsView()
} label: {
Label(String(localized: "settings.playback.subtitles.appearance"), systemImage: "textformat.size")
}
}
}
}
// MARK: - Behavior Section
private struct BehaviorSection: View {
@Bindable var settings: SettingsManager
var body: some View {
#if os(tvOS)
let footer: LocalizedStringKey? = "settings.playback.tvOSMenuButtonClosesVideo.footer"
#else
let footer: LocalizedStringKey? = nil
#endif
SettingsFormSection("settings.playback.behavior.header", footer: footer) {
#if os(macOS)
PlatformMenuPicker(
String(localized: "settings.playback.macOS.playerMode"),
selection: $settings.macPlayerMode
) {
ForEach(MacPlayerMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
Toggle(
String(localized: "settings.playback.macOS.autoResizePlayer"),
isOn: $settings.playerSheetAutoResize
)
#endif
PlatformMenuPicker(
String(localized: "settings.playback.resumeAction"),
selection: $settings.resumeAction
) {
ForEach(ResumeAction.allCases, id: \.self) { action in
Text(action.displayName).tag(action)
}
}
Toggle(
String(localized: "settings.playback.backgroundPlayback"),
isOn: $settings.backgroundPlaybackEnabled
)
#if os(tvOS)
Toggle(
String(localized: "settings.playback.tvOSMenuButtonClosesVideo"),
isOn: $settings.tvOSMenuButtonClosesVideo
)
#endif
}
}
}
// MARK: - TV Display Matching Section
#if os(tvOS)
private struct TVDisplayMatchingSection: View {
@Bindable var settings: SettingsManager
var body: some View {
SettingsFormSection(
"settings.playback.tvDisplayMatching.header",
footer: "settings.playback.tvDisplayMatching.footer"
) {
Toggle(
String(localized: "settings.playback.tvDisplayMatching.frameRate"),
isOn: $settings.tvMatchDisplayFrameRate
)
Toggle(
String(localized: "settings.playback.tvDisplayMatching.dynamicRange"),
isOn: $settings.tvMatchDisplayDynamicRange
)
}
}
}
#endif
// MARK: - Queue Section
private struct QueueSection: View {
@Bindable var settings: SettingsManager
var body: some View {
SettingsFormSection("settings.playback.queue.header") {
Toggle(
String(localized: "settings.playback.queue.enabled"),
isOn: $settings.queueEnabled
)
#if os(tvOS)
PlatformMenuPicker(
String(localized: "settings.playback.queue.autoPlayCountdown"),
selection: $settings.queueAutoPlayCountdown
) {
ForEach(1...15, id: \.self) { value in
Text("\(value)s").tag(value)
}
}
.disabled(!settings.queueEnabled)
#elseif os(macOS)
// macOS: Use simple string label (custom HStack label breaks Form rendering)
Stepper(
"\(String(localized: "settings.playback.queue.autoPlayCountdown")): \(settings.queueAutoPlayCountdown)s",
value: $settings.queueAutoPlayCountdown,
in: 1...15
)
.disabled(!settings.queueEnabled)
#else
Stepper(
value: $settings.queueAutoPlayCountdown,
in: 1...15
) {
HStack {
Text(String(localized: "settings.playback.queue.autoPlayCountdown"))
Spacer()
Text("\(settings.queueAutoPlayCountdown)s")
.foregroundStyle(.secondary)
}
}
.disabled(!settings.queueEnabled)
#endif
}
}
}
// MARK: - Orientation Section (iOS)
#if os(iOS)
private struct OrientationSection: View {
@Bindable var settings: SettingsManager
private var isPhone: Bool {
UIDevice.current.userInterfaceIdiom == .phone
}
var body: some View {
SettingsFormSection("settings.playback.orientation.header") {
Toggle(
String(localized: "settings.playback.orientation.rotateToMatchAspectRatio"),
isOn: $settings.rotateToMatchAspectRatio
)
if isPhone {
Toggle(
String(localized: "settings.playback.orientation.preferPortraitBrowsing"),
isOn: $settings.preferPortraitBrowsing
)
}
}
}
}
#endif
// MARK: - VideoQuality Extension
extension VideoQuality {
var displayName: String {
switch self {
case .auto: return String(localized: "settings.playback.quality.best")
case .hd4k: return "4K"
case .hd1440p: return "1440p"
case .hd1080p: return "1080p"
case .hd720p: return "720p"
case .sd480p: return "480p"
case .sd360p: return "360p"
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
PlaybackSettingsView()
}
.appEnvironment(.preview)
}