From 289868e71a9eb839ad9787f7ac0e3603eef597a1 Mon Sep 17 00:00:00 2001 From: Tony Date: Thu, 12 Oct 2023 18:34:57 -0700 Subject: [PATCH] Add audio, subtitle, playback select for tvOS (#859) --- Shared/ViewModels/VideoPlayerManager.swift | 2 + Swiftfin tvOS/Components/SFSymbolButton.swift | 28 +++-- .../ActionButtons/AutoPlayActionButton.swift | 1 - .../Views/VideoPlayer/Overlays/Overlay.swift | 113 +++++++++--------- .../Overlays/SmallMenuOverlay.swift | 100 +++++++++++----- .../PlaybackSpeedActionButton.swift | 12 +- 6 files changed, 146 insertions(+), 110 deletions(-) diff --git a/Shared/ViewModels/VideoPlayerManager.swift b/Shared/ViewModels/VideoPlayerManager.swift index 71693472..281738aa 100644 --- a/Shared/ViewModels/VideoPlayerManager.swift +++ b/Shared/ViewModels/VideoPlayerManager.swift @@ -40,6 +40,8 @@ class VideoPlayerManager: ViewModel { var state: VLCVideoPlayer.State = .opening @Published var subtitleTrackIndex: Int = -1 + @Published + var playbackSpeed: PlaybackSpeed = PlaybackSpeed.one // MARK: ViewModel diff --git a/Swiftfin tvOS/Components/SFSymbolButton.swift b/Swiftfin tvOS/Components/SFSymbolButton.swift index ffd7ecf6..80b4b27b 100644 --- a/Swiftfin tvOS/Components/SFSymbolButton.swift +++ b/Swiftfin tvOS/Components/SFSymbolButton.swift @@ -16,16 +16,7 @@ struct SFSymbolButton: UIViewRepresentable { private let systemName: String private let systemNameFocused: String? - func makeUIView(context: Context) -> some UIButton { - var configuration = UIButton.Configuration.plain() - configuration.cornerStyle = .capsule - - let buttonAction = UIAction(title: "") { _ in - self.onSelect() - } - - let button = UIButton(configuration: configuration, primaryAction: buttonAction) - + private func makeButtonConfig(_ button: UIButton) { let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize) let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig) @@ -36,11 +27,26 @@ struct SFSymbolButton: UIViewRepresentable { button.setImage(focusedSymbolImage, for: .focused) } + } + + func makeUIView(context: Context) -> some UIButton { + var configuration = UIButton.Configuration.plain() + configuration.cornerStyle = .capsule + + let buttonAction = UIAction(title: "") { _ in + self.onSelect() + } + + let button = UIButton(configuration: configuration, primaryAction: buttonAction) + + makeButtonConfig(button) return button } - func updateUIView(_ uiView: UIViewType, context: Context) {} + func updateUIView(_ uiView: UIViewType, context: Context) { + makeButtonConfig(uiView) + } } extension SFSymbolButton { diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift index 1949aff0..5896cb85 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift @@ -28,7 +28,6 @@ extension VideoPlayer.Overlay.ActionButtons { overlayTimer.start(5) } .frame(maxWidth: 30, maxHeight: 30) - .id(autoPlayEnabled) } } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift index faf945d9..7e8ef900 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift @@ -30,72 +30,73 @@ extension VideoPlayer { @StateObject private var overlayTimer: TimerProxy = .init() - var body: some View { - ZStack { - - MainOverlay() - .visible(currentOverlayType == .main) - - ConfirmCloseOverlay() - .visible(currentOverlayType == .confirmClose) - - SmallMenuOverlay() - .visible(currentOverlayType == .smallMenu) - + @ViewBuilder + private var currentOverlay: some View { + switch currentOverlayType { + case .chapters: ChapterOverlay() - .visible(currentOverlayType == .chapters) + case .confirmClose: + ConfirmCloseOverlay() + case .main: + MainOverlay() + case .smallMenu: + SmallMenuOverlay() } - .visible(isPresentingOverlay) - .animation(.linear(duration: 0.1), value: currentOverlayType) - .environment(\.currentOverlayType, $currentOverlayType) - .environmentObject(overlayTimer) - .onChange(of: currentOverlayType) { newValue in - if [.smallMenu, .chapters].contains(newValue) { - overlayTimer.pause() - } else if isPresentingOverlay { + } + + var body: some View { + currentOverlay + .visible(isPresentingOverlay) + .animation(.linear(duration: 0.1), value: currentOverlayType) + .environment(\.currentOverlayType, $currentOverlayType) + .environmentObject(overlayTimer) + .onChange(of: currentOverlayType) { newValue in + if [.smallMenu, .chapters].contains(newValue) { + overlayTimer.pause() + } else if isPresentingOverlay { + overlayTimer.start(5) + } + } + .onChange(of: overlayTimer.isActive) { isActive in + guard !isActive else { return } + + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = false + } + } + .onSelectPressed { + currentOverlayType = .main + isPresentingOverlay = true overlayTimer.start(5) } - } - .onChange(of: overlayTimer.isActive) { isActive in - guard !isActive else { return } + .onMenuPressed { - withAnimation(.linear(duration: 0.3)) { - isPresentingOverlay = false - } - } - .onSelectPressed { - currentOverlayType = .main - isPresentingOverlay = true - overlayTimer.start(5) - } - .onMenuPressed { + overlayTimer.start(5) + confirmCloseWorkItem?.cancel() - overlayTimer.start(5) - confirmCloseWorkItem?.cancel() - - if isPresentingOverlay && currentOverlayType == .confirmClose { - proxy.stop() - router.dismissCoordinator() - } else if isPresentingOverlay && currentOverlayType == .smallMenu { - currentOverlayType = .main - } else { - withAnimation { - currentOverlayType = .confirmClose - isPresentingOverlay = true - } - - let task = DispatchWorkItem { + if isPresentingOverlay && currentOverlayType == .confirmClose { + proxy.stop() + router.dismissCoordinator() + } else if isPresentingOverlay && currentOverlayType == .smallMenu { + currentOverlayType = .main + } else { withAnimation { - isPresentingOverlay = false - overlayTimer.stop() + currentOverlayType = .confirmClose + isPresentingOverlay = true } + + let task = DispatchWorkItem { + withAnimation { + isPresentingOverlay = false + overlayTimer.stop() + } + } + + confirmCloseWorkItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) } - - confirmCloseWorkItem = task - - DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) } - } } } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift index 42cc789e..35c7bd3b 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift @@ -14,7 +14,6 @@ extension VideoPlayer { enum MenuSection: String, Displayable { case audio - case chapters case playbackSpeed case subtitles @@ -22,8 +21,6 @@ extension VideoPlayer { switch self { case .audio: return "Audio" - case .chapters: - return "Chapters" case .playbackSpeed: return "Playback Speed" case .subtitles: @@ -41,7 +38,7 @@ extension VideoPlayer { private var focusedSection: MenuSection? @State - private var lastFocusedSection: MenuSection? + private var lastFocusedSection: MenuSection = .subtitles @StateObject private var focusGuide: FocusGuide = .init() @@ -50,24 +47,54 @@ extension VideoPlayer { private var subtitleMenu: some View { HStack { ForEach(viewModel.subtitleStreams, id: \.self) { mediaStream in - Button {} label: { - if videoPlayerManager.subtitleTrackIndex == mediaStream.index { - Label(mediaStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(mediaStream.displayTitle ?? L10n.noTitle) - } + Button { + videoPlayerManager.subtitleTrackIndex = mediaStream.index ?? -1 + videoPlayerManager.proxy.setSubtitleTrack(.absolute(mediaStream.index ?? -1)) + } label: { + Label( + mediaStream.displayTitle ?? L10n.noTitle, + systemImage: videoPlayerManager.subtitleTrackIndex == mediaStream.index ? "checkmark.circle.fill" : "circle" + ) } } } - .frame(height: 80) - .padding(.horizontal, 50) - .padding(.top) - .padding(.bottom, 45) - .focusGuide( - focusGuide, - tag: "contents", - top: "sections" - ) + .modifier(MenuStyle(focusGuide: focusGuide)) + } + + @ViewBuilder + private var audioMenu: some View { + HStack { + ForEach(viewModel.audioStreams, id: \.self) { mediaStream in + Button { + videoPlayerManager.audioTrackIndex = mediaStream.index ?? -1 + videoPlayerManager.proxy.setAudioTrack(.absolute(mediaStream.index ?? -1)) + } label: { + Label( + mediaStream.displayTitle ?? L10n.noTitle, + systemImage: videoPlayerManager.audioTrackIndex == mediaStream.index ? "checkmark.circle.fill" : "circle" + ) + } + } + } + .modifier(MenuStyle(focusGuide: focusGuide)) + } + + @ViewBuilder + private var playbackSpeedMenu: some View { + HStack { + ForEach(PlaybackSpeed.allCases, id: \.self) { speed in + Button { + videoPlayerManager.playbackSpeed = speed + videoPlayerManager.proxy.setRate(.absolute(Float(speed.rawValue))) + } label: { + Label( + speed.displayTitle, + systemImage: speed == videoPlayerManager.playbackSpeed ? "checkmark.circle.fill" : "circle" + ) + } + } + } + .modifier(MenuStyle(focusGuide: focusGuide)) } var body: some View { @@ -98,14 +125,6 @@ extension VideoPlayer { focused: $focusedSection, lastFocused: $lastFocusedSection ) - - if !viewModel.chapters.isEmpty { - SectionButton( - section: .chapters, - focused: $focusedSection, - lastFocused: $lastFocusedSection - ) - } } .focusGuide( focusGuide, @@ -123,10 +142,10 @@ extension VideoPlayer { switch lastFocusedSection { case .subtitles: subtitleMenu - default: - Button { - Text("None") - } + case .audio: + audioMenu + case .playbackSpeed: + playbackSpeedMenu } } } @@ -151,7 +170,7 @@ extension VideoPlayer { let section: MenuSection let focused: FocusState.Binding - let lastFocused: Binding + let lastFocused: Binding var body: some View { Button { @@ -170,5 +189,22 @@ extension VideoPlayer { .focused(focused, equals: section) } } + + struct MenuStyle: ViewModifier { + var focusGuide: FocusGuide + + func body(content: Content) -> some View { + content + .focusGuide( + focusGuide, + tag: "contents", + top: "sections" + ) + .frame(height: 80) + .padding(.horizontal, 50) + .padding(.top) + .padding(.bottom, 45) + } + } } } diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlaybackSpeedActionButton.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlaybackSpeedActionButton.swift index 5bade58f..071524ee 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlaybackSpeedActionButton.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/ActionButtons/PlaybackSpeedActionButton.swift @@ -13,10 +13,6 @@ extension VideoPlayer.Overlay.ActionButtons { struct PlaybackSpeedMenu: View { - @Environment(\.playbackSpeed) - @Binding - private var playbackSpeed - @EnvironmentObject private var overlayTimer: TimerProxy @EnvironmentObject @@ -30,20 +26,16 @@ extension VideoPlayer.Overlay.ActionButtons { Menu { ForEach(PlaybackSpeed.allCases, id: \.self) { speed in Button { - playbackSpeed = Float(speed.rawValue) + videoPlayerManager.playbackSpeed = speed videoPlayerProxy.setRate(.absolute(Float(speed.rawValue))) } label: { - if Float(speed.rawValue) == playbackSpeed { + if speed == videoPlayerManager.playbackSpeed { Label(speed.displayTitle, systemImage: "checkmark") } else { Text(speed.displayTitle) } } } - - if !PlaybackSpeed.allCases.map(\.rawValue).contains(where: { $0 == Double(playbackSpeed) }) { - Label(String(format: "%.2f", playbackSpeed).appending("x"), systemImage: "checkmark") - } } label: { content().eraseToAnyView() }