diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 01a9adb7..3fd47092 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -217,9 +217,22 @@ final class MPVBackend: PlayerBackend { #endif var captions: Captions? - if let captionsLanguageCode = Defaults[.captionsLanguageCode] { - captions = video.captions.first { $0.code == captionsLanguageCode } ?? - video.captions.first { $0.code.contains(captionsLanguageCode) } + + if Defaults[.captionsAutoShow] == true { + let captionsDefaultLanguageCode = Defaults[.captionsDefaultLanguageCode], + captionsFallbackLanguageCode = Defaults[.captionsFallbackLanguageCode] + + // Try to get captions with the default language code first + captions = video.captions.first { $0.code == captionsDefaultLanguageCode } ?? + video.captions.first { $0.code.contains(captionsDefaultLanguageCode) } + + // If there are still no captions, try to get captions with the fallback language code + if captions.isNil && !captionsFallbackLanguageCode.isEmpty { + captions = video.captions.first { $0.code == captionsFallbackLanguageCode } ?? + video.captions.first { $0.code.contains(captionsFallbackLanguageCode) } + } + } else { + captions = nil } let updateCurrentStream = { @@ -254,9 +267,8 @@ final class MPVBackend: PlayerBackend { self.startClientUpdates() - // Captions should only be displayed when selected by the user, - // not when the video starts. So, we remove them. - self.client?.removeSubs() + if Defaults[.captionsAutoShow] { self.client?.setSubToAuto() } else { self.client?.setSubToNo() } + PlayerModel.shared.captions = self.captions if !preservingTime, !upgrading, diff --git a/Model/Player/Backends/MPVClient.swift b/Model/Player/Backends/MPVClient.swift index 75f9128c..dd63590d 100644 --- a/Model/Player/Backends/MPVClient.swift +++ b/Model/Player/Backends/MPVClient.swift @@ -68,6 +68,8 @@ final class MPVClient: ObservableObject { checkError(mpv_set_option_string(mpv, "vo", "libmpv")) checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1")) checkError(mpv_set_option_string(mpv, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no")) + checkError(mpv_set_option_string(mpv, "sub-scale", Defaults[.captionsFontScaleSize])) + checkError(mpv_set_option_string(mpv, "sub-color", Defaults[.captionsFontColor])) checkError(mpv_initialize(mpv)) @@ -405,6 +407,22 @@ final class MPVClient: ObservableObject { setString("video", "no") } + func setSubToAuto() { + setString("sub", "auto") + } + + func setSubToNo() { + setString("sub", "no") + } + + func setSubFontSize(scaleSize: String) { + setString("sub-scale", scaleSize) + } + + func setSubFontColor(color: String) { + setString("sub-color", color) + } + var tracksCount: Int { Int(getString("track-list/count") ?? "-1") ?? -1 } diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index c2cc9d23..971ac876 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -301,7 +301,12 @@ extension Defaults.Keys { static let lastPlayed = Key("lastPlayed") static let activeBackend = Key("activeBackend", default: .mpv) + static let captionsAutoShow = Key("captionsAutoShow", default: false) static let captionsLanguageCode = Key("captionsLanguageCode") + static let captionsDefaultLanguageCode = Key("captionsDefaultLanguageCode", default: LanguageCodes.English.rawValue) + static let captionsFallbackLanguageCode = Key("captionsDefaultFallbackCode", default: LanguageCodes.English.rawValue) + static let captionsFontScaleSize = Key("captionsFontScale", default: "1.0") + static let captionsFontColor = Key("captionsFontColor", default: "#FFFFFF") static let lastUsedPlaylistID = Key("lastPlaylistID") static let lastAccountIsPublic = Key("lastAccountIsPublic", default: false) diff --git a/Shared/LanguageCodes.swift b/Shared/LanguageCodes.swift new file mode 100644 index 00000000..74fd00e5 --- /dev/null +++ b/Shared/LanguageCodes.swift @@ -0,0 +1,109 @@ +enum LanguageCodes: String, CaseIterable { + case Afrikaans = "af" + case Arabic = "ar" + case Azerbaijani = "az" + case Bengali = "bn" + case Catalan = "ca" + case Czech = "cs" + case Welsh = "cy" + case Danish = "da" + case German = "de" + case Greek = "el" + case English = "en" + case English_GB = "en-GB" + case Spanish = "es" + case Persian = "fa" + case Finnish = "fi" + case Filipino = "fil" + case French = "fr" + case Irish = "ga" + case Hebrew = "he" + case Hindi = "hi" + case Hungarian = "hu" + case Indonesian = "id" + case Italian = "it" + case Japanese = "ja" + case Javanese = "jv" + case Korean = "ko" + case Lithuanian = "lt" + case Malay = "ms" + case Maltese = "mt" + case Dutch = "nl" + case Norwegian = "no" + case Polish = "pl" + case Portuguese = "pt" + case Romanian = "ro" + case Russian = "ru" + case Slovak = "sk" + case Slovene = "sl" + case Swedish = "sv" + case Swahili = "sw" + case Thai = "th" + case Tagalog = "tl" + case Turkish = "tr" + case Ukrainian = "uk" + case Urdu = "ur" + case Uzbek = "uz" + case Vietnamese = "vi" + case Xhosa = "xh" + case Chinese = "zh" + case Zulu = "zu" + + var description: String { + switch self { + case .Afrikaans: return "Afrikaans" + case .Arabic: return "Arabic" + case .Azerbaijani: return "Azerbaijani" + case .Bengali: return "Bengali" + case .Catalan: return "Catalan" + case .Czech: return "Czech" + case .Welsh: return "Welsh" + case .Danish: return "Danish" + case .German: return "German" + case .Greek: return "Greek" + case .English: return "English" + case .English_GB: return "English (United Kingdom)" + case .Spanish: return "Spanish" + case .Persian: return "Persian" + case .Finnish: return "Finnish" + case .Filipino: return "Filipino" + case .French: return "French" + case .Irish: return "Irish" + case .Hebrew: return "Hebrew" + case .Hindi: return "Hindi" + case .Hungarian: return "Hungarian" + case .Indonesian: return "Indonesian" + case .Italian: return "Italian" + case .Japanese: return "Japanese" + case .Javanese: return "Javanese" + case .Korean: return "Korean" + case .Lithuanian: return "Lithuanian" + case .Malay: return "Malay" + case .Maltese: return "Maltese" + case .Dutch: return "Dutch" + case .Norwegian: return "Norwegian" + case .Polish: return "Polish" + case .Portuguese: return "Portuguese" + case .Romanian: return "Romanian" + case .Russian: return "Russian" + case .Slovak: return "Slovak" + case .Slovene: return "Slovene" + case .Swedish: return "Swedish" + case .Swahili: return "Swahili" + case .Thai: return "Thai" + case .Tagalog: return "Tagalog" + case .Turkish: return "Turkish" + case .Ukrainian: return "Ukrainian" + case .Urdu: return "Urdu" + case .Uzbek: return "Uzbek" + case .Vietnamese: return "Vietnamese" + case .Xhosa: return "Xhosa" + case .Chinese: return "Chinese" + case .Zulu: return "Zulu" + } + } + + static func languageName(for code: String) -> String { + return LanguageCodes(rawValue: code)?.description ?? "Unknown" + } +} diff --git a/Shared/Player/Controls/ControlsOverlay.swift b/Shared/Player/Controls/ControlsOverlay.swift index 6de44e8a..df0e00f6 100644 --- a/Shared/Player/Controls/ControlsOverlay.swift +++ b/Shared/Player/Controls/ControlsOverlay.swift @@ -11,16 +11,16 @@ struct ControlsOverlay: View { @Default(.qualityProfiles) private var qualityProfiles #if os(tvOS) - enum Field: Hashable { - case qualityProfile - case stream - case increaseRate - case decreaseRate - case captions - } + enum Field: Hashable { + case qualityProfile + case stream + case increaseRate + case decreaseRate + case captions + } - @FocusState private var focusedField: Field? - @State private var presentingButtonHintAlert = false + @FocusState private var focusedField: Field? + @State private var presentingButtonHintAlert = false #endif var body: some View { @@ -94,10 +94,10 @@ struct ControlsOverlay: View { #endif #if os(tvOS) - Text("Press and hold remote button to open captions and quality menus") - .frame(maxWidth: 400) - .font(.caption) - .foregroundColor(.secondary) + Text("Press and hold remote button to open captions and quality menus") + .frame(maxWidth: 400) + .font(.caption) + .foregroundColor(.secondary) #endif } .frame(maxHeight: overlayHeight) @@ -117,9 +117,9 @@ struct ControlsOverlay: View { private var overlayHeight: Double { #if os(tvOS) - contentSize.height + 80.0 + contentSize.height + 80.0 #else - contentSize.height + contentSize.height #endif } @@ -160,26 +160,26 @@ struct ControlsOverlay: View { @ViewBuilder private var rateButton: some View { #if os(macOS) - ratePicker - .labelsHidden() - .frame(maxWidth: 100) + ratePicker + .labelsHidden() + .frame(maxWidth: 100) #elseif os(iOS) - Menu { - ratePicker - } label: { - Text(player.rateLabel(player.currentRate)) - .foregroundColor(.primary) - .frame(width: 123) - } - .transaction { t in t.animation = .none } - .buttonStyle(.plain) - .foregroundColor(.primary) - .frame(width: 123, height: 40) - .modifier(ControlBackgroundModifier()) - .mask(RoundedRectangle(cornerRadius: 3)) - #else + Menu { + ratePicker + } label: { Text(player.rateLabel(player.currentRate)) - .frame(minWidth: 120) + .foregroundColor(.primary) + .frame(width: 123) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(width: 123, height: 40) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) + #else + Text(player.rateLabel(player.currentRate)) + .frame(minWidth: 120) #endif } @@ -241,50 +241,50 @@ struct ControlsOverlay: View { private var rateButtonsSpacing: Double { #if os(tvOS) - 10 + 10 #else - 8 + 8 #endif } @ViewBuilder private var qualityProfileButton: some View { #if os(macOS) - qualityProfilePicker - .labelsHidden() - .frame(maxWidth: 300) + qualityProfilePicker + .labelsHidden() + .frame(maxWidth: 300) #elseif os(iOS) - Menu { - qualityProfilePicker - } label: { - Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) - .frame(maxWidth: 240) - } - .transaction { t in t.animation = .none } - .buttonStyle(.plain) - .foregroundColor(.primary) - .frame(maxWidth: 240) - .frame(height: 40) - .modifier(ControlBackgroundModifier()) - .mask(RoundedRectangle(cornerRadius: 3)) + Menu { + qualityProfilePicker + } label: { + Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) + .frame(maxWidth: 240) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(maxWidth: 240) + .frame(height: 40) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) #else - ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) { - Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) - .lineLimit(1) - .frame(maxWidth: 320) - } - .contextMenu { - Button("Automatic") { player.qualityProfileSelection = nil } + 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) {} + ForEach(qualityProfiles) { qualityProfile in + Button { + player.qualityProfileSelection = qualityProfile + } label: { + Text(qualityProfile.description) } + + Button("Cancel", role: .cancel) {} } + } #endif } @@ -300,71 +300,91 @@ struct ControlsOverlay: View { @ViewBuilder private var qualityButton: some View { #if os(macOS) - StreamControl() - .labelsHidden() - .frame(maxWidth: 300) + StreamControl() + .labelsHidden() + .frame(maxWidth: 300) #elseif os(iOS) - Menu { - StreamControl() - } label: { - Text(player.streamSelection?.resolutionAndFormat ?? "loading") - .frame(width: 140, height: 40) - .foregroundColor(.primary) - } - .transaction { t in t.animation = .none } - .buttonStyle(.plain) - .foregroundColor(.primary) - .frame(width: 240, height: 40) - .modifier(ControlBackgroundModifier()) - .mask(RoundedRectangle(cornerRadius: 3)) + Menu { + StreamControl() + } label: { + Text(player.streamSelection?.resolutionAndFormat ?? "loading") + .frame(width: 140, height: 40) + .foregroundColor(.primary) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(width: 240, height: 40) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) #else - StreamControl(focusedField: $focusedField) + StreamControl(focusedField: $focusedField) #endif } @ViewBuilder private var captionsButton: some View { #if os(macOS) - captionsPicker - .labelsHidden() - .frame(maxWidth: 300) + captionsPicker + .labelsHidden() + .frame(maxWidth: 300) #elseif os(iOS) - Menu { - captionsPicker - } label: { - HStack(spacing: 4) { - Image(systemName: "text.bubble") - if let captions = captionsBinding.wrappedValue { - Text(captions.code) - .foregroundColor(.primary) - } - } - .frame(width: 240) - .frame(height: 40) - } - .transaction { t in t.animation = .none } - .buttonStyle(.plain) - .foregroundColor(.primary) - .frame(width: 240) - .modifier(ControlBackgroundModifier()) - .mask(RoundedRectangle(cornerRadius: 3)) - #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 } + Menu { + captionsPicker + } label: { + HStack(spacing: 4) { + Image(systemName: "text.bubble") + if let captions = captionsBinding.wrappedValue, + let language = LanguageCodes(rawValue: captions.code) - ForEach(player.currentVideo?.captions ?? []) { caption in - Button(caption.description) { captionsBinding.wrappedValue = caption } + { + Text("\(language.description.capitalized) (\(language.rawValue))") + .foregroundColor(.accentColor) + } else { + if captionsBinding.wrappedValue == nil { + Text("Not available") + } else { + Text("Disabled") + .foregroundColor(.accentColor) + } } - Button("Cancel", role: .cancel) {} } + .frame(width: 240) + .frame(height: 40) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(width: 240) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) + #else + ControlsOverlayButton(focusedField: $focusedField, field: .captions) { + HStack(spacing: 8) { + Image(systemName: "text.bubble") + if let captions = captionsBinding.wrappedValue, + let language = LanguageCodes(rawValue: captions.code) + { + Text("\(language.description.capitalized) (\(language.rawValue))") + .foregroundColor(.accentColor) + } else { + if captionsBinding.wrappedValue == nil { + Text("Not available") + } else { + Text("Disabled") + .foregroundColor(.accentColor) + } + } + } + .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 } diff --git a/Shared/Player/PlaybackSettings.swift b/Shared/Player/PlaybackSettings.swift index 66638b11..c9861235 100644 --- a/Shared/Player/PlaybackSettings.swift +++ b/Shared/Player/PlaybackSettings.swift @@ -1,3 +1,4 @@ +import Combine import Defaults import SwiftUI @@ -383,23 +384,35 @@ struct PlaybackSettings: View { } @ViewBuilder private var captionsButton: some View { + let videoCaptions = player.currentVideo?.captions #if os(macOS) captionsPicker .labelsHidden() .frame(maxWidth: 300) #elseif os(iOS) Menu { - captionsPicker + if videoCaptions?.isEmpty == false { + captionsPicker + } } label: { HStack(spacing: 4) { Image(systemName: "text.bubble") - if let captions = player.captions { - Text(captions.code) + if let captions = player.captions, + let language = LanguageCodes(rawValue: captions.code) + { + Text("\(language.description.capitalized) (\(language.rawValue))") .foregroundColor(.accentColor) + } else { + if videoCaptions?.isEmpty == true { + Text("Not available") + } else { + Text("Disabled") + } } } .frame(alignment: .trailing) .frame(height: 40) + .disabled(videoCaptions?.isEmpty == true) } .transaction { t in t.animation = .none } .buttonStyle(.plain) diff --git a/Shared/Settings/PlayerSettings.swift b/Shared/Settings/PlayerSettings.swift index eaa890f9..9c1aefdc 100644 --- a/Shared/Settings/PlayerSettings.swift +++ b/Shared/Settings/PlayerSettings.swift @@ -30,12 +30,19 @@ struct PlayerSettings: View { @Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike + @Default(.showRelated) private var showRelated @Default(.showInspector) private var showInspector + @Default(.showChapters) private var showChapters @Default(.showChapterThumbnails) private var showThumbnails @Default(.showChapterThumbnailsOnlyWhenDifferent) private var showThumbnailsOnlyWhenDifferent @Default(.expandChapters) private var expandChapters - @Default(.showRelated) private var showRelated + + @Default(.captionsAutoShow) private var captionsAutoShow + @Default(.captionsDefaultLanguageCode) private var captionsDefaultLanguageCode + @Default(.captionsFallbackLanguageCode) private var captionsFallbackLanguageCode + @Default(.captionsFontScaleSize) private var captionsFontScaleSize + @Default(.captionsFontColor) private var captionsFontColor @ObservedObject private var accounts = AccountsModel.shared @@ -45,6 +52,11 @@ struct PlayerSettings: View { } #endif + #if os(tvOS) + @State private var isShowingDefaultLanguagePicker = false + @State private var isShowingFallbackLanguagePicker = false + #endif + var body: some View { Group { #if os(macOS) @@ -93,7 +105,54 @@ struct PlayerSettings: View { inspectorVisibilityPicker #endif } + #endif + Section(header: SettingsHeader(text: "Captions".localized())) { + #if os(tvOS) + Text("Size").font(.subheadline) + #endif + captionsFontScaleSizePicker + #if os(tvOS) + Text("Color").font(.subheadline) + #endif + captionsFontColorPicker + showCaptionsAutoShowToggle + + #if !os(tvOS) + captionDefaultLanguagePicker + captionFallbackLanguagePicker + #else + Button(action: { isShowingDefaultLanguagePicker = true }) { + HStack { + Text("Default language") + Spacer() + Text("\(LanguageCodes(rawValue: captionsDefaultLanguageCode)!.description.capitalized) (\(captionsDefaultLanguageCode))").foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity).sheet(isPresented: $isShowingDefaultLanguagePicker) { + defaultLanguagePickerTVOS( + selectedLanguage: $captionsDefaultLanguageCode, + isShowing: $isShowingDefaultLanguagePicker + ) + } + + Button(action: { isShowingFallbackLanguagePicker = true }) { + HStack { + Text("Fallback language") + Spacer() + Text("\(LanguageCodes(rawValue: captionsFallbackLanguageCode)!.description.capitalized) (\(captionsFallbackLanguageCode))").foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity).sheet(isPresented: $isShowingDefaultLanguagePicker) { + fallbackLanguagePickerTVOS( + selectedLanguage: $captionsFallbackLanguageCode, + isShowing: $isShowingFallbackLanguagePicker + ) + } + #endif + } + + #if !os(tvOS) Section(header: SettingsHeader(text: "Chapters".localized())) { showChaptersToggle showThumbnailsToggle @@ -279,6 +338,103 @@ struct PlayerSettings: View { } #endif + private var showCaptionsAutoShowToggle: some View { + Toggle("Always show captions", isOn: $captionsAutoShow) + } + + private var captionsFontScaleSizePicker: some View { + Picker("Size", selection: $captionsFontScaleSize) { + Text("Small").tag(String("0.5")) + Text("Medium").tag(String("1.0")) + Text("Large").tag(String("2.0")) + } + .onChange(of: captionsFontScaleSize) { _ in + PlayerModel.shared.mpvBackend.client.setSubFontSize(scaleSize: captionsFontScaleSize) + } + #if os(macOS) + .labelsHidden() + #endif + } + + private var captionsFontColorPicker: some View { + Picker("Color", selection: $captionsFontColor) { + Text("White").tag(String("#FFFFFF")) + Text("Yellow").tag(String("#FFFF00")) + Text("Red").tag(String("#FF0000")) + Text("Orange").tag(String("#FFA500")) + Text("Green").tag(String("#008000")) + Text("Blue").tag(String("#0000FF")) + } + .onChange(of: captionsFontColor) { _ in + PlayerModel.shared.mpvBackend.client.setSubFontColor(color: captionsFontColor) + } + #if os(macOS) + .labelsHidden() + #endif + } + + #if !os(tvOS) + private var captionDefaultLanguagePicker: some View { + Picker("Default language", selection: $captionsDefaultLanguageCode) { + ForEach(LanguageCodes.allCases, id: \.self) { language in + Text("\(language.description.capitalized) (\(language.rawValue))").tag(language.rawValue) + } + } + #if os(macOS) + .labelsHidden() + #endif + } + + private var captionFallbackLanguagePicker: some View { + Picker("Fallback language", selection: $captionsFallbackLanguageCode) { + ForEach(LanguageCodes.allCases, id: \.self) { language in + Text("\(language.description.capitalized) (\(language.rawValue))").tag(language.rawValue) + } + } + #if os(macOS) + .labelsHidden() + #endif + } + #else + struct defaultLanguagePickerTVOS: View { + @Binding var selectedLanguage: String + @Binding var isShowing: Bool + + var body: some View { + NavigationView { + List(LanguageCodes.allCases, id: \.self) { language in + Button(action: { + selectedLanguage = language.rawValue + isShowing = false + }) { + Text("\(language.description.capitalized) (\(language.rawValue))") + } + } + .navigationTitle("Select Default Language") + } + } + } + + struct fallbackLanguagePickerTVOS: View { + @Binding var selectedLanguage: String + @Binding var isShowing: Bool + + var body: some View { + NavigationView { + List(LanguageCodes.allCases, id: \.self) { language in + Button(action: { + selectedLanguage = language.rawValue + isShowing = false + }) { + Text("\(language.description.capitalized) (\(language.rawValue))") + } + } + .navigationTitle("Select Fallback Language") + } + } + } + #endif + #if !os(tvOS) private var inspectorVisibilityPicker: some View { Picker("Inspector", selection: $showInspector) { diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 5691e4bb..dfcdc1d1 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -1076,6 +1076,9 @@ E258F38A2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; }; E258F38B2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; }; E258F38C2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; }; + E27568B92BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; }; + E27568BA2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; }; + E27568BB2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; }; FA97174C2A494700001FF53D /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = FA97174B2A494700001FF53D /* MPVKit */; }; /* End PBXBuildFile section */ @@ -1547,6 +1550,7 @@ 3DA101AF287C30F50027D920 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStatus.swift; sourceTree = ""; }; E258F3892BF61BD2005B8C28 /* URLTester.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLTester.swift; sourceTree = ""; }; + E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageCodes.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2295,6 +2299,7 @@ 37D2E0D328B67EFC00F64D52 /* Delay.swift */, 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */, E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */, + E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */, 375B537828DF6CBB004C1D19 /* Localizable.strings */, 3729037D2739E47400EA99F6 /* MenuCommands.swift */, 37B7958F2771DAE0001CF27B /* OpenURLHandler.swift */, @@ -3225,6 +3230,7 @@ 37C3A24D272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */, 37484C2D26FC844700287258 /* InstanceSettings.swift in Sources */, 37DD9DA32785BBC900539416 /* NoCommentsView.swift in Sources */, + E27568B92BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */, 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */, 377ABC48286E5887009C986F /* Sequence+Unique.swift in Sources */, @@ -3405,6 +3411,7 @@ 371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */, 3756C2AB2861151C00E4B059 /* NetworkStateModel.swift in Sources */, 375EC95A289EEB8200751258 /* QualityProfileForm.swift in Sources */, + E27568BA2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */, 37001564271B1F250049C794 /* AccountsModel.swift in Sources */, 378FFBC528660172009E3FBE /* URLParser.swift in Sources */, 3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */, @@ -4000,6 +4007,7 @@ 37FB28432721B22200A57617 /* ContentItem.swift in Sources */, 37A7D6EF2B67E3BF009CB1ED /* BrowsingSettingsGroupImporter.swift in Sources */, 37D2E0D228B67DBC00F64D52 /* AnimationCompletionObserverModifier.swift in Sources */, + E27568BB2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */, 37A7D6EB2B67E334009CB1ED /* BrowsingSettingsGroupExporter.swift in Sources */, 37AAF2A226741C97007FC770 /* FeedView.swift in Sources */, 37484C1B26FC837400287258 /* PlayerSettings.swift in Sources */,