mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
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.
417 lines
12 KiB
Swift
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)
|
|
}
|