improvements to captions on tvOS

This commit is contained in:
Toni Förster 2024-05-20 14:20:08 +02:00
parent 4db02b2638
commit c9125644ed
No known key found for this signature in database
GPG Key ID: 292F3E5086C83FC7
3 changed files with 207 additions and 136 deletions

View File

@ -11,16 +11,16 @@ struct ControlsOverlay: View {
@Default(.qualityProfiles) private var qualityProfiles @Default(.qualityProfiles) private var qualityProfiles
#if os(tvOS) #if os(tvOS)
enum Field: Hashable { enum Field: Hashable {
case qualityProfile case qualityProfile
case stream case stream
case increaseRate case increaseRate
case decreaseRate case decreaseRate
case captions case captions
} }
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@State private var presentingButtonHintAlert = false @State private var presentingButtonHintAlert = false
#endif #endif
var body: some View { var body: some View {
@ -94,10 +94,10 @@ struct ControlsOverlay: View {
#endif #endif
#if os(tvOS) #if os(tvOS)
Text("Press and hold remote button to open captions and quality menus") Text("Press and hold remote button to open captions and quality menus")
.frame(maxWidth: 400) .frame(maxWidth: 400)
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
#endif #endif
} }
.frame(maxHeight: overlayHeight) .frame(maxHeight: overlayHeight)
@ -117,9 +117,9 @@ struct ControlsOverlay: View {
private var overlayHeight: Double { private var overlayHeight: Double {
#if os(tvOS) #if os(tvOS)
contentSize.height + 80.0 contentSize.height + 80.0
#else #else
contentSize.height contentSize.height
#endif #endif
} }
@ -160,26 +160,26 @@ struct ControlsOverlay: View {
@ViewBuilder private var rateButton: some View { @ViewBuilder private var rateButton: some View {
#if os(macOS) #if os(macOS)
ratePicker ratePicker
.labelsHidden() .labelsHidden()
.frame(maxWidth: 100) .frame(maxWidth: 100)
#elseif os(iOS) #elseif os(iOS)
Menu { Menu {
ratePicker ratePicker
} label: { } 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
Text(player.rateLabel(player.currentRate)) 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 #endif
} }
@ -241,50 +241,50 @@ struct ControlsOverlay: View {
private var rateButtonsSpacing: Double { private var rateButtonsSpacing: Double {
#if os(tvOS) #if os(tvOS)
10 10
#else #else
8 8
#endif #endif
} }
@ViewBuilder private var qualityProfileButton: some View { @ViewBuilder private var qualityProfileButton: some View {
#if os(macOS) #if os(macOS)
qualityProfilePicker qualityProfilePicker
.labelsHidden() .labelsHidden()
.frame(maxWidth: 300) .frame(maxWidth: 300)
#elseif os(iOS) #elseif os(iOS)
Menu { Menu {
qualityProfilePicker qualityProfilePicker
} label: { } label: {
Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
.frame(maxWidth: 240) .frame(maxWidth: 240)
} }
.transaction { t in t.animation = .none } .transaction { t in t.animation = .none }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundColor(.primary) .foregroundColor(.primary)
.frame(maxWidth: 240) .frame(maxWidth: 240)
.frame(height: 40) .frame(height: 40)
.modifier(ControlBackgroundModifier()) .modifier(ControlBackgroundModifier())
.mask(RoundedRectangle(cornerRadius: 3)) .mask(RoundedRectangle(cornerRadius: 3))
#else #else
ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) { ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) {
Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) Text(player.qualityProfileSelection?.description ?? "Automatic".localized())
.lineLimit(1) .lineLimit(1)
.frame(maxWidth: 320) .frame(maxWidth: 320)
} }
.contextMenu { .contextMenu {
Button("Automatic") { player.qualityProfileSelection = nil } Button("Automatic") { player.qualityProfileSelection = nil }
ForEach(qualityProfiles) { qualityProfile in ForEach(qualityProfiles) { qualityProfile in
Button { Button {
player.qualityProfileSelection = qualityProfile player.qualityProfileSelection = qualityProfile
} label: { } label: {
Text(qualityProfile.description) Text(qualityProfile.description)
}
Button("Cancel", role: .cancel) {}
} }
Button("Cancel", role: .cancel) {}
} }
}
#endif #endif
} }
@ -300,71 +300,91 @@ struct ControlsOverlay: View {
@ViewBuilder private var qualityButton: some View { @ViewBuilder private var qualityButton: some View {
#if os(macOS) #if os(macOS)
StreamControl() StreamControl()
.labelsHidden() .labelsHidden()
.frame(maxWidth: 300) .frame(maxWidth: 300)
#elseif os(iOS) #elseif os(iOS)
Menu { Menu {
StreamControl() StreamControl()
} label: { } label: {
Text(player.streamSelection?.resolutionAndFormat ?? "loading") Text(player.streamSelection?.resolutionAndFormat ?? "loading")
.frame(width: 140, height: 40) .frame(width: 140, height: 40)
.foregroundColor(.primary) .foregroundColor(.primary)
} }
.transaction { t in t.animation = .none } .transaction { t in t.animation = .none }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundColor(.primary) .foregroundColor(.primary)
.frame(width: 240, height: 40) .frame(width: 240, height: 40)
.modifier(ControlBackgroundModifier()) .modifier(ControlBackgroundModifier())
.mask(RoundedRectangle(cornerRadius: 3)) .mask(RoundedRectangle(cornerRadius: 3))
#else #else
StreamControl(focusedField: $focusedField) StreamControl(focusedField: $focusedField)
#endif #endif
} }
@ViewBuilder private var captionsButton: some View { @ViewBuilder private var captionsButton: some View {
#if os(macOS) #if os(macOS)
captionsPicker captionsPicker
.labelsHidden() .labelsHidden()
.frame(maxWidth: 300) .frame(maxWidth: 300)
#elseif os(iOS) #elseif os(iOS)
Menu { Menu {
captionsPicker captionsPicker
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "text.bubble") Image(systemName: "text.bubble")
if let captions = captionsBinding.wrappedValue { if let captions = captionsBinding.wrappedValue,
Text(captions.code) let language = LanguageCodes(rawValue: 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 }
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 #endif
} }

View File

@ -384,13 +384,16 @@ struct PlaybackSettings: View {
} }
@ViewBuilder private var captionsButton: some View { @ViewBuilder private var captionsButton: some View {
let videoCaptions = player.currentVideo?.captions
#if os(macOS) #if os(macOS)
captionsPicker captionsPicker
.labelsHidden() .labelsHidden()
.frame(maxWidth: 300) .frame(maxWidth: 300)
#elseif os(iOS) #elseif os(iOS)
Menu { Menu {
captionsPicker if videoCaptions?.isEmpty == false {
captionsPicker
}
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "text.bubble") Image(systemName: "text.bubble")
@ -399,10 +402,17 @@ struct PlaybackSettings: View {
{ {
Text("\(language.description.capitalized) (\(language.rawValue))") Text("\(language.description.capitalized) (\(language.rawValue))")
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
} else {
if videoCaptions?.isEmpty == true {
Text("Not available")
} else {
Text("Disabled")
}
} }
} }
.frame(alignment: .trailing) .frame(alignment: .trailing)
.frame(height: 40) .frame(height: 40)
.disabled(videoCaptions?.isEmpty == true)
} }
.transaction { t in t.animation = .none } .transaction { t in t.animation = .none }
.buttonStyle(.plain) .buttonStyle(.plain)

View File

@ -49,6 +49,10 @@ struct PlayerSettings: View {
} }
#endif #endif
#if os(tvOS)
@State private var isShowingLanguagePicker = false
#endif
var body: some View { var body: some View {
Group { Group {
#if os(macOS) #if os(macOS)
@ -101,7 +105,23 @@ struct PlayerSettings: View {
Section(header: SettingsHeader(text: "Captions".localized())) { Section(header: SettingsHeader(text: "Captions".localized())) {
showCaptionsAutoShowToggle 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) #if !os(tvOS)
@ -290,21 +310,11 @@ struct PlayerSettings: View {
} }
#endif #endif
private var showCaptionsAutoShowToggle: some View {
Toggle("Always show captions", isOn: $captionsAutoShow)
}
#if !os(tvOS) #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 { private var captionDefaultLanguagePicker: some View {
Picker("Default language", selection: $captionsDefaultLanguageCode) { Picker("Default language", selection: $captionsDefaultLanguageCode) {
ForEach(LanguageCodes.allCases, id: \.self) { language in ForEach(LanguageCodes.allCases, id: \.self) { language in
@ -315,6 +325,37 @@ struct PlayerSettings: View {
.labelsHidden() .labelsHidden()
#endif #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 { private var showChaptersToggle: some View {
Toggle("Show chapters", isOn: $showChapters) Toggle("Show chapters", isOn: $showChapters)