From c9125644ed166e5fa9b51b75020001b77b92b8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20F=C3=B6rster?= Date: Mon, 20 May 2024 14:20:08 +0200 Subject: [PATCH] improvements to captions on tvOS --- Shared/Player/Controls/ControlsOverlay.swift | 260 ++++++++++--------- Shared/Player/PlaybackSettings.swift | 12 +- Shared/Settings/PlayerSettings.swift | 71 +++-- 3 files changed, 207 insertions(+), 136 deletions(-) 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 765314f8..c9861235 100644 --- a/Shared/Player/PlaybackSettings.swift +++ b/Shared/Player/PlaybackSettings.swift @@ -384,13 +384,16 @@ 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") @@ -399,10 +402,17 @@ struct PlaybackSettings: View { { 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 9c5e6c9f..4329a862 100644 --- a/Shared/Settings/PlayerSettings.swift +++ b/Shared/Settings/PlayerSettings.swift @@ -49,6 +49,10 @@ struct PlayerSettings: View { } #endif + #if os(tvOS) + @State private var isShowingLanguagePicker = false + #endif + var body: some View { Group { #if os(macOS) @@ -101,7 +105,23 @@ struct PlayerSettings: View { Section(header: SettingsHeader(text: "Captions".localized())) { showCaptionsAutoShowToggle - captionDefaultLanguagePicker + #if !os(tvOS) + captionDefaultLanguagePicker + #else + Button(action: { isShowingLanguagePicker = true }) { + HStack { + Text("Default language") + Spacer() + Text("\(LanguageCodes(rawValue: captionsDefaultLanguageCode)!.description.capitalized) (\(captionsDefaultLanguageCode))").foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity).sheet(isPresented: $isShowingLanguagePicker) { + LanguagePickerTVOS( + selectedLanguage: $captionsDefaultLanguageCode, + isShowing: $isShowingLanguagePicker + ) + } + #endif } #if !os(tvOS) @@ -290,21 +310,11 @@ struct PlayerSettings: View { } #endif + private var showCaptionsAutoShowToggle: some View { + Toggle("Always show captions", isOn: $captionsAutoShow) + } + #if !os(tvOS) - private var inspectorVisibilityPicker: some View { - Picker("Inspector", selection: $showInspector) { - Text("Always").tag(ShowInspectorSetting.always) - Text("Only for local files and URLs").tag(ShowInspectorSetting.onlyLocal) - } - #if os(macOS) - .labelsHidden() - #endif - } - - private var showCaptionsAutoShowToggle: some View { - Toggle("Always show captions", isOn: $captionsAutoShow) - } - private var captionDefaultLanguagePicker: some View { Picker("Default language", selection: $captionsDefaultLanguageCode) { ForEach(LanguageCodes.allCases, id: \.self) { language in @@ -315,6 +325,37 @@ struct PlayerSettings: View { .labelsHidden() #endif } + #else + struct LanguagePickerTVOS: 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") + } + } + } + #endif + + #if !os(tvOS) + private var inspectorVisibilityPicker: some View { + Picker("Inspector", selection: $showInspector) { + Text("Always").tag(ShowInspectorSetting.always) + Text("Only for local files and URLs").tag(ShowInspectorSetting.onlyLocal) + } + #if os(macOS) + .labelsHidden() + #endif + } private var showChaptersToggle: some View { Toggle("Show chapters", isOn: $showChapters)