Files
yattee/Yattee/Views/Settings/PlaybackSettingsView.swift
Arkadiusz Fal 83895a1fd9 Reflect the video below the hinge in the tabletop layout
The iPhone Duo tabletop layout mirrors every presented frame into the
lower half, blurred and fading out, like a screen standing on a glossy
table. The render view blits a quarter-resolution copy of the display
framebuffer into a ring of IOSurface-backed buffers (no Y flip, so the
surface is already mirrored) and hands it to a CALayer hosted by
MPVReflectionView; the simulator's software renderer feeds the same
layer a flipped CGImage. Off during PiP, for audio-only streams, and
via the new Playback → Foldable → "Reflection Below Video" setting.
2026-10-01 20:59:48 +02:00

445 lines
13 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)
FoldableSection(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: - Foldable Section (iOS)
#if os(iOS)
/// iPhone Duo only: options for the tabletop (laptop-like) player layout.
private struct FoldableSection: View {
@Bindable var settings: SettingsManager
@State private var hasHinge = HingeLayoutObserver.forceTabletop
var body: some View {
Group {
if hasHinge {
SettingsFormSection(
"settings.playback.foldable.header",
footer: "settings.playback.foldable.tabletopReflection.footer"
) {
Toggle(
String(localized: "settings.playback.foldable.tabletopReflection"),
isOn: $settings.tabletopReflection
)
}
}
}
.observeHinge(hasHinge: $hasHinge)
}
}
#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)
}