From 2d7cad8cec72e65e851192f291f24592f57a141a Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 3 Jan 2022 22:55:39 -0700 Subject: [PATCH] lots of final tvos work --- .../App/JellyfinPlayer_tvOSApp.swift | 9 + JellyfinPlayer tvOS/Info.plist | 7 +- .../Views/BasicAppSettingsView.swift | 2 +- .../CinematicEpisodeItemView.swift | 16 +- .../CinematicItemAboutView.swift | 4 +- .../CinematicItemViewTopRow.swift | 112 ++++---- .../CinematicItemViewTopRowButton.swift | 5 +- .../CinematicMovieItemView.swift | 16 +- .../Views/ServerDetailView.swift | 1 + .../Views/ServerListView.swift | 6 +- JellyfinPlayer tvOS/Views/SettingsView.swift | 98 ------- .../ExperimentalSettingsView.swift | 28 ++ .../SettingsView/OverlaySettingsView.swift | 29 ++ .../Views/SettingsView/SettingsView.swift | 111 ++++++++ .../VideoPlayer/PlayerOverlayDelegate.swift | 1 - .../VideoPlayer/VLCPlayerViewController.swift | 67 +++-- .../tvOSOverlay/SmallMenuOverlay.swift | 249 ++++++++++++++++-- .../tvOSOverlay/tvOSOverlayContent.swift | 96 ------- .../tvOSOverlay/tvOSVLCOverlay.swift | 18 +- .../VideoPlayer/tvOSSLider/SliderView.swift | 2 + .../VideoPlayer/tvOSSLider/tvOSSlider.swift | 5 +- JellyfinPlayer.xcodeproj/project.pbxproj | 22 +- .../SwiftfinStore/SwiftfinStoreDefaults.swift | 3 + Shared/ViewModels/VideoPlayerViewModel.swift | 31 ++- Shared/Views/ImageView.swift | 8 +- 25 files changed, 596 insertions(+), 350 deletions(-) delete mode 100644 JellyfinPlayer tvOS/Views/SettingsView.swift create mode 100644 JellyfinPlayer tvOS/Views/SettingsView/ExperimentalSettingsView.swift create mode 100644 JellyfinPlayer tvOS/Views/SettingsView/OverlaySettingsView.swift create mode 100644 JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift diff --git a/JellyfinPlayer tvOS/App/JellyfinPlayer_tvOSApp.swift b/JellyfinPlayer tvOS/App/JellyfinPlayer_tvOSApp.swift index 2f1ff9e7..8f4d25bc 100644 --- a/JellyfinPlayer tvOS/App/JellyfinPlayer_tvOSApp.swift +++ b/JellyfinPlayer tvOS/App/JellyfinPlayer_tvOSApp.swift @@ -14,6 +14,15 @@ struct JellyfinPlayer_tvOSApp: App { var body: some Scene { WindowGroup { MainCoordinator().view() + .onAppear { + JellyfinPlayer_tvOSApp.setupAppearance() + } } } + + static func setupAppearance() { + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first as? UIWindowScene + windowScene?.windows.first?.overrideUserInterfaceStyle = .dark + } } diff --git a/JellyfinPlayer tvOS/Info.plist b/JellyfinPlayer tvOS/Info.plist index d2b80c8d..22b92be4 100644 --- a/JellyfinPlayer tvOS/Info.plist +++ b/JellyfinPlayer tvOS/Info.plist @@ -28,12 +28,15 @@ UILaunchScreen - + + UIColorName + LaunchScreenBackground + UIRequiredDeviceCapabilities arm64 UIUserInterfaceStyle - Automatic + Dark diff --git a/JellyfinPlayer tvOS/Views/BasicAppSettingsView.swift b/JellyfinPlayer tvOS/Views/BasicAppSettingsView.swift index 4f4265ec..328cb191 100644 --- a/JellyfinPlayer tvOS/Views/BasicAppSettingsView.swift +++ b/JellyfinPlayer tvOS/Views/BasicAppSettingsView.swift @@ -39,7 +39,7 @@ struct BasicAppSettingsView: View { } .alert(L10n.reset, isPresented: $resetTapped, actions: { Button(role: .destructive) { - viewModel.reset() + viewModel.resetAppSettings() basicAppSettingsRouter.dismissCoordinator() } label: { L10n.reset.text diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift index f304cdc6..014dc7ee 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift @@ -13,24 +13,11 @@ import SwiftUI struct CinematicEpisodeItemView: View { @ObservedObject var viewModel: EpisodeItemViewModel - @State var verticalScrollViewOffset: CGFloat = 0 @State var wrappedScrollView: UIScrollView? var body: some View { ZStack { - VStack { - Spacer() - - GeometryReader { overlayGeoReader in - Text("") - .onAppear { - self.verticalScrollViewOffset = overlayGeoReader.frame(in: .global).origin.y + overlayGeoReader.frame(in: .global).height - 200 - } - } - .frame(height: 50) - } - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) .ignoresSafeArea() @@ -38,10 +25,9 @@ struct CinematicEpisodeItemView: View { ScrollView { VStack(spacing: 0) { - Spacer(minLength: verticalScrollViewOffset) - CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView) .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) ZStack(alignment: .topLeading) { diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift index 6e8cb1ed..476f8871 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift @@ -16,8 +16,8 @@ struct CinematicItemAboutView: View { var body: some View { HStack(alignment: .top, spacing: 10) { - ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 230)) - .frame(width: 230, height: 380) + ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 257)) + .frame(width: 257, height: 380) .cornerRadius(10) ZStack(alignment: .topLeading) { diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift index a34fb698..71c44ec3 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift @@ -25,68 +25,86 @@ struct CinematicItemViewTopRow: View { .ignoresSafeArea() .frame(height: 210) - HStack(alignment: .bottom) { - VStack(alignment: .leading) { - HStack(alignment: .PlayInformationAlignmentGuide) { - CinematicItemViewTopRowButton(wrappedScrollView: wrappedScrollView) { + VStack { + Spacer() + + HStack(alignment: .bottom) { + VStack(alignment: .leading) { + HStack(alignment: .PlayInformationAlignmentGuide) { + + // MARK: Play Button { itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) } label: { - ZStack { - Color.white.frame(width: 230, height: 100) - - Text("Play") + HStack(spacing: 15) { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) .font(.title3) - .foregroundColor(.black) + Text(viewModel.playButtonText()) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) +// .font(.title3) + .fontWeight(.semibold) } + .frame(width: 230, height: 100) + .background(viewModel.playButtonItem == nil ? Color.secondarySystemFill : Color.white) + .cornerRadius(10) + +// ZStack { +// Color.white.frame(width: 230, height: 100) +// +// Text("Play") +// .font(.title3) +// .foregroundColor(.black) +// } } .buttonStyle(CardButtonStyle()) .disabled(viewModel.itemVideoPlayerViewModel == nil) } } - } - - VStack(alignment: .leading, spacing: 5) { - Text(viewModel.item.name ?? "") - .font(.title2) - .lineLimit(2) - if let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() { - Text("\(seriesName) - \(episodeLocator)") + VStack(alignment: .leading, spacing: 5) { + Text(viewModel.item.name ?? "") + .font(.title2) + .lineLimit(2) + + if let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() { + Text("\(seriesName) - \(episodeLocator)") + } + + HStack(alignment: .PlayInformationAlignmentGuide, spacing: 20) { + + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + } + + if let productionYear = viewModel.item.productionYear { + Text(String(productionYear)) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + } + + if let officialRating = viewModel.item.officialRating { + Text(officialRating) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + } + .foregroundColor(.secondary) } - HStack(alignment: .PlayInformationAlignmentGuide, spacing: 20) { - - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - } - - if let productionYear = viewModel.item.productionYear { - Text(String(productionYear)) - .font(.subheadline) - .fontWeight(.medium) - .lineLimit(1) - } - - if let officialRating = viewModel.item.officialRating { - Text(officialRating) - .font(.subheadline) - .fontWeight(.semibold) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } - .foregroundColor(.secondary) + Spacer() } - - Spacer() + .padding(.horizontal, 50) + .padding(.bottom, 50) } - .padding(.horizontal, 50) - .padding(.bottom, 50) + } .onChange(of: envFocused) { envFocus in if envFocus == true { diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift index 8549d1fa..bc799724 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift @@ -38,7 +38,10 @@ struct CinematicItemViewTopRowButton: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { wrappedScrollView?.scrollToTop() } - print("Scroll to top") + + withAnimation(.linear(duration: 0.15)) { + self.focused = newValue + } } } } diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift index 415fa865..9f202917 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift @@ -13,24 +13,11 @@ import SwiftUI struct CinematicMovieItemView: View { @ObservedObject var viewModel: MovieItemViewModel - @State var verticalScrollViewOffset: CGFloat = 0 @State var wrappedScrollView: UIScrollView? var body: some View { ZStack { - VStack { - Spacer() - - GeometryReader { overlayGeoReader in - Text("") - .onAppear { - self.verticalScrollViewOffset = overlayGeoReader.frame(in: .global).origin.y + overlayGeoReader.frame(in: .global).height - 200 - } - } - .frame(height: 50) - } - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) .ignoresSafeArea() @@ -38,10 +25,9 @@ struct CinematicMovieItemView: View { ScrollView { VStack(spacing: 0) { - Spacer(minLength: verticalScrollViewOffset) - CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView) .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) ZStack(alignment: .topLeading) { diff --git a/JellyfinPlayer tvOS/Views/ServerDetailView.swift b/JellyfinPlayer tvOS/Views/ServerDetailView.swift index d15219b1..dbb8e166 100644 --- a/JellyfinPlayer tvOS/Views/ServerDetailView.swift +++ b/JellyfinPlayer tvOS/Views/ServerDetailView.swift @@ -22,6 +22,7 @@ struct ServerDetailView: View { Text(SessionManager.main.currentLogin.server.name) .foregroundColor(.secondary) } + .focusable() HStack { Text("URI") diff --git a/JellyfinPlayer tvOS/Views/ServerListView.swift b/JellyfinPlayer tvOS/Views/ServerListView.swift index d416ce73..a62d454f 100644 --- a/JellyfinPlayer tvOS/Views/ServerListView.swift +++ b/JellyfinPlayer tvOS/Views/ServerListView.swift @@ -67,7 +67,7 @@ struct ServerListView: View { Text("Connect to a Jellyfin server to get started") .frame(minWidth: 50, maxWidth: 500) .multilineTextAlignment(.center) - .font(.callout) + .font(.body) Button { serverListRouter.route(to: \.connectToServer) @@ -75,8 +75,12 @@ struct ServerListView: View { L10n.connect.text .bold() .font(.callout) + .padding(.vertical) + .padding(.horizontal, 30) + .background(Color.jellyfinPurple) } .padding(.top, 40) + .buttonStyle(CardButtonStyle()) } } diff --git a/JellyfinPlayer tvOS/Views/SettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView.swift deleted file mode 100644 index f1ed6c9a..00000000 --- a/JellyfinPlayer tvOS/Views/SettingsView.swift +++ /dev/null @@ -1,98 +0,0 @@ -/* JellyfinPlayer/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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import CoreData -import SwiftUI -import Defaults -import JellyfinAPI - -struct SettingsView: View { - - @ObservedObject var viewModel: SettingsViewModel - - @Default(.inNetworkBandwidth) var inNetworkStreamBitrate - @Default(.outOfNetworkBandwidth) var outOfNetworkStreamBitrate - @Default(.isAutoSelectSubtitles) var isAutoSelectSubtitles - @Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode - @Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode - - var body: some View { - ZStack { - Color.black - .ignoresSafeArea() - - GeometryReader { reader in - HStack { - - Image(uiImage: UIImage(named: "App Icon")!) - .frame(width: reader.size.width / 2) - - Form { - Section(header: L10n.playbackSettings.text) { - Picker("Default local quality", selection: $inNetworkStreamBitrate) { - ForEach(self.viewModel.bitrates, id: \.self) { bitrate in - Text(bitrate.name).tag(bitrate.value) - } - } - - Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) { - ForEach(self.viewModel.bitrates, id: \.self) { bitrate in - Text(bitrate.name).tag(bitrate.value) - } - } - } - - Section(header: L10n.accessibility.text) { - Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles) - SearchablePicker(label: "Preferred subtitle language", - options: viewModel.langs, - optionToString: { $0.name }, - selected: Binding( - get: { viewModel.langs.first(where: { $0.isoCode == autoSelectSubtitlesLangcode }) ?? .auto }, - set: {autoSelectSubtitlesLangcode = $0.isoCode} - ) - ) - SearchablePicker(label: "Preferred audio language", - options: viewModel.langs, - optionToString: { $0.name }, - selected: Binding( - get: { viewModel.langs.first(where: { $0.isoCode == autoSelectAudioLangcode }) ?? .auto }, - set: { autoSelectAudioLangcode = $0.isoCode} - ) - ) - } - - Section(header: Text(SessionManager.main.currentLogin.server.name)) { - HStack { - Text(L10n.signedInAsWithString(SessionManager.main.currentLogin.user.username)).foregroundColor(.primary) - Spacer() - Button { - SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) - } label: { - L10n.switchUser.text.font(.callout) - } - } - Button { - SessionManager.main.logout() - } label: { - Text("Sign out").font(.callout) - } - } - } - .padding(.leading, 90) - .padding(.trailing, 90) - } - } - } - } -} - -struct SettingsView_Previews: PreviewProvider { - static var previews: some View { - SettingsView(viewModel: SettingsViewModel()) - } -} diff --git a/JellyfinPlayer tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView/ExperimentalSettingsView.swift new file mode 100644 index 00000000..179b1889 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/SettingsView/ExperimentalSettingsView.swift @@ -0,0 +1,28 @@ +// + /* + * 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 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Defaults +import SwiftUI + +struct ExperimentalSettingsView: View { + + @Default(.Experimental.syncSubtitleStateWithAdjacent) var syncSubtitleStateWithAdjacent + + var body: some View { + Form { + Section { + + Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) + + } header: { + Text("Experimental") + } + } + } +} diff --git a/JellyfinPlayer tvOS/Views/SettingsView/OverlaySettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView/OverlaySettingsView.swift new file mode 100644 index 00000000..81f068ac --- /dev/null +++ b/JellyfinPlayer tvOS/Views/SettingsView/OverlaySettingsView.swift @@ -0,0 +1,29 @@ +// + /* + * 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 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Defaults +import SwiftUI + +struct OverlaySettingsView: View { + + @Default(.shouldShowPlayPreviousItem) var shouldShowPlayPreviousItem + @Default(.shouldShowPlayNextItem) var shouldShowPlayNextItem + @Default(.shouldShowAutoPlay) var shouldShowAutoPlay + + var body: some View { + Form { + Section(header: Text("Overlay")) { + + Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem) + Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem) + Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay) + } + } + } +} diff --git a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift new file mode 100644 index 00000000..2ce3ce98 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift @@ -0,0 +1,111 @@ +/* JellyfinPlayer/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 2021 Aiden Vigue & Jellyfin Contributors + */ + +import CoreData +import SwiftUI +import Defaults +import JellyfinAPI + +struct SettingsView: View { + + @EnvironmentObject var settingsRouter: SettingsCoordinator.Router + @ObservedObject var viewModel: SettingsViewModel + + @Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode + @Default(.videoPlayerJumpForward) var jumpForwardLength + @Default(.videoPlayerJumpBackward) var jumpBackwardLength + @Default(.downActionShowsMenu) var downActionShowsMenu + + var body: some View { + GeometryReader { reader in + HStack { + + Image(uiImage: UIImage(named: "App Icon")!) + .scaleEffect(2) + .frame(width: reader.size.width / 2) + + Form { + Section(header: EmptyView()) { + HStack { + Text("User") + Spacer() + Text(viewModel.user.username) + .foregroundColor(.jellyfinPurple) + } + + Button { + settingsRouter.route(to: \.serverDetail) + } label: { + HStack { + Text("Server") + .foregroundColor(.primary) + Spacer() + Text(viewModel.server.name) + .foregroundColor(.jellyfinPurple) + + Image(systemName: "chevron.right") + .foregroundColor(.jellyfinPurple) + } + } + + Button { + SessionManager.main.logout() + } label: { + Text("Switch User") + .foregroundColor(Color.jellyfinPurple) + .font(.callout) + } + } + + Section(header: Text("Video Player")) { + Picker("Jump Forward Length", selection: $jumpForwardLength) { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in + Text(length.label).tag(length.rawValue) + } + } + + Picker("Jump Backward Length", selection: $jumpBackwardLength) { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in + Text(length.label).tag(length.rawValue) + } + } + + Toggle("Press Down for Menu", isOn: $downActionShowsMenu) + + Button { + settingsRouter.route(to: \.overlaySettings) + } label: { + HStack { + Text("Overlay") + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + + Button { + settingsRouter.route(to: \.experimentalSettings) + } label: { + HStack { + Text("Experimental") + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + } + } + } + } + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView(viewModel: SettingsViewModel(server: .sample, user: .sample)) + } +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index f80db501..fbc91a7c 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -13,7 +13,6 @@ protocol PlayerOverlayDelegate { func didSelectClose() func didSelectMenu() - func didDeselectMenu() func didSelectBackward() func didSelectForward() diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 50da72f6..08218ee3 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -116,6 +116,8 @@ class VLCPlayerViewController: UIViewController { // aren't unnecessarily set more than once vlcMediaPlayer.delegate = self vlcMediaPlayer.drawable = videoContentView + + // TODO: custom font sizes vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16) setupMediaPlayer(newViewModel: viewModel) @@ -188,8 +190,7 @@ class VLCPlayerViewController: UIViewController { guard let buttonPress = presses.first?.type else { return } switch(buttonPress) { - case .menu: - print("Menu") + case .menu: () // Captured by other gesture case .playPause: didSelectMain() case .select: @@ -201,24 +202,22 @@ class VLCPlayerViewController: UIViewController { showOverlay() restartOverlayDismissTimer() } - - print("Up arrow") case .downArrow: - if !displayingContentOverlay { - stopOverlayDismissTimer() - - hideOverlay() - showOverlayContent() + if Defaults[.downActionShowsMenu] { + if !displayingContentOverlay { + didSelectMenu() + } } case .leftArrow: - didSelectBackward() - print("Left arrow") + if !displayingContentOverlay { + didSelectBackward() + } case .rightArrow: - didSelectForward() - case .pageUp: - print("page up") - case .pageDown: - print("page down") + if !displayingContentOverlay { + didSelectForward() + } + case .pageUp: () + case .pageDown: () @unknown default: () } } @@ -235,6 +234,9 @@ class VLCPlayerViewController: UIViewController { hideOverlay() } else if displayingContentOverlay { hideOverlayContent() + + showOverlay() + restartOverlayDismissTimer() } else { vlcMediaPlayer.pause() @@ -301,11 +303,23 @@ class VLCPlayerViewController: UIViewController { currentOverlayContentHostingController.removeFromParent() } - let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel, - title: "Subtitles", - items: viewModel.subtitleStreams) { selectedMediaStream in - self.didSelectSubtitleStream(index: selectedMediaStream.index ?? -1) - } +// let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel, +// title: "Subtitles", +// items: viewModel.subtitleStreams) { selectedMediaStream in +// self.didSelectSubtitleStream(index: selectedMediaStream.index ?? -1) +// } +// let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel, +// items: viewModel.subtitleStreams, +// selectedIndex: viewModel.selectedSubtitleStreamIndex, +// title: "Subtitles") { selectedMediaStream in +// DispatchQueue.main.async { +// self.viewModel.selectedSubtitleStreamIndex = selectedMediaStream.index ?? -1 +// self.didSelectSubtitleStream(index: selectedMediaStream.index ?? -1) +// } +// } + + let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel) + let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) // let newOverlayContentView = tvOSOverlayContentView(viewModel: viewModel) // let newOverlayContentHostingController = UIHostingController(rootView: newOverlayContentView) @@ -357,6 +371,8 @@ extension VLCPlayerViewController { lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + // TODO: Custom buffer/cache amounts + let media = VLCMedia(url: newViewModel.streamURL) media.addOption("--prefetch-buffer-size=1048576") media.addOption("--network-caching=5000") @@ -640,14 +656,11 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } } - // TODO: Implement properly in overlays func didSelectMenu() { stopOverlayDismissTimer() - } - - // TODO: Implement properly in overlays - func didDeselectMenu() { - restartOverlayDismissTimer() + + hideOverlay() + showOverlayContent() } func didSelectBackward() { diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift index 31c676cc..4734cec9 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift @@ -10,47 +10,244 @@ import JellyfinAPI import SwiftUI +// TODO: Needs replacement/reworking struct SmallMediaStreamSelectionView: View { + enum Layer: Hashable { + case subtitles + case audio + case playbackSpeed + } + + enum MediaSection: Hashable { + case titles + case items + } + @ObservedObject var viewModel: VideoPlayerViewModel - let title: String - var items: [MediaStream] - var selectedAction: (MediaStream) -> Void + + @State private var updateFocusedLayer: Layer = .subtitles + + @FocusState private var subtitlesFocused: Bool + @FocusState private var audioFocused: Bool + @FocusState private var playbackSpeedFocused: Bool + @FocusState private var focusedSection: MediaSection? + @FocusState private var focusedLayer: Layer? { + willSet { + updateFocusedLayer = newValue! + + if focusedSection == .titles { + lastFocusedLayer = newValue! + } + } + } + + @State private var lastFocusedLayer: Layer = .subtitles var body: some View { ZStack(alignment: .bottom) { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.9)]), + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), startPoint: .top, endPoint: .bottom) .ignoresSafeArea() - .frame(height: 150) - - VStack { - - Spacer() - - HStack { - Text("Subtitles") + .frame(height: 300) + VStack { + + Spacer() + + HStack { + + // MARK: Subtitle Header + Button { + updateFocusedLayer = .subtitles + focusedLayer = .subtitles + } label: { + if updateFocusedLayer == .subtitles { + HStack(spacing: 15) { + Image(systemName: "captions.bubble") + Text("Subtitles") + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "captions.bubble") + Text("Subtitles") + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .subtitles) + .focused($subtitlesFocused) + .onChange(of: subtitlesFocused) { isFocused in + if isFocused { + focusedLayer = .subtitles + } + } + + // MARK: Audio Header + Button { + updateFocusedLayer = .audio + focusedLayer = .audio + } label: { + if updateFocusedLayer == .audio { + HStack(spacing: 15) { + Image(systemName: "speaker.wave.3") + Text("Audio") + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "speaker.wave.3") + Text("Audio") + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .audio) + .focused($audioFocused) + .onChange(of: audioFocused) { isFocused in + if isFocused { + focusedLayer = .audio + } + } + + // MARK: Playback Speed Header + Button { + updateFocusedLayer = .playbackSpeed + focusedLayer = .playbackSpeed + } label: { + if updateFocusedLayer == .playbackSpeed { + HStack(spacing: 15) { + Image(systemName: "speedometer") + Text("Playback Speed") + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "speedometer") + Text("Playback Speed") + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .playbackSpeed) + .focused($playbackSpeedFocused) + .onChange(of: playbackSpeedFocused) { isFocused in + if isFocused { + focusedLayer = .playbackSpeed + } + } + Spacer() } - - ScrollView(.horizontal) { - HStack { - ForEach(items, id: \.self) { item in - Button { - viewModel.playerOverlayDelegate?.didSelectSubtitleStream(index: item.index ?? -1) - } label: { - if item.index ?? -1 == viewModel.selectedSubtitleStreamIndex { - Label(item.displayTitle ?? "No Title", systemImage: "checkmark") - } else { - Text(item.displayTitle ?? "No Title") - } - } + .padding() + .focusSection() + .focused($focusedSection, equals: .titles) + .onChange(of: focusedSection) { newSection in + if focusedSection == .titles { + if lastFocusedLayer == .subtitles { + subtitlesFocused = true + } else if lastFocusedLayer == .audio { + audioFocused = true + } else if lastFocusedLayer == .playbackSpeed { + playbackSpeedFocused = true } } } - .frame(maxHeight: 100) + + if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles { + // MARK: Subtitles + + ScrollView(.horizontal) { + HStack { + if viewModel.subtitleStreams.isEmpty { + Button { + + } label: { + Text("None") + } + } else { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? "No Title") + } + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } else if updateFocusedLayer == .audio && lastFocusedLayer == .audio { + // MARK: Audio + + ScrollView(.horizontal) { + HStack { + if viewModel.audioStreams.isEmpty { + Button { + + } label: { + Text("None") + } + } else { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? "No Title") + } + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed { + // MARK: Rates + + ScrollView(.horizontal) { + HStack { + ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in + Button { + viewModel.playbackSpeed = playbackSpeed + } label: { + if playbackSpeed == viewModel.playbackSpeed { + Label(playbackSpeed.displayTitle, systemImage: "checkmark") + } else { + Text(playbackSpeed.displayTitle) + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } } } } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift deleted file mode 100644 index 851abf1b..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift +++ /dev/null @@ -1,96 +0,0 @@ -// - /* - * 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 2021 Aiden Vigue & Jellyfin Contributors - */ - -import JellyfinAPI -import SwiftUI - -struct tvOSOverlayContentView: View { - - @ObservedObject var viewModel: VideoPlayerViewModel - @FocusState private var focused: Bool - - var body: some View { - VStack { - - Spacer() - - HStack { - HStack { - Button { - print("here") - } label: { - Text("About") - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - - Button { - print("here") - } label: { - Text("Chapters") - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - - Button { - print("here") - } label: { - Text("Subtitles") - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - - Button { - print("here") - } label: { - Text("Audio") - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - } - .frame(height: 50) - - Spacer() - } - .padding(.bottom) - - Color.gray - .frame(height: 300) - } - } -} - -struct tvOSOverlayContentView_Previews: PreviewProvider { - - static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(), - title: "Glorious Purpose", - subtitle: "Loki - S1E1", - streamURL: URL(string: "www.apple.com")!, - hlsURL: URL(string: "www.apple.com")!, - response: PlaybackInfoResponse(), - audioStreams: [MediaStream(displayTitle: "English", index: -1)], - subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - subtitlesEnabled: true, - autoplayEnabled: false, - overlayType: .compact, - shouldShowPlayPreviousItem: true, - shouldShowPlayNextItem: true, - shouldShowAutoPlay: true) - - static var previews: some View { - ZStack { - Color.red - .ignoresSafeArea() - - tvOSOverlayContentView(viewModel: videoPlayerViewModel) - } - } -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift index d3e56743..03c859cb 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift @@ -7,12 +7,14 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Defaults import JellyfinAPI import SwiftUI struct tvOSVLCOverlay: View { @ObservedObject var viewModel: VideoPlayerViewModel + @Default(.downActionShowsMenu) var downActionShowsMenu @ViewBuilder private var mainButtonView: some View { @@ -29,7 +31,7 @@ struct tvOSVLCOverlay: View { var body: some View { ZStack(alignment: .bottom) { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7), .black]), + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), startPoint: .top, endPoint: .bottom) .ignoresSafeArea() @@ -101,16 +103,12 @@ struct tvOSVLCOverlay: View { } } - SFSymbolButton(systemName: "ellipsis.circle") { - viewModel.playerOverlayDelegate?.didSelectMenu() - } - .frame(maxWidth: 30, maxHeight: 30) - .contextMenu { - SFSymbolButton(systemName: "speedometer") { - print("here") + if !downActionShowsMenu { + SFSymbolButton(systemName: "ellipsis.circle") { + viewModel.playerOverlayDelegate?.didSelectMenu() } - } - } + .frame(maxWidth: 30, maxHeight: 30) + } } .offset(x: 0, y: 10) SliderView(viewModel: viewModel) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift index 662a4102..aebb9831 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift @@ -13,9 +13,11 @@ struct SliderView: UIViewRepresentable { @ObservedObject var viewModel: VideoPlayerViewModel + // TODO: look at adjusting value dependent on item runtime private let maxValue: Double = 1000 func updateUIView(_ uiView: TvOSSlider, context: Context) { + guard !viewModel.sliderIsScrubbing else { return } uiView.value = Float(maxValue * viewModel.sliderPercentage) } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift index f7f69126..77d08528 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift @@ -457,6 +457,9 @@ public final class TvOSSlider: UIControl { if !isFocused || abs(deceleratingVelocity) < 1 { stopDeceleratingTimer() } + + viewModel.sliderPercentage = Double(percent) + viewModel.sliderIsScrubbing = false } private func stopDeceleratingTimer() { @@ -504,7 +507,6 @@ public final class TvOSSlider: UIControl { viewModel.sliderPercentage = Double(percent) case .ended, .cancelled: - viewModel.sliderIsScrubbing = false thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) @@ -514,6 +516,7 @@ public final class TvOSSlider: UIControl { deceleratingTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(handleDeceleratingTimer(timer:)), userInfo: nil, repeats: true) } else { + viewModel.sliderIsScrubbing = false stopDeceleratingTimer() } default: diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 87aeb1c6..51fa5158 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -294,7 +294,6 @@ E178859E2780F53B0094FBCF /* SliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859D2780F53B0094FBCF /* SliderView.swift */; }; E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */; }; E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17885A3278105170094FBCF /* SFSymbolButton.swift */; }; - E17885A6278130610094FBCF /* tvOSOverlayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17885A5278130610094FBCF /* tvOSOverlayContent.swift */; }; E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; }; E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; }; E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */; }; @@ -384,6 +383,8 @@ E1E5D5462783C28100692DFE /* CinematicItemAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */; }; E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */; }; E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */; }; + E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */; }; + E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */; }; E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */; }; @@ -615,7 +616,6 @@ E178859D2780F53B0094FBCF /* SliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderView.swift; sourceTree = ""; }; E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVLCOverlay.swift; sourceTree = ""; }; E17885A3278105170094FBCF /* SFSymbolButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSymbolButton.swift; sourceTree = ""; }; - E17885A5278130610094FBCF /* tvOSOverlayContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSOverlayContent.swift; sourceTree = ""; }; E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Stackable.swift"; sourceTree = ""; }; E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewBody.swift; sourceTree = ""; }; E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = ""; }; @@ -668,6 +668,8 @@ E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemAboutView.swift; sourceTree = ""; }; E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = ""; }; E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = ""; }; + E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = ""; }; + E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = ""; }; E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = ""; }; E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallMenuOverlay.swift; sourceTree = ""; }; E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; @@ -1255,7 +1257,7 @@ 531690EE267ABF72005D8AB9 /* NextUpView.swift */, E193D54F2719430400900D82 /* ServerDetailView.swift */, E193D54A271941D300900D82 /* ServerListView.swift */, - 5398514426B64DA100101B49 /* SettingsView.swift */, + E1E5D54D2783E66600692DFE /* SettingsView */, E193D546271941C500900D82 /* UserListView.swift */, E193D548271941CC00900D82 /* UserSignInView.swift */, 5310694F2684E7EE00CFFDBA /* VideoPlayer */, @@ -1331,7 +1333,6 @@ isa = PBXGroup; children = ( E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */, - E17885A5278130610094FBCF /* tvOSOverlayContent.swift */, E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */, ); path = tvOSOverlay; @@ -1460,6 +1461,16 @@ path = SettingsView; sourceTree = ""; }; + E1E5D54D2783E66600692DFE /* SettingsView */ = { + isa = PBXGroup; + children = ( + E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */, + E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */, + 5398514426B64DA100101B49 /* SettingsView.swift */, + ); + path = SettingsView; + sourceTree = ""; + }; E1FCD08E26C466F3007C8DCF /* Errors */ = { isa = PBXGroup; children = ( @@ -1916,7 +1927,6 @@ 091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */, E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, E1E5D5422783B33900692DFE /* PortraitItemsRowView.swift in Sources */, - E17885A6278130610094FBCF /* tvOSOverlayContent.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, @@ -1983,6 +1993,7 @@ C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */, E1D4BF822719D22800A11E64 /* AppAppearance.swift in Sources */, C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */, + E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */, 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */, 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */, E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */, @@ -2016,6 +2027,7 @@ 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */, C4BE0764271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */, E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */, + E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */, C4BE076A271FC164003F4AD1 /* TVLibrariesView.swift in Sources */, E13DD3C327164941009D4DAF /* SwiftfinStore.swift in Sources */, 09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */, diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 921a3ba9..8f999033 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -56,4 +56,7 @@ extension Defaults.Keys { struct Experimental { static let syncSubtitleStateWithAdjacent = Key("experimental.syncSubtitleState", default: false, suite: SwiftfinStore.Defaults.generalSuite) } + + // tvos specific + static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) } diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index dc2ee91c..274db3d7 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -114,6 +114,11 @@ final class VideoPlayerViewModel: ViewModel { // Necessary PassthroughSubject to capture manual scrubbing from sliders let sliderScrubbingSubject = PassthroughSubject() + // During scrubbing, many progress reports were spammed + // Send only the current report after a delay + private var progressReportTimer: Timer? + private var lastProgressReport: PlaybackProgressInfo? + // MARK: init init(item: BaseItemDto, @@ -304,6 +309,15 @@ extension VideoPlayerViewModel { } } +// MARK: Progress Report Timer +extension VideoPlayerViewModel { + + private func sendNewProgressReportWithTimer() { + self.progressReportTimer?.invalidate() + self.progressReportTimer = Timer.scheduledTimer(timeInterval: 0.7, target: self, selector: #selector(_sendProgressReport), userInfo: nil, repeats: false) + } +} + // MARK: Updates extension VideoPlayerViewModel { @@ -408,7 +422,15 @@ extension VideoPlayerViewModel { nowPlayingQueue: nil, playlistItemId: "playlistItem0") - PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) + self.lastProgressReport = progressInfo + + self.sendNewProgressReportWithTimer() + } + + @objc private func _sendProgressReport() { + guard let lastProgressReport = lastProgressReport else { return } + + PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: lastProgressReport) .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { _ in @@ -441,3 +463,10 @@ extension VideoPlayerViewModel { .store(in: &cancellables) } } + +// MARK: Embedded SubtitleStreamViewModel +extension VideoPlayerViewModel { + + + +} diff --git a/Shared/Views/ImageView.swift b/Shared/Views/ImageView.swift index 8176fd13..42d13995 100644 --- a/Shared/Views/ImageView.swift +++ b/Shared/Views/ImageView.swift @@ -20,6 +20,7 @@ struct ImageView: View { self.failureInitials = failureInitials } + // TODO: fix placeholder hash image @ViewBuilder private var placeholderImage: some View { Image(uiImage: UIImage(blurHash: blurhash, size: CGSize(width: 8, height: 8)) ?? UIImage(blurHash: "001fC^", size: CGSize(width: 8, height: 8))!) @@ -47,7 +48,12 @@ struct ImageView: View { } else if phase.error != nil { failureImage } else { - placeholderImage + // TODO: remove once placeholder hash image fixed + ZStack { + Color.gray.ignoresSafeArea() + + ProgressView() + } } } }