Files
yattee/Yattee/Views/Settings/PlaybackSettingsView.swift
Arkadiusz Fal 4ec9b936a6 Fix background playback setting behavior across platforms
Hide the toggle on macOS, where apps are never suspended and playback
always continues regardless of the setting. Pause playback on iOS when
backgrounding with the setting disabled — the audio background mode
previously kept mpv playing, making the off state a no-op.

Move the continue-playing policy into PlayerService and make the
backend's background render pause unconditional (PiP-gated only):
it is a GPU optimization that should not depend on the setting.
Also ungate the player sheet visibility handlers, which manage
foreground mini player rendering unrelated to background playback.
2026-07-18 16:28:19 +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)
Toggle(
String(localized: "settings.playback.macOS.separateWindow"),
isOn: $settings.macPlayerSeparateWindow
)
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)
}
}
#if !os(macOS)
// macOS never suspends apps, so playback always continues in the
// background the toggle would have no effect there
Toggle(
String(localized: "settings.playback.backgroundPlayback"),
isOn: $settings.backgroundPlaybackEnabled
)
#endif
#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)
}