diff --git a/PreferencesView/Sources/PreferencesView/PreferenceKeys.swift b/PreferencesView/Sources/PreferencesView/PreferenceKeys.swift index d1819d8b..a19e4479 100644 --- a/PreferencesView/Sources/PreferencesView/PreferenceKeys.swift +++ b/PreferencesView/Sources/PreferencesView/PreferenceKeys.swift @@ -43,3 +43,14 @@ struct SupportedOrientationsPreferenceKey: PreferenceKey { } } #endif + +#if os(tvOS) +struct PressCommandsPreferenceKey: PreferenceKey { + + static var defaultValue: [PressCommandAction] = [] + + static func reduce(value: inout [PressCommandAction], nextValue: () -> [PressCommandAction]) { + value.append(contentsOf: nextValue()) + } +} +#endif diff --git a/PreferencesView/Sources/PreferencesView/PressCommandAction.swift b/PreferencesView/Sources/PreferencesView/PressCommandAction.swift new file mode 100644 index 00000000..f9e5af83 --- /dev/null +++ b/PreferencesView/Sources/PreferencesView/PressCommandAction.swift @@ -0,0 +1,34 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import SwiftUI + +public struct PressCommandAction { + + let title: String + let press: UIPress.PressType + let action: () -> Void + + public init( + title: String, + press: UIPress.PressType, + action: @escaping () -> Void + ) { + self.title = title + self.press = press + self.action = action + } +} + +extension PressCommandAction: Equatable { + + public static func == (lhs: PressCommandAction, rhs: PressCommandAction) -> Bool { + lhs.press == rhs.press + } +} diff --git a/PreferencesView/Sources/PreferencesView/PressCommandBuilder.swift b/PreferencesView/Sources/PreferencesView/PressCommandBuilder.swift new file mode 100644 index 00000000..5d67c31c --- /dev/null +++ b/PreferencesView/Sources/PreferencesView/PressCommandBuilder.swift @@ -0,0 +1,37 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +@resultBuilder +public enum PressCommandsBuilder { + + public static func buildBlock(_ components: [PressCommandAction]...) -> [PressCommandAction] { + components.flatMap { $0 } + } + + public static func buildExpression(_ expression: PressCommandAction) -> [PressCommandAction] { + [expression] + } + + public static func buildOptional(_ component: [PressCommandAction]?) -> [PressCommandAction] { + component ?? [] + } + + public static func buildEither(first component: [PressCommandAction]) -> [PressCommandAction] { + component + } + + public static func buildEither(second component: [PressCommandAction]) -> [PressCommandAction] { + component + } + + public static func buildArray(_ components: [[PressCommandAction]]) -> [PressCommandAction] { + components.flatMap { $0 } + } +} diff --git a/PreferencesView/Sources/PreferencesView/UIPreferencesHostingController.swift b/PreferencesView/Sources/PreferencesView/UIPreferencesHostingController.swift index 328335dc..abe471a9 100644 --- a/PreferencesView/Sources/PreferencesView/UIPreferencesHostingController.swift +++ b/PreferencesView/Sources/PreferencesView/UIPreferencesHostingController.swift @@ -27,6 +27,10 @@ public class UIPreferencesHostingController: UIHostingController { .onPreferenceChange(SupportedOrientationsPreferenceKey.self) { box.value?._orientations = $0 } + #elseif os(tvOS) + .onPreferenceChange(PressCommandsPreferenceKey.self) { + box.value?._pressCommandActions = $0 + } #endif ) @@ -112,6 +116,30 @@ public class UIPreferencesHostingController: UIHostingController { } #endif + + #if os(tvOS) + + override public func viewDidLoad() { + super.viewDidLoad() + + let gesture = UITapGestureRecognizer(target: self, action: #selector(ignorePress)) + gesture.allowedPressTypes = [NSNumber(value: UIPress.PressType.menu.rawValue)] + view.addGestureRecognizer(gesture) + } + + @objc + func ignorePress() {} + + private var _pressCommandActions: [PressCommandAction] = [] + + override public func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let buttonPress = presses.first?.type else { return } + + guard let action = _pressCommandActions + .first(where: { $0.press == buttonPress }) else { return } + action.action() + } + #endif } // TODO: remove after iOS 15 support removed diff --git a/PreferencesView/Sources/PreferencesView/ViewExtensions.swift b/PreferencesView/Sources/PreferencesView/ViewExtensions.swift index 68059ee0..39ce7830 100644 --- a/PreferencesView/Sources/PreferencesView/ViewExtensions.swift +++ b/PreferencesView/Sources/PreferencesView/ViewExtensions.swift @@ -27,4 +27,10 @@ public extension View { preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations) } #endif + + #if os(tvOS) + func pressCommands(@PressCommandsBuilder _ commands: @escaping () -> [PressCommandAction]) -> some View { + preference(key: PressCommandsPreferenceKey.self, value: commands()) + } + #endif } diff --git a/Shared/Coordinators/VideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator.swift index 55a89e0d..b5dcb37d 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator.swift @@ -76,6 +76,7 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { PreferencesView { VideoPlayer(manager: self.videoPlayerManager) } + .ignoresSafeArea() } else { NativeVideoPlayer(manager: self.videoPlayerManager) } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift index 0b09e14e..2d692b2b 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/Overlay.swift @@ -6,6 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import PreferencesView import SwiftUI import VLCUI @@ -21,6 +22,8 @@ extension VideoPlayer { private var proxy: VLCVideoPlayer.Proxy @EnvironmentObject private var router: VideoPlayerCoordinator.Router + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager @State private var confirmCloseWorkItem: DispatchWorkItem? @@ -50,6 +53,11 @@ extension VideoPlayer { .animation(.linear(duration: 0.1), value: currentOverlayType) .environment(\.currentOverlayType, $currentOverlayType) .environmentObject(overlayTimer) + .onChange(of: isPresentingOverlay) { + if !isPresentingOverlay { + currentOverlayType = .main + } + } .onChange(of: currentOverlayType) { _, newValue in if [.smallMenu, .chapters].contains(newValue) { overlayTimer.pause() @@ -64,39 +72,64 @@ extension VideoPlayer { isPresentingOverlay = false } } -// .onSelectPressed { -// currentOverlayType = .main -// isPresentingOverlay = true -// overlayTimer.start(5) -// } -// .onMenuPressed { -// -// 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 { -// withAnimation { -// isPresentingOverlay = false -// overlayTimer.stop() -// } -// } -// -// confirmCloseWorkItem = task -// -// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) -// } -// } + .pressCommands { + PressCommandAction(title: L10n.back, press: .menu, action: menuPress) + PressCommandAction(title: L10n.playAndPause, press: .playPause) { + if videoPlayerManager.state == .playing { + videoPlayerManager.proxy.pause() + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = true + } + } else if videoPlayerManager.state == .paused { + videoPlayerManager.proxy.play() + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = false + } + } + } + PressCommandAction(title: L10n.pressDownForMenu, press: .upArrow, action: arrowPress) + PressCommandAction(title: L10n.pressDownForMenu, press: .downArrow, action: arrowPress) + PressCommandAction(title: L10n.pressDownForMenu, press: .leftArrow, action: arrowPress) + PressCommandAction(title: L10n.pressDownForMenu, press: .rightArrow, action: arrowPress) + PressCommandAction(title: L10n.pressDownForMenu, press: .select, action: arrowPress) + } + } + + func arrowPress() { + if isPresentingOverlay { return } + currentOverlayType = .main + overlayTimer.start(5) + withAnimation { + isPresentingOverlay = true + } + } + + func menuPress() { + 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 { + withAnimation { + isPresentingOverlay = false + overlayTimer.stop() + } + } + + confirmCloseWorkItem = task + + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) + } } } }