From 59465a3c4ad971176712f2f1e81371a94141d083 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 07:21:44 -0700 Subject: [PATCH 01/62] Initial implementation over --- .../Components/MediaPlayButtonRowView.swift | 9 +- .../Views/ItemView/EpisodeItemView.swift | 10 +- .../Views/LatestMediaView.swift | 2 +- .../Views/VideoPlayer/AudioView.swift | 66 - .../InfoTabBarViewController.swift | 59 - .../Views/VideoPlayer/MediaInfoView.swift | 121 -- .../NativePlayerViewController.swift | 159 +++ .../VideoPlayer/PlayerOverlayDelegate.swift | 30 + .../Views/VideoPlayer/SubtitlesView.swift | 67 - .../Views/VideoPlayer/VideoPlayer.storyboard | 126 -- .../Views/VideoPlayer/VideoPlayer.swift | 28 - .../Views/VideoPlayer/VideoPlayerView.swift | 25 + .../VideoPlayerViewController.swift | 772 ----------- JellyfinPlayer.xcodeproj/project.pbxproj | 142 +- JellyfinPlayer/App/AppDelegate.swift | 8 + JellyfinPlayer/Views/ItemView/ItemView.swift | 18 +- .../Landscape/ItemLandscapeMainView.swift | 14 +- .../ItemPortraitHeaderOverlayView.swift | 7 +- .../Portrait/ItemPortraitMainView.swift | 7 +- JellyfinPlayer/Views/SettingsView.swift | 2 + .../NativePlayerViewController.swift | 121 ++ .../Views/VideoPlayer/PlaybackSpeed.swift | 40 + .../VideoPlayer/PlayerOverlayDelegate.swift | 30 + .../VLCPlayerCompactOverlayView.swift | 271 ++++ .../VideoPlayer/VLCPlayerOverlayView.swift | 258 ++++ .../VideoPlayer/VLCPlayerViewController.swift | 445 ++++++ .../Views/VideoPlayer/VideoPlayer.storyboard | 252 ---- .../Views/VideoPlayer/VideoPlayer.swift | 1188 ----------------- .../VideoPlayerCastDeviceSelector.swift | 93 -- .../VideoPlayer/VideoPlayerSettingsView.swift | 89 -- .../Views/VideoPlayer/VideoPlayerView.swift | 41 + .../Views/VideoPlayer/VideoUpNextView.swift | 54 - Shared/Coordinators/ItemCoordinator.swift | 4 +- .../Coordinators/VideoPlayerCoordinator.swift | 30 - .../iOSVideoPlayerCoordinator.swift | 42 + .../tvOSVideoPlayerCoordinator.swift | 35 + .../Extensions/URLComponentsExtensions.swift | 22 + .../SwiftfinStore/SwiftfinStoreDefaults.swift | 1 + Shared/ViewModels/HomeViewModel.swift | 4 +- Shared/ViewModels/ItemViewModel.swift | 103 ++ Shared/ViewModels/LibraryViewModel.swift | 4 +- Shared/ViewModels/VideoPlayerViewModel.swift | 218 +++ 42 files changed, 1960 insertions(+), 3057 deletions(-) delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/AudioView.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/InfoTabBarViewController.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/MediaInfoView.swift create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/SubtitlesView.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.storyboard delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.swift create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift delete mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerViewController.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayer.storyboard delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayerCastDeviceSelector.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayerSettingsView.swift create mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoUpNextView.swift delete mode 100644 Shared/Coordinators/VideoPlayerCoordinator.swift create mode 100644 Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift create mode 100644 Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift create mode 100644 Shared/Extensions/URLComponentsExtensions.swift create mode 100644 Shared/ViewModels/VideoPlayerViewModel.swift diff --git a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift index 4e6ef971..4063152a 100644 --- a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift +++ b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift @@ -11,15 +11,22 @@ import SwiftUI struct MediaPlayButtonRowView: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: ItemViewModel @State var wrappedScrollView: UIScrollView? var body: some View { HStack { VStack { - NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) { +// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) { +// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) +// } + Button { + itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) + } label: { MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) } + Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play) .font(.caption) } diff --git a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift index d0632bf8..8e3c3d51 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift @@ -11,6 +11,8 @@ import SwiftUI import JellyfinAPI struct EpisodeItemView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: EpisodeItemViewModel @State var actors: [BaseItemPerson] = [] @@ -130,7 +132,13 @@ struct EpisodeItemView: View { .font(.caption) } VStack { - NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) { +// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) { +// MediaViewActionButton(icon: "play.fill") +// } + Button { + itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) + } label: { +// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) MediaViewActionButton(icon: "play.fill") } Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play) diff --git a/JellyfinPlayer tvOS/Views/LatestMediaView.swift b/JellyfinPlayer tvOS/Views/LatestMediaView.swift index 92be14d8..0bc78790 100644 --- a/JellyfinPlayer tvOS/Views/LatestMediaView.swift +++ b/JellyfinPlayer tvOS/Views/LatestMediaView.swift @@ -27,7 +27,7 @@ struct LatestMediaView: View { viewDidLoad = true DispatchQueue.global(qos: .userInitiated).async { - UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true, limit: 12) + UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { response in diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/AudioView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/AudioView.swift deleted file mode 100644 index 9b198f94..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/AudioView.swift +++ /dev/null @@ -1,66 +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 SwiftUI - -class AudioViewController: InfoTabViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - tabBarItem.title = "Audio" - - } - - func prepareAudioView(audioTracks: [AudioTrack], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) { - let contentView = UIHostingController(rootView: AudioView(selectedTrack: selectedTrack, audioTrackArray: audioTracks, delegate: delegate)) - self.view.addSubview(contentView.view) - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true - - } - -} - -struct AudioView: View { - - @State var selectedTrack: Int32 = -1 - @State var audioTrackArray: [AudioTrack] = [] - - weak var delegate: VideoPlayerSettingsDelegate? - - var body : some View { - NavigationView { - VStack { - List(audioTrackArray, id: \.id) { track in - Button(action: { - delegate?.selectNew(audioTrack: track.id) - selectedTrack = track.id - }, label: { - HStack(spacing: 10) { - if track.id == selectedTrack { - Image(systemName: "checkmark") - } else { - Image(systemName: "checkmark") - .hidden() - } - Text(track.name) - } - }) - - } - } - .frame(width: 400) - .frame(maxHeight: 400) - } - } -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/InfoTabBarViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/InfoTabBarViewController.swift deleted file mode 100644 index e7f13ebd..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/InfoTabBarViewController.swift +++ /dev/null @@ -1,59 +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 TVUIKit -import JellyfinAPI - -class InfoTabViewController: UIViewController { - var height: CGFloat = 420 -} - -class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate { - - var videoPlayer: VideoPlayerViewController? - var subtitleViewController: SubtitlesViewController? - var audioViewController: AudioViewController? - var mediaInfoController: MediaInfoViewController? - - override func viewDidLoad() { - super.viewDidLoad() - mediaInfoController = MediaInfoViewController() - audioViewController = AudioViewController() - subtitleViewController = SubtitlesViewController() - - viewControllers = [mediaInfoController!, audioViewController!, subtitleViewController!] - - } - - func setupInfoViews(mediaItem: BaseItemDto, subtitleTracks: [Subtitle], selectedSubtitleTrack: Int32, audioTracks: [AudioTrack], selectedAudioTrack: Int32, delegate: VideoPlayerSettingsDelegate) { - - mediaInfoController?.setMedia(item: mediaItem) - - audioViewController?.prepareAudioView(audioTracks: audioTracks, selectedTrack: selectedAudioTrack, delegate: delegate) - - subtitleViewController?.prepareSubtitleView(subtitleTracks: subtitleTracks, selectedTrack: selectedSubtitleTrack, delegate: delegate) - - } - - override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { - - if let index = tabBar.items?.firstIndex(of: item), - let tabViewController = viewControllers?[index] as? InfoTabViewController, - let width = videoPlayer?.infoPanelContainerView.frame.width { - let height = tabViewController.height + tabBar.frame.size.height - UIView.animate(withDuration: 0.6, delay: 0, options: .curveEaseOut) { [self] in - videoPlayer?.infoPanelContainerView.frame = CGRect(x: 88, y: 87, width: width, height: height) - } - } - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return true - } -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/MediaInfoView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/MediaInfoView.swift deleted file mode 100644 index e914b3ad..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/MediaInfoView.swift +++ /dev/null @@ -1,121 +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 SwiftUI -import JellyfinAPI - -class MediaInfoViewController: InfoTabViewController { - private var contentView: UIHostingController! - - override func viewDidLoad() { - super.viewDidLoad() - - tabBarItem.title = "Info" - } - - func setMedia(item: BaseItemDto) { - contentView = UIHostingController(rootView: MediaInfoView(item: item)) - self.view.addSubview(contentView.view) - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true - - height = self.view.frame.height - - } -} - -struct MediaInfoView: View { - @State var item: BaseItemDto? - - var body: some View { - if let item = item { - HStack(spacing: 30) { - VStack { - ImageView(src: item.type == "Episode" ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), bh: item.type == "Episode" ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash()) - .frame(width: 200, height: 300) - .cornerRadius(10) - .ignoresSafeArea() - Spacer() - } - - VStack(alignment: .leading, spacing: 10) { - if item.type == "Episode" { - Text(item.seriesName ?? "Series") - .fontWeight(.bold) - - HStack { - Text(item.name ?? "Episode") - .foregroundColor(.secondary) - - Text(item.getEpisodeLocator() ?? "") - - if let date = item.premiereDate { - Text(formatDate(date: date)) - } - } - } else { - Text(item.name ?? "Movie") - .fontWeight(.bold) - } - - HStack(spacing: 10) { - if item.type != "Episode" { - if let year = item.productionYear { - Text(String(year)) - } - } - - if item.runTimeTicks != nil { - Text("•") - Text(item.getItemRuntime()) - } - - if let rating = item.officialRating { - Text("•") - - Text("\(rating)").font(.subheadline) - .fontWeight(.semibold) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - - } - } - .foregroundColor(.secondary) - - if let overview = item.overview { - Text(overview) - .padding(.top) - .foregroundColor(.secondary) - } - - Spacer() - } - - Spacer() - - } - .padding(.leading, 350) - .padding(.trailing, 125) - } else { - EmptyView() - } - - } - - func formatDate(date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "MMM d, yyyy" - - return formatter.string(from: date) - } -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift new file mode 100644 index 00000000..f5fcbbc3 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift @@ -0,0 +1,159 @@ +// +// NativePlayerViewController.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/20/21. +// + +import AVKit +import Combine +import JellyfinAPI +import UIKit + +class NativePlayerViewController: AVPlayerViewController { + + let viewModel: VideoPlayerViewModel + + var timeObserverToken: Any? + + var lastProgressTicks: Int64 = 0 + + private var cancellables = Set() + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + + let player = AVPlayer(url: viewModel.hlsURL) + + player.appliesMediaSelectionCriteriaAutomatically = false + player.currentItem?.externalMetadata = createMetadata() + player.currentItem?.navigationMarkerGroups = createNavigationMarkerGroups() + +// let chevron = UIImage(systemName: "chevron.right.circle.fill")! +// let testAction = UIAction(title: "Next", image: chevron) { action in +// SessionAPI.sendSystemCommand(sessionId: viewModel.response.playSessionId!, command: .setSubtitleStreamIndex) +// .sink { completion in +// print(completion) +// } receiveValue: { _ in +// print("idk but we're here") +// } +// .store(in: &self.cancellables) +// } + +// self.transportBarCustomMenuItems = [testAction] + +// self.infoViewActions.append(testAction) + + let timeScale = CMTimeScale(NSEC_PER_SEC) + let time = CMTime(seconds: 5, preferredTimescale: timeScale) + + timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in +// print("Timer timed: \(time)") + + if time.seconds != 0 { + self?.sendProgressReport(seconds: time.seconds) + } + } + + self.player = player + + self.allowsPictureInPicturePlayback = true + self.player?.allowsExternalPlayback = true + } + + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any] = [ + .commonIdentifierTitle: viewModel.title, + .iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "", + .commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any, + .commonIdentifierDescription: viewModel.item.overview ?? "", + .iTunesMetadataContentRating: viewModel.item.officialRating ?? "", + .quickTimeMetadataGenre: viewModel.item.genres?.first ?? "" + ] + + return allMetadata.compactMap { createMetadataItem(for:$0, value:$1) } + } + + private func createMetadataItem(for identifier: AVMetadataIdentifier, + value: Any) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem + } + + private func createNavigationMarkerGroups() -> [AVNavigationMarkersGroup] { + guard let chapters = viewModel.item.chapters else { return [] } + + var metadataGroups: [AVTimedMetadataGroup] = [] + + // TODO: Determine range between chapters + chapters.forEach { chapterInfo in + var chapterMetadata: [AVMetadataItem] = [] + + let titleItem = createMetadataItem(for: .commonIdentifierTitle, value: chapterInfo.name ?? "No Name") + chapterMetadata.append(titleItem) + + let imageItem = createMetadataItem(for: .commonIdentifierArtwork, value: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any) + chapterMetadata.append(imageItem) + + let startTime = CMTimeMake(value: chapterInfo.startPositionTicks ?? 0, timescale: 10_000_000) + let endTime = CMTimeMake(value: (chapterInfo.startPositionTicks ?? 0) + 50_000_000, timescale: 10_000_000) + let timeRange = CMTimeRangeFromTimeToTime(start: startTime, end: endTime) + + metadataGroups.append(AVTimedMetadataGroup(items: chapterMetadata, timeRange: timeRange)) + } + + return [AVNavigationMarkersGroup(title: nil, timedNavigationMarkers: metadataGroups)] + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stop() + removePeriodicTimeObserver() + } + + func removePeriodicTimeObserver() { + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek(to: CMTimeMake(value: viewModel.item.userData?.playbackPositionTicks ?? 0, timescale: 10_000_000), toleranceBefore: CMTimeMake(value: 5, timescale: 1), toleranceAfter: CMTimeMake(value: 5, timescale: 1), completionHandler: { _ in + self.play() + }) + } + + private func play() { + player?.play() + + viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0) + } + + private func sendProgressReport(seconds: Double) { + viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000) + } + + private func stop() { + self.player?.pause() + viewModel.sendStopReport(ticks: 10_000_000) + } +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift new file mode 100644 index 00000000..338ddd85 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -0,0 +1,30 @@ +// +// PlayerOverlayDelegate.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 12/27/21. +// + +import Foundation + +protocol PlayerOverlayDelegate { + + func didSelectClose() + func didSelectGoogleCast() + func didSelectAirplay() + func didSelectCaptions() + func didSelectMenu() + func didDeselectMenu() + + func didSelectBackward() + func didSelectForward() + func didSelectMain() + + func didGenerallyTap() + + func didBeginScrubbing() + func didEndScrubbing(position: Double) + + func didSelectAudioStream(index: Int) + func didSelectSubtitleStream(index: Int) +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/SubtitlesView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/SubtitlesView.swift deleted file mode 100644 index a4e35717..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/SubtitlesView.swift +++ /dev/null @@ -1,67 +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 SwiftUI - -class SubtitlesViewController: InfoTabViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - tabBarItem.title = "Subtitles" - - } - - func prepareSubtitleView(subtitleTracks: [Subtitle], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) { - let contentView = UIHostingController(rootView: SubtitleView(selectedTrack: selectedTrack, subtitleTrackArray: subtitleTracks, delegate: delegate)) - self.view.addSubview(contentView.view) - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true - - } -} - -struct SubtitleView: View { - - @State var selectedTrack: Int32 = -1 - @State var subtitleTrackArray: [Subtitle] = [] - - weak var delegate: VideoPlayerSettingsDelegate? - - var body : some View { - NavigationView { - VStack { - List(subtitleTrackArray, id: \.id) { track in - Button(action: { - delegate?.selectNew(subtitleTrack: track.id) - selectedTrack = track.id - }, label: { - HStack(spacing: 10) { - if track.id == selectedTrack { - Image(systemName: "checkmark") - } else { - Image(systemName: "checkmark") - .hidden() - } - Text(track.name) - } - }) - - } - } - .frame(width: 400) - .frame(maxHeight: 400) - - } - } - -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.storyboard b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.storyboard deleted file mode 100644 index 1eac1fbd..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.storyboard +++ /dev/null @@ -1,126 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.swift deleted file mode 100644 index 809ad75a..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayer.swift +++ /dev/null @@ -1,28 +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 SwiftUI -import JellyfinAPI - -struct VideoPlayerView: UIViewControllerRepresentable { - var item: BaseItemDto - - func makeUIViewController(context: Context) -> some UIViewController { - - let storyboard = UIStoryboard(name: "VideoPlayer", bundle: nil) - let viewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! VideoPlayerViewController - viewController.manifest = item - - return viewController - } - - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { - - } -} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift new file mode 100644 index 00000000..0b2aee90 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift @@ -0,0 +1,25 @@ +// +// VideoPlayerView.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/12/21. +// + +import UIKit +import SwiftUI + +struct NativePlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = NativePlayerViewController + + func makeUIViewController(context: Context) -> NativePlayerViewController { + + return NativePlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) { + + } +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerViewController.swift deleted file mode 100644 index 3e20b5ef..00000000 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerViewController.swift +++ /dev/null @@ -1,772 +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 TVUIKit -import TVVLCKit -import MediaPlayer -import JellyfinAPI -import Combine -import Defaults - -protocol VideoPlayerSettingsDelegate: AnyObject { - func selectNew(audioTrack id: Int32) - func selectNew(subtitleTrack id: Int32) -} - -class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate, VLCMediaPlayerDelegate, VLCMediaDelegate, UIGestureRecognizerDelegate { - - @IBOutlet weak var videoContentView: UIView! - @IBOutlet weak var controlsView: UIView! - - @IBOutlet weak var activityIndicator: UIActivityIndicatorView! - - @IBOutlet weak var transportBarView: UIView! - @IBOutlet weak var scrubberView: UIView! - @IBOutlet weak var scrubLabel: UILabel! - @IBOutlet weak var gradientView: UIView! - - @IBOutlet weak var currentTimeLabel: UILabel! - @IBOutlet weak var remainingTimeLabel: UILabel! - - @IBOutlet weak var infoPanelContainerView: UIView! - - var infoTabBarViewController: InfoTabBarViewController? - var focusedOnTabBar: Bool = false - var showingInfoPanel: Bool = false - - var mediaPlayer = VLCMediaPlayer() - - var lastProgressReportTime: Double = 0 - var lastTime: Float = 0.0 - var startTime: Int = 0 - - var selectedAudioTrack: Int32 = -1 - var selectedCaptionTrack: Int32 = -1 - - var subtitleTrackArray: [Subtitle] = [] - var audioTrackArray: [AudioTrack] = [] - - var playing: Bool = false - var seeking: Bool = false - var showingControls: Bool = false - var loading: Bool = true - - var initialSeekPos: CGFloat = 0 - var videoPos: Double = 0 - var videoDuration: Double = 0 - var controlsAppearTime: Double = 0 - - var manifest: BaseItemDto = BaseItemDto() - var playbackItem = PlaybackItem() - var playSessionId: String = "" - - var cancellables = Set() - - override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { - - super.didUpdateFocus(in: context, with: coordinator) - - // Check if focused on the tab bar, allows for swipe up to dismiss the info panel - if let nextFocused = context.nextFocusedView, - nextFocused.description.contains("UITabBarButton") { - // Set value after half a second so info panel is not dismissed instantly when swiping up from content - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.focusedOnTabBar = true - } - } else { - focusedOnTabBar = false - } - - } - - override func viewDidLoad() { - super.viewDidLoad() - - activityIndicator.isHidden = false - activityIndicator.startAnimating() - - mediaPlayer.delegate = self - mediaPlayer.drawable = videoContentView - - if let runTimeTicks = manifest.runTimeTicks { - videoDuration = Double(runTimeTicks / 10_000_000) - } - - // Black gradient behind transport bar - let gradientLayer: CAGradientLayer = CAGradientLayer() - gradientLayer.frame.size = self.gradientView.frame.size - gradientLayer.colors = [UIColor.black.withAlphaComponent(0.6).cgColor, UIColor.black.withAlphaComponent(0).cgColor] - gradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0) - gradientLayer.endPoint = CGPoint(x: 0.0, y: 0.0) - self.gradientView.layer.addSublayer(gradientLayer) - - infoPanelContainerView.center = CGPoint(x: infoPanelContainerView.center.x, y: -infoPanelContainerView.frame.height) - infoPanelContainerView.layer.cornerRadius = 40 - - let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) - blurEffectView.frame = infoPanelContainerView.bounds - blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - blurEffectView.layer.cornerRadius = 40 - blurEffectView.clipsToBounds = true - infoPanelContainerView.addSubview(blurEffectView) - infoPanelContainerView.sendSubviewToBack(blurEffectView) - - transportBarView.layer.cornerRadius = CGFloat(5) - - setupGestures() - - fetchVideo() - - setupNowPlayingCC() - - // Adjust subtitle size - mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16) - - } - - func fetchVideo() { - // Fetch max bitrate from UserDefaults depending on current connection mode - let maxBitrate = Defaults[.inNetworkBandwidth] - - // Build a device profile - let builder = DeviceProfileBuilder() - builder.setMaxBitrate(bitrate: maxBitrate) - let profile = builder.buildProfile() - - let currentUser = SessionManager.main.currentLogin.user - - let playbackInfo = PlaybackInfoDto(userId: currentUser.id, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true) - - DispatchQueue.global(qos: .userInitiated).async { [self] in - MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: currentUser.id, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { [self] response in - - videoContentView.setNeedsLayout() - videoContentView.setNeedsDisplay() - - playSessionId = response.playSessionId ?? "" - - guard let mediaSource = response.mediaSources?.first.self else { - return - } - - let item = PlaybackItem() - let streamURL: URL - - // Item is being transcoded by request of server - if let transcodiungUrl = mediaSource.transcodingUrl { - item.videoType = .transcode - streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(transcodiungUrl)")! - } - // Item will be directly played by the client - else { - item.videoType = .directPlay -// streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag!)")! - streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")! - } - - item.videoUrl = streamURL - - let disableSubtitleTrack = Subtitle(name: "None", id: -1, url: nil, delivery: .embed, codec: "", languageCode: "") - subtitleTrackArray.append(disableSubtitleTrack) - - // Loop through media streams and add to array - for stream in mediaSource.mediaStreams! { - - if stream.type == .subtitle { - var deliveryUrl: URL? - - if stream.deliveryMethod == .external { - deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl!)")! - } - - let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", languageCode: stream.language ?? "") - - if stream.isDefault == true { - selectedCaptionTrack = Int32(stream.index!) - } - - if subtitle.delivery != .encode { - subtitleTrackArray.append(subtitle) - } - } - - if stream.type == .audio { - let track = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", id: Int32(stream.index!)) - - if stream.isDefault! == true { - selectedAudioTrack = Int32(stream.index!) - } - - audioTrackArray.append(track) - } - } - - // If no default audio tracks select the first one - if selectedAudioTrack == -1 && !audioTrackArray.isEmpty { - selectedAudioTrack = audioTrackArray.first!.id - } - - self.sendPlayReport() - playbackItem = item - - mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl) - mediaPlayer.media.delegate = self - mediaPlayer.play() - - // 1 second = 10,000,000 ticks - - if let rawStartTicks = manifest.userData?.playbackPositionTicks { - mediaPlayer.jumpForward(Int32(rawStartTicks / 10_000_000)) - } - - subtitleTrackArray.forEach { sub in - if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" { - mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false) - } - } - - playing = true - setupInfoPanel() - - }) - .store(in: &cancellables) - - } - } - - func setupNowPlayingCC() { - let commandCenter = MPRemoteCommandCenter.shared() - commandCenter.playCommand.isEnabled = true - commandCenter.pauseCommand.isEnabled = true - - commandCenter.skipBackwardCommand.isEnabled = true - commandCenter.skipBackwardCommand.preferredIntervals = [15] - - commandCenter.skipForwardCommand.isEnabled = true - commandCenter.skipForwardCommand.preferredIntervals = [30] - - commandCenter.changePlaybackPositionCommand.isEnabled = true - commandCenter.enableLanguageOptionCommand.isEnabled = true - - // Add handler for Pause Command - commandCenter.pauseCommand.addTarget { _ in - self.pause() - self.showingControls = true - self.controlsView.isHidden = false - self.controlsAppearTime = CACurrentMediaTime() - return .success - } - - // Add handler for Play command - commandCenter.playCommand.addTarget { _ in - self.play() - self.showingControls = false - self.controlsView.isHidden = true - return .success - } - - // Add handler for FF command - commandCenter.skipForwardCommand.addTarget { _ in - self.mediaPlayer.jumpForward(30) - self.sendProgressReport(eventName: "timeupdate") - return .success - } - - // Add handler for RW command - commandCenter.skipBackwardCommand.addTarget { _ in - self.mediaPlayer.jumpBackward(15) - self.sendProgressReport(eventName: "timeupdate") - return .success - } - - // Scrubber - commandCenter.changePlaybackPositionCommand.addTarget { [weak self](remoteEvent) -> MPRemoteCommandHandlerStatus in - guard let self = self else {return .commandFailed} - - if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent { - let targetSeconds = event.positionTime - let videoPosition = Double(self.mediaPlayer.time.intValue / 1000) - let offset = targetSeconds - videoPosition - - if offset > 0 { - self.mediaPlayer.jumpForward(Int32(offset)) - } else { - self.mediaPlayer.jumpBackward(Int32(abs(offset))) - } - self.sendProgressReport(eventName: "unpause") - - return .success - } else { - return .commandFailed - } - } - - var runTicks = 0 - var playbackTicks = 0 - - if let ticks = manifest.runTimeTicks { - runTicks = Int(ticks / 10_000_000) - } - - if let ticks = manifest.userData?.playbackPositionTicks { - playbackTicks = Int(ticks / 10_000_000) - } - - var nowPlayingInfo = [String: Any]() - - nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video" - if manifest.type == "Episode" { - nowPlayingInfo[MPMediaItemPropertyArtist] = "\(manifest.seriesName ?? manifest.name ?? "") • \(manifest.getEpisodeLocator())" - } - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 - nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks - - if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 500)) { - if let artworkImage = UIImage(data: imageData as Data) { - let artwork = MPMediaItemArtwork.init(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in - return artworkImage - }) - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork - } - } - - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - - UIApplication.shared.beginReceivingRemoteControlEvents() - } - - func updateNowPlayingCenter(time: Double?, playing: Bool?) { - - var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]() - - if let playing = playing { - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = playing ? 1.0 : 0.0 - } - - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = mediaPlayer.time.intValue / 1000 - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - } - - // Grabs a reference to the info panel view controller - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if segue.identifier == "infoView" { - infoTabBarViewController = segue.destination as? InfoTabBarViewController - infoTabBarViewController?.videoPlayer = self - - } - } - - // MARK: Player functions - // Animate the scrubber when playing state changes - func animateScrubber() { - let y: CGFloat = playing ? 0 : -20 - let height: CGFloat = playing ? 10 : 30 - - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn, animations: { - self.scrubberView.frame = CGRect(x: self.scrubberView.frame.minX, y: y, width: 2, height: height) - }) - } - - func pause() { - playing = false - mediaPlayer.pause() - - self.sendProgressReport(eventName: "pause") - - self.updateNowPlayingCenter(time: nil, playing: false) - - animateScrubber() - - self.scrubLabel.frame = CGRect(x: self.scrubberView.frame.minX - self.scrubLabel.frame.width/2, y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height) - } - - func play () { - playing = true - mediaPlayer.play() - - self.updateNowPlayingCenter(time: nil, playing: true) - - self.sendProgressReport(eventName: "unpause") - - animateScrubber() - } - - func toggleInfoContainer() { - showingInfoPanel.toggle() - - infoTabBarViewController?.view.isUserInteractionEnabled = showingInfoPanel - - if showingInfoPanel && seeking { - scrubLabel.isHidden = true - UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { - self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: self.scrubberView.frame.minY, width: 2, height: self.scrubberView.frame.height) - }) { _ in - self.scrubLabel.frame = CGRect(x: (self.initialSeekPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height) - self.scrubLabel.text = self.currentTimeLabel.text - } - seeking = false - - } - - UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { [self] in - let size = infoPanelContainerView.frame.size - let y: CGFloat = showingInfoPanel ? 87 : -size.height - - infoPanelContainerView.frame = CGRect(x: 88, y: y, width: size.width, height: size.height) - } - - } - - // MARK: Gestures - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - for item in presses { - if item.type == .select { - selectButtonTapped() - } - } - } - - func setupGestures() { - self.becomeFirstResponder() - - // vlc crap - videoContentView.gestureRecognizers?.forEach { gr in - videoContentView.removeGestureRecognizer(gr) - } - videoContentView.subviews.forEach { sv in - sv.gestureRecognizers?.forEach { gr in - sv.removeGestureRecognizer(gr) - } - } - - let playPauseGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped)) - let playPauseType = UIPress.PressType.playPause - playPauseGesture.allowedPressTypes = [NSNumber(value: playPauseType.rawValue)] - view.addGestureRecognizer(playPauseGesture) - - let backTapGesture = UITapGestureRecognizer(target: self, action: #selector(self.backButtonPressed(tap:))) - let backPress = UIPress.PressType.menu - backTapGesture.allowedPressTypes = [NSNumber(value: backPress.rawValue)] - view.addGestureRecognizer(backTapGesture) - - let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.userPanned(panGestureRecognizer:))) - view.addGestureRecognizer(panGestureRecognizer) - } - - @objc func backButtonPressed(tap: UITapGestureRecognizer) { - // Dismiss info panel - if showingInfoPanel { - if focusedOnTabBar { - toggleInfoContainer() - } - return - } - - // Cancel seek and move back to initial position - if seeking { - scrubLabel.isHidden = true - UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { - self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: 0, width: 2, height: 10) - }) - play() - seeking = false - } else { - // Dismiss view - self.resignFirstResponder() - mediaPlayer.stop() - sendStopReport() - self.navigationController?.popViewController(animated: true) - } - } - - @objc func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { - if loading { - return - } - - let translation = panGestureRecognizer.translation(in: view) - let velocity = panGestureRecognizer.velocity(in: view) - - // Swiped up - Handle dismissing info panel - if translation.y < -200 && (focusedOnTabBar && showingInfoPanel) { - toggleInfoContainer() - return - } - - if showingInfoPanel { - return - } - - // Swiped down - Show the info panel - if translation.y > 200 { - toggleInfoContainer() - return - } - - // Ignore seek if video is playing - if playing { - return - } - - // Save current position if seek is cancelled and show the scrubLabel - if !seeking { - initialSeekPos = self.scrubberView.frame.minX - seeking = true - self.scrubLabel.isHidden = false - } - - let newPos = (self.scrubberView.frame.minX + velocity.x/100).clamped(to: 0...transportBarView.frame.width) - - UIView.animate(withDuration: 0.8, delay: 0, options: .curveEaseOut, animations: { - let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width) - - self.scrubberView.frame = CGRect(x: newPos, y: self.scrubberView.frame.minY, width: 2, height: 30) - self.scrubLabel.frame = CGRect(x: (newPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height) - self.scrubLabel.text = (self.formatSecondsToHMS(time)) - - }) - - } - - /// Play/Pause or Select is pressed on the AppleTV remote - @objc func selectButtonTapped() { - print("select") - if loading { - return - } - - showingControls = true - controlsView.isHidden = false - controlsAppearTime = CACurrentMediaTime() - - // Move to seeked position - if seeking { - scrubLabel.isHidden = true - - // Move current time to the scrubbed position - UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { [self] in - - self.currentTimeLabel.frame = CGRect(x: CGFloat(scrubLabel.frame.minX + transportBarView.frame.minX), y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height) - - }) - - let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width) - - self.currentTimeLabel.text = self.scrubLabel.text - self.remainingTimeLabel.text = "-" + formatSecondsToHMS(videoDuration - time) - - mediaPlayer.position = Float(self.scrubberView.frame.minX) / Float(self.transportBarView.frame.width) - - play() - - seeking = false - return - } - - playing ? pause() : play() - } - - // MARK: Jellyfin Playstate updates - func sendProgressReport(eventName: String) { - updateNowPlayingCenter(time: nil, playing: mediaPlayer.state == .playing) - - if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" { - var ticks: Int64 = Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)) - if ticks == 0 { - ticks = manifest.userData?.playbackPositionTicks ?? 0 - } - - let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: (!playing), isMuted: false, positionTicks: ticks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback progress report sent!") - }) - .store(in: &cancellables) - } - } - - func sendStopReport() { - let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), liveStreamId: nil, playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", nowPlayingQueue: []) - - PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback stop report sent!") - }) - .store(in: &cancellables) - } - - func sendPlayReport() { - startTime = Int(Date().timeIntervalSince1970) * 10000000 - - print("sending play report!") - - let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback start report sent!") - }) - .store(in: &cancellables) - } - - // MARK: VLC Delegate - - func mediaPlayerStateChanged(_ aNotification: Notification!) { - let currentState: VLCMediaPlayerState = mediaPlayer.state - switch currentState { - case .buffering: - print("Video is buffering") - loading = true - activityIndicator.isHidden = false - activityIndicator.startAnimating() - mediaPlayer.pause() - usleep(10000) - mediaPlayer.play() - break - case .stopped: - print("stopped") - - break - case .ended: - print("ended") - - break - case .opening: - print("opening") - - break - case .paused: - print("paused") - - break - case .playing: - print("Video is playing") - loading = false - sendProgressReport(eventName: "unpause") - DispatchQueue.main.async { [self] in - activityIndicator.isHidden = true - activityIndicator.stopAnimating() - } - playing = true - break - case .error: - print("error") - break - case .esAdded: - print("esAdded") - break - default: - print("default") - break - - } - - } - - // Move time along transport bar - func mediaPlayerTimeChanged(_ aNotification: Notification!) { - - if loading { - loading = false - DispatchQueue.main.async { [self] in - activityIndicator.isHidden = true - activityIndicator.stopAnimating() - } - updateNowPlayingCenter(time: nil, playing: true) - } - - let time = mediaPlayer.position - if time != lastTime { - self.currentTimeLabel.text = formatSecondsToHMS(Double(mediaPlayer.time.intValue/1000)) - self.remainingTimeLabel.text = "-" + formatSecondsToHMS(Double(abs(mediaPlayer.remainingTime.intValue/1000))) - - self.videoPos = Double(mediaPlayer.position) - - let newPos = videoPos * Double(self.transportBarView.frame.width) - if !newPos.isNaN && self.playing { - self.scrubberView.frame = CGRect(x: newPos, y: 0, width: 2, height: 10) - self.currentTimeLabel.frame = CGRect(x: CGFloat(newPos) + transportBarView.frame.minX - currentTimeLabel.frame.width/2, y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height) - } - - if showingControls { - if CACurrentMediaTime() - controlsAppearTime > 5 { - showingControls = false - UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: { - self.controlsView.alpha = 0.0 - }, completion: { (_: Bool) in - self.controlsView.isHidden = true - self.controlsView.alpha = 1 - }) - controlsAppearTime = 999_999_999_999_999 - } - } - - } - - lastTime = time - - if CACurrentMediaTime() - lastProgressReportTime > 5 { - sendProgressReport(eventName: "timeupdate") - lastProgressReportTime = CACurrentMediaTime() - } - } - - // MARK: Settings Delegate - func selectNew(audioTrack id: Int32) { - selectedAudioTrack = id - mediaPlayer.currentAudioTrackIndex = id - } - - func selectNew(subtitleTrack id: Int32) { - selectedCaptionTrack = id - mediaPlayer.currentVideoSubTitleIndex = id - } - - func setupInfoPanel() { - infoTabBarViewController?.setupInfoViews(mediaItem: manifest, subtitleTracks: subtitleTrackArray, selectedSubtitleTrack: selectedCaptionTrack, audioTracks: audioTrackArray, selectedAudioTrack: selectedAudioTrack, delegate: self) - } - - func formatSecondsToHMS(_ seconds: Double) -> String { - let timeHMSFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .positional - formatter.allowedUnits = seconds >= 3600 ? - [.hour, .minute, .second] : - [.minute, .second] - formatter.zeroFormattingBehavior = .pad - return formatter - }() - - guard !seconds.isNaN, - let text = timeHMSFormatter.string(from: seconds) else { - return "00:00" - } - - return text.hasPrefix("0") && text.count > 4 ? - .init(text.dropFirst()) : text - } -} - -extension Comparable { - func clamped(to limits: ClosedRange) -> Self { - return min(max(self, limits.lowerBound), limits.upperBound) - } -} diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 76dc947c..09d8d485 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -14,15 +14,7 @@ 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; }; 09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; }; - 0959A5FD2686D29800C7C9A9 /* VideoUpNextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */; }; 363CADF08820D3B2055CF1D8 /* Pods_JellyfinPlayer_tvOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4BE2D324B040DCA2629C110D /* Pods_JellyfinPlayer_tvOS.framework */; }; - 531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */; }; - 531069582684E7EE00CFFDBA /* MediaInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069512684E7EE00CFFDBA /* MediaInfoView.swift */; }; - 531069592684E7EE00CFFDBA /* SubtitlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069522684E7EE00CFFDBA /* SubtitlesView.swift */; }; - 5310695A2684E7EE00CFFDBA /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069532684E7EE00CFFDBA /* VideoPlayer.swift */; }; - 5310695B2684E7EE00CFFDBA /* AudioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069542684E7EE00CFFDBA /* AudioView.swift */; }; - 5310695C2684E7EE00CFFDBA /* VideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */; }; - 5310695D2684E7EE00CFFDBA /* VideoPlayer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */; }; 53116A17268B919A003024C9 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53116A16268B919A003024C9 /* SeriesItemView.swift */; }; 53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53116A18268B947A003024C9 /* PlainLinkButton.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; @@ -39,8 +31,6 @@ 53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */; }; 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */; }; 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272538268C20100035FBF1 /* EpisodeItemView.swift */; }; - 532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */; }; - 53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */; }; 53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; }; 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; }; 534D4FF026A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; }; @@ -64,7 +54,6 @@ 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; }; 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* Typings.swift */; }; 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; }; - 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VideoPlayer.swift */; }; 53649AAD269CFAEA00A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AAC269CFAEA00A2D8B7 /* Puppy */; }; 53649AAF269CFAF600A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AAE269CFAF600A2D8B7 /* Puppy */; }; 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; }; @@ -150,7 +139,6 @@ 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; }; 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; }; 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* LibrarySearchView.swift */; }; - 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */; }; 53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */; }; 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */; }; 560CA59B3956A4CA13EDAC05 /* Pods_JellyfinPlayer_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 86BAC42C3764D232C8DF8F5E /* Pods_JellyfinPlayer_iOS.framework */; }; @@ -165,7 +153,7 @@ 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */; }; 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */; }; 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */; }; - 6220D0C626D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */; }; + 6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */; }; 6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 6220D0C826D63F3700B8E046 /* Stinsen */; }; 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */; }; 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; }; @@ -317,7 +305,6 @@ E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */; }; E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */; }; - E193D53E27193F9A00900D82 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */; }; E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D5422719407E00900D82 /* tvOSMainCoordinator.swift */; }; E193D547271941C500900D82 /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D546271941C500900D82 /* UserListView.swift */; }; E193D549271941CC00900D82 /* UserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D548271941CC00900D82 /* UserSignInView.swift */; }; @@ -340,6 +327,21 @@ E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */; }; E1B6DCE8271A23780015B715 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE7271A23780015B715 /* CombineExt */; }; E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE9271A23880015B715 /* SwiftyJSON */; }; + E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; + E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */; }; + E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */; }; + E1C812BF277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */; }; + E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */; }; + E1C812C1277A8E5D00918266 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */; }; + E1C812C3277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */; }; + E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; }; + E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */; }; + E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C7277AE40900918266 /* NativePlayerViewController.swift */; }; + E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C8277AE40900918266 /* VideoPlayerView.swift */; }; + E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; }; + E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; }; + E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */; }; + E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; }; E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */; }; E1D4BF7E2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */; }; E1D4BF7F2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */; }; @@ -402,18 +404,10 @@ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; 091B5A882683142E00D78B61 /* UDPBroadCastConnection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UDPBroadCastConnection.swift; sourceTree = ""; }; 09389CC626819B4500AE350E /* VideoPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerModel.swift; sourceTree = ""; }; - 0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUpNextView.swift; sourceTree = ""; }; 14E199C7BBA98782CAD2F0D4 /* Pods-JellyfinPlayer iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer iOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS.release.xcconfig"; sourceTree = ""; }; 20CA36DDD247EED8D16438A5 /* Pods-JellyfinPlayer tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.release.xcconfig"; sourceTree = ""; }; 4BDCEE3B49CF70A9E9BA3CD8 /* Pods-WidgetExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetExtension.debug.xcconfig"; path = "Target Support Files/Pods-WidgetExtension/Pods-WidgetExtension.debug.xcconfig"; sourceTree = ""; }; 4BE2D324B040DCA2629C110D /* Pods_JellyfinPlayer_tvOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JellyfinPlayer_tvOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InfoTabBarViewController.swift; sourceTree = ""; }; - 531069512684E7EE00CFFDBA /* MediaInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaInfoView.swift; sourceTree = ""; }; - 531069522684E7EE00CFFDBA /* SubtitlesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubtitlesView.swift; sourceTree = ""; }; - 531069532684E7EE00CFFDBA /* VideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; - 531069542684E7EE00CFFDBA /* AudioView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioView.swift; sourceTree = ""; }; - 531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewController.swift; sourceTree = ""; }; - 531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = ""; }; 53116A16268B919A003024C9 /* SeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = ""; }; 53116A18268B947A003024C9 /* PlainLinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainLinkButton.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; @@ -427,8 +421,6 @@ 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewActionButton.swift; sourceTree = ""; }; 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemView.swift; sourceTree = ""; }; 53272538268C20100035FBF1 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = ""; }; - 532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCastDeviceSelector.swift; sourceTree = ""; }; - 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = ""; }; 5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; 534D4FE826A7D7CC000A7A48 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = ""; }; 534D4FEC26A7D7CC000A7A48 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = ""; }; @@ -440,7 +432,6 @@ 535870702669D21700D05A09 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 535870AC2669D8DD00D05A09 /* Typings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typings.swift; sourceTree = ""; }; 535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; - 535BAEA4264A151C005FA86D /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; 5362E4A7267D4067000E2F71 /* GoogleCast.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleCast.framework; path = "../../Downloads/GoogleCastSDK-ios-4.6.0_dynamic/GoogleCast.framework"; sourceTree = ""; }; 5362E4AA267D40AD000E2F71 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; }; 5362E4AC267D40B1000E2F71 /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; @@ -501,7 +492,6 @@ 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = ""; }; 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectorView.swift; sourceTree = ""; }; 53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; - 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsView.swift; sourceTree = ""; }; 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemView.swift; sourceTree = ""; }; 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = ""; }; 59AFF849629F3C787909A911 /* Pods_WidgetExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WidgetExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -514,7 +504,7 @@ 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCoordinator.swift; sourceTree = ""; }; 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterCoordinator.swift; sourceTree = ""; }; 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCoordinator.swift; sourceTree = ""; }; - 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCoordinator.swift; sourceTree = ""; }; + 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSVideoPlayerCoordinator.swift; sourceTree = ""; }; 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLHandler.swift; sourceTree = ""; }; 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeader.swift; sourceTree = ""; }; 624C21742685CF60007F1390 /* SearchablePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchablePickerView.swift; sourceTree = ""; }; @@ -610,6 +600,19 @@ E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillHStackView.swift; sourceTree = ""; }; E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGUIDPairExtensions.swift; sourceTree = ""; }; E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitHeaderOverlayView.swift; sourceTree = ""; }; + E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = ""; }; + E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = ""; }; + E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = ""; }; + E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerOverlayView.swift; sourceTree = ""; }; + E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; + E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; + E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerCompactOverlayView.swift; sourceTree = ""; }; + E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsExtensions.swift; sourceTree = ""; }; + E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = ""; }; + E1C812C7277AE40900918266 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; + E1C812C8277AE40900918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; + E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; + E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVideoPlayerCoordinator.swift; sourceTree = ""; }; E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsViewModel.swift; sourceTree = ""; }; E1D4BF802719D22800A11E64 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = ""; }; @@ -695,13 +698,9 @@ 5310694F2684E7EE00CFFDBA /* VideoPlayer */ = { isa = PBXGroup; children = ( - 531069512684E7EE00CFFDBA /* MediaInfoView.swift */, - 531069522684E7EE00CFFDBA /* SubtitlesView.swift */, - 531069542684E7EE00CFFDBA /* AudioView.swift */, - 531069502684E7EE00CFFDBA /* InfoTabBarViewController.swift */, - 531069532684E7EE00CFFDBA /* VideoPlayer.swift */, - 531069552684E7EE00CFFDBA /* VideoPlayerViewController.swift */, - 531069562684E7EE00CFFDBA /* VideoPlayer.storyboard */, + E1C812C7277AE40900918266 /* NativePlayerViewController.swift */, + E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */, + E1C812C8277AE40900918266 /* VideoPlayerView.swift */, ); path = VideoPlayer; sourceTree = ""; @@ -720,17 +719,18 @@ 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */, 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */, 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */, - C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */, - C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */, 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */, + C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */, 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */, 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */, E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, + C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */, E13DD3F82717E961009D4DAF /* UserListViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, 09389CC626819B4500AE350E /* VideoPlayerModel.swift */, + E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, ); path = ViewModels; @@ -1073,6 +1073,7 @@ E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */, 621338922660107500A81A2A /* StringExtensions.swift */, E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */, + E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */, 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */, ); path = Extensions; @@ -1116,7 +1117,7 @@ C4BE0762271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift */, E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */, E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */, - 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */, + E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */, ); path = Coordinators; sourceTree = ""; @@ -1284,11 +1285,13 @@ E193D5452719418B00900D82 /* VideoPlayer */ = { isa = PBXGroup; children = ( - 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */, - 535BAEA4264A151C005FA86D /* VideoPlayer.swift */, - 532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */, - 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */, - 0959A5FC2686D29800C7C9A9 /* VideoUpNextView.swift */, + E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */, + E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, + E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, + E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */, + E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */, + E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */, + E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */, ); path = VideoPlayer; sourceTree = ""; @@ -1331,6 +1334,15 @@ path = Views; sourceTree = ""; }; + E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = { + isa = PBXGroup; + children = ( + 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */, + E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */, + ); + path = VideoPlayerCoordinator; + sourceTree = ""; + }; E1DD1127271E7D15005BE12F /* Objects */ = { isa = PBXGroup; children = ( @@ -1531,7 +1543,6 @@ 53913C0226D323FE00EB3286 /* Localizable.strings in Resources */, 53913C1426D323FE00EB3286 /* Localizable.strings in Resources */, 53913BF926D323FE00EB3286 /* Localizable.strings in Resources */, - 5310695D2684E7EE00CFFDBA /* VideoPlayer.storyboard in Resources */, 534D4FF726A7D7CC000A7A48 /* Localizable.strings in Resources */, 53913BF326D323FE00EB3286 /* Localizable.strings in Resources */, 53913BF626D323FE00EB3286 /* Localizable.strings in Resources */, @@ -1562,7 +1573,6 @@ 534D4FF626A7D7CC000A7A48 /* Localizable.strings in Resources */, 53913BF226D323FE00EB3286 /* Localizable.strings in Resources */, 53913BF526D323FE00EB3286 /* Localizable.strings in Resources */, - 53313B90265EEA6D00947AA3 /* VideoPlayer.storyboard in Resources */, 53913C0426D323FE00EB3286 /* Localizable.strings in Resources */, 53913BFE26D323FE00EB3286 /* Localizable.strings in Resources */, 53913C0D26D323FE00EB3286 /* Localizable.strings in Resources */, @@ -1762,13 +1772,11 @@ buildActionMask = 2147483647; files = ( E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, - 531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, 6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */, C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */, - 531069592684E7EE00CFFDBA /* SubtitlesView.swift in Sources */, C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */, 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */, 53116A17268B919A003024C9 /* SeriesItemView.swift in Sources */, @@ -1776,15 +1784,16 @@ 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, + E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */, E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, 62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */, E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, 53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */, - E193D53E27193F9A00900D82 /* VideoPlayerCoordinator.swift in Sources */, 536D3D88267C17350004248C /* PublicUserButton.swift in Sources */, 62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, + E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */, 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */, 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */, @@ -1804,7 +1813,6 @@ 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 5398514526B64DA100101B49 /* SettingsView.swift in Sources */, 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, - 5310695A2684E7EE00CFFDBA /* VideoPlayer.swift in Sources */, E193D54B271941D300900D82 /* ServerListView.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, @@ -1823,13 +1831,13 @@ E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */, E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, + E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, 535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */, E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, 62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, 536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */, 62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */, - 5310695B2684E7EE00CFFDBA /* AudioView.swift in Sources */, 5398514726B64E4100101B49 /* SearchBarView.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */, @@ -1844,24 +1852,25 @@ 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */, 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, E19169CF272514760085832A /* HTTPScheme.swift in Sources */, + E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */, C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */, - 531069582684E7EE00CFFDBA /* MediaInfoView.swift in Sources */, E1D4BF822719D22800A11E64 /* AppAppearance.swift in Sources */, C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */, 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */, 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */, + E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */, E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */, E13DD3C927164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, 535870A62669D8AE00D05A09 /* LazyView.swift in Sources */, E193D53A27193F9000900D82 /* ServerListCoordinator.swift in Sources */, 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, 5321753E2671DE9C005491E6 /* Typings.swift in Sources */, + E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */, E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, 6264E88D273850380081A12A /* Strings.swift in Sources */, 536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */, E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, - 5310695C2684E7EE00CFFDBA /* VideoPlayerViewController.swift in Sources */, C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, @@ -1896,6 +1905,7 @@ E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */, 621338932660107500A81A2A /* StringExtensions.swift in Sources */, 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, + E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */, @@ -1906,6 +1916,7 @@ E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */, + E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, C40CD925271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, @@ -1918,36 +1929,37 @@ E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, E188460426DEF04800B0C5B7 /* EpisodeCardVStackView.swift in Sources */, - 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */, E19169CE272514760085832A /* HTTPScheme.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */, 62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */, - 0959A5FD2686D29800C7C9A9 /* VideoUpNextView.swift in Sources */, 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */, E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, 625CB56F2678C23300530A6E /* HomeView.swift in Sources */, + E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */, 53892770263C25230035E14B /* NextUpView.swift in Sources */, E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */, C4BE0766271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, - 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, - 532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */, E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, C4BE0763271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */, 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, + E1C812BF277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */, + E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */, + E1C812C3277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift in Sources */, E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */, 091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */, 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */, + E1C812C1277A8E5D00918266 /* NativePlayerViewController.swift in Sources */, E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, @@ -1960,14 +1972,16 @@ E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */, E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */, - 6220D0C626D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */, + 6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, + E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */, 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, + E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */, E13DD3BD27163C63009D4DAF /* EmailHelper.swift in Sources */, E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */, E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */, @@ -2379,7 +2393,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2391,7 +2405,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; @@ -2416,7 +2430,7 @@ CURRENT_PROJECT_VERSION = 66; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2428,7 +2442,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; @@ -2447,7 +2461,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2456,7 +2470,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin.widget; + PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -2474,7 +2488,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2483,7 +2497,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin.widget; + PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/JellyfinPlayer/App/AppDelegate.swift b/JellyfinPlayer/App/AppDelegate.swift index 21701dd2..6f120c94 100644 --- a/JellyfinPlayer/App/AppDelegate.swift +++ b/JellyfinPlayer/App/AppDelegate.swift @@ -7,6 +7,7 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import AVFAudio import SwiftUI import UIKit @@ -17,6 +18,13 @@ class AppDelegate: NSObject, UIApplicationDelegate { // Lazily initialize datastack _ = SwiftfinStore.dataStack + + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playback) + } catch { + print("setting category AVAudioSessionCategoryPlayback failed") + } return true } diff --git a/JellyfinPlayer/Views/ItemView/ItemView.swift b/JellyfinPlayer/Views/ItemView/ItemView.swift index 95d37413..e95dbc72 100644 --- a/JellyfinPlayer/Views/ItemView/ItemView.swift +++ b/JellyfinPlayer/Views/ItemView/ItemView.swift @@ -9,11 +9,6 @@ import Introspect import JellyfinAPI import SwiftUI -class VideoPlayerItem: ObservableObject { - @Published var shouldShowPlayer: Bool = false - @Published var itemToPlay = BaseItemDto() -} - // Intermediary view for ItemView to set navigation bar settings struct ItemNavigationView: View { private let item: BaseItemDto @@ -31,10 +26,7 @@ struct ItemNavigationView: View { private struct ItemView: View { @EnvironmentObject var itemRouter: ItemCoordinator.Router - @State private var videoIsLoading: Bool = false // This variable is only changed by the underlying VLC view. - @State private var viewDidLoad: Bool = false @State private var orientation: UIDeviceOrientation = .unknown - @StateObject private var videoPlayerItem = VideoPlayerItem() @Environment(\.horizontalSizeClass) private var hSizeClass @Environment(\.verticalSizeClass) private var vSizeClass @@ -91,19 +83,13 @@ private struct ItemView: View { var body: some View { Group { if hSizeClass == .compact && vSizeClass == .regular { - ItemPortraitMainView(videoIsLoading: $videoIsLoading) - .environmentObject(videoPlayerItem) + ItemPortraitMainView() .environmentObject(viewModel) } else { - ItemLandscapeMainView(videoIsLoading: $videoIsLoading) - .environmentObject(videoPlayerItem) + ItemLandscapeMainView() .environmentObject(viewModel) } } - .onReceive(videoPlayerItem.$shouldShowPlayer) { flag in - guard flag else { return } - self.itemRouter.route(to: \.videoPlayer, viewModel.item) - } .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { toolbarItemContent diff --git a/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift b/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift index 49d3a83e..4cfc520c 100644 --- a/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift @@ -12,13 +12,7 @@ import SwiftUI struct ItemLandscapeMainView: View { @EnvironmentObject var itemRouter: ItemCoordinator.Router - @Binding private var videoIsLoading: Bool @EnvironmentObject private var viewModel: ItemViewModel - @EnvironmentObject private var videoPlayerItem: VideoPlayerItem - - init(videoIsLoading: Binding) { - self._videoIsLoading = videoIsLoading - } // MARK: innerBody @@ -34,14 +28,10 @@ struct ItemLandscapeMainView: View { Spacer().frame(height: 15) + // MARK: Play Button { - if let playButtonItem = viewModel.playButtonItem { - self.videoPlayerItem.itemToPlay = playButtonItem - self.videoPlayerItem.shouldShowPlayer = true - } + self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) } label: { - // MARK: Play - HStack { Image(systemName: "play.fill") .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) diff --git a/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift b/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift index 99a069d5..98053a5a 100644 --- a/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift +++ b/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift @@ -12,8 +12,8 @@ import JellyfinAPI struct PortraitHeaderOverlayView: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router @EnvironmentObject private var viewModel: ItemViewModel - @EnvironmentObject private var videoPlayerItem: VideoPlayerItem var body: some View { VStack(alignment: .leading) { @@ -75,10 +75,7 @@ struct PortraitHeaderOverlayView: View { // MARK: Play Button { - if let playButtonItem = viewModel.playButtonItem { - self.videoPlayerItem.itemToPlay = playButtonItem - self.videoPlayerItem.shouldShowPlayer = true - } + self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) } label: { HStack { Image(systemName: "play.fill") diff --git a/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitMainView.swift b/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitMainView.swift index 259af534..6e588c02 100644 --- a/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitMainView.swift +++ b/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitMainView.swift @@ -11,14 +11,9 @@ import JellyfinAPI import SwiftUI struct ItemPortraitMainView: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router - @Binding private var videoIsLoading: Bool @EnvironmentObject private var viewModel: ItemViewModel - @EnvironmentObject private var videoPlayerItem: VideoPlayerItem - - init(videoIsLoading: Binding) { - self._videoIsLoading = videoIsLoading - } // MARK: portraitHeaderView diff --git a/JellyfinPlayer/Views/SettingsView.swift b/JellyfinPlayer/Views/SettingsView.swift index ef21dc0f..f5f405d4 100644 --- a/JellyfinPlayer/Views/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView.swift @@ -23,6 +23,7 @@ struct SettingsView: View { @Default(.appAppearance) var appAppearance @Default(.videoPlayerJumpForward) var jumpForwardLength @Default(.videoPlayerJumpBackward) var jumpBackwardLength + @Default(.nativeVideoPlayer) var nativeVideoPlayer var body: some View { Form { @@ -83,6 +84,7 @@ struct SettingsView: View { } Section(header: Text("Playback")) { + Toggle("Native Player", isOn: $nativeVideoPlayer) Picker("Default local quality", selection: $inNetworkStreamBitrate) { ForEach(self.viewModel.bitrates, id: \.self) { bitrate in Text(bitrate.name).tag(bitrate.value) diff --git a/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift new file mode 100644 index 00000000..9692626d --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift @@ -0,0 +1,121 @@ +// +// NativePlayerViewController.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/20/21. +// + +import AVKit +import Combine +import JellyfinAPI +import UIKit + +class NativePlayerViewController: AVPlayerViewController { + + let viewModel: VideoPlayerViewModel + + var timeObserverToken: Any? + + var lastProgressTicks: Int64 = 0 + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + + let player = AVPlayer(url: viewModel.hlsURL) + + player.appliesMediaSelectionCriteriaAutomatically = false + player.currentItem?.externalMetadata = createMetadata() + + let chevron = UIImage(systemName: "chevron.right.circle.fill")! + let testAction = UIAction(title: "Next", image: chevron) { action in + print("next item selected") + } + + // tvos +// self.transportBarCustomMenuItems = [testAction] + + let timeScale = CMTimeScale(NSEC_PER_SEC) + let time = CMTime(seconds: 5, preferredTimescale: timeScale) + + timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in +// print("Timer timed: \(time)") + + if time.seconds != 0 { + self?.sendProgressReport(seconds: time.seconds) + } + } + + self.player = player + + self.allowsPictureInPicturePlayback = true + self.player?.allowsExternalPlayback = true + } + + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any] = [ + .commonIdentifierTitle: viewModel.title, + .iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "", + .commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any, + .commonIdentifierDescription: viewModel.item.overview ?? "" + ] + + return allMetadata.compactMap { createMetadataItem(for:$0, value:$1) } + } + + private func createMetadataItem(for identifier: AVMetadataIdentifier, + value: Any) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stop() + removePeriodicTimeObserver() + } + + func removePeriodicTimeObserver() { + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek(to: CMTimeMake(value: viewModel.item.userData?.playbackPositionTicks ?? 0, timescale: 10_000_000), toleranceBefore: CMTimeMake(value: 5, timescale: 1), toleranceAfter: CMTimeMake(value: 5, timescale: 1), completionHandler: { _ in + self.play() + }) + } + + private func play() { + player?.play() + + viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0) + } + + private func sendProgressReport(seconds: Double) { + viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000) + } + + private func stop() { + viewModel.sendStopReport(ticks: 10_000_000) + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift b/JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift new file mode 100644 index 00000000..78b410f1 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift @@ -0,0 +1,40 @@ +// +// PlaybackSpeed.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 12/27/21. +// + +import Foundation + +enum PlaybackSpeed: Double, CaseIterable { + case quarter = 0.25 + case half = 0.5 + case threeQuarter = 0.75 + case one = 1.0 + case oneQuarter = 1.25 + case oneHalf = 1.5 + case oneThreeQuarter = 1.75 + case two = 2.0 + + var displayTitle: String { + switch self { + case .quarter: + return "0.25x" + case .half: + return "0.5x" + case .threeQuarter: + return "0.75x" + case .one: + return "1x" + case .oneQuarter: + return "1.25x" + case .oneHalf: + return "1.5x" + case .oneThreeQuarter: + return "1.75x" + case .two: + return "2x" + } + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift new file mode 100644 index 00000000..338ddd85 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -0,0 +1,30 @@ +// +// PlayerOverlayDelegate.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 12/27/21. +// + +import Foundation + +protocol PlayerOverlayDelegate { + + func didSelectClose() + func didSelectGoogleCast() + func didSelectAirplay() + func didSelectCaptions() + func didSelectMenu() + func didDeselectMenu() + + func didSelectBackward() + func didSelectForward() + func didSelectMain() + + func didGenerallyTap() + + func didBeginScrubbing() + func didEndScrubbing(position: Double) + + func didSelectAudioStream(index: Int) + func didSelectSubtitleStream(index: Int) +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift new file mode 100644 index 00000000..67dceb15 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift @@ -0,0 +1,271 @@ +// +// VLCPlayerCompactOverlayView.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 12/26/21. +// + +import Combine +import MobileVLCKit +import SwiftUI +import JellyfinAPI + +struct VLCPlayerCompactOverlayView: View { + + @ObservedObject var viewModel: VideoPlayerViewModel + + @ViewBuilder + private var mainButtonView: some View { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.fill") + .font(.system(size: 28, weight: .heavy, design: .default)) + case .playing: + Image(systemName: "pause") + .font(.system(size: 28, weight: .heavy, design: .default)) + default: + ProgressView() + } + } + + @ViewBuilder + private var mainBody: some View { + VStack { + + VStack(alignment: .EpisodeSeriesAlignmentGuide) { + + // MARK: Top Bar + HStack(alignment: .top) { + + VStack(alignment: .leading) { + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectClose() + } label: { + Image(systemName: "chevron.left.circle.fill") + .font(.system(size: 28, weight: .regular, design: .default)) + } + + Text(viewModel.title) + .font(.system(size: 28, weight: .regular, design: .default)) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + } + } + + Spacer() + + HStack(spacing: 20) { + + if viewModel.shouldShowGoogleCast { + Button { + viewModel.playerOverlayDelegate?.didSelectGoogleCast() + } label: { + Image(systemName: "rectangle.badge.plus") + } + } + + if viewModel.shouldShowAirplay { + Button { + viewModel.playerOverlayDelegate?.didSelectAirplay() + } label: { + Image(systemName: "airplayvideo") + } + } + + Button { + viewModel.screenFilled = !viewModel.screenFilled + } label: { + if viewModel.screenFilled { + Image(systemName: "rectangle.arrowtriangle.2.inward") + .rotationEffect(Angle(degrees: 90)) + } else { + Image(systemName: "rectangle.arrowtriangle.2.outward") + .rotationEffect(Angle(degrees: 90)) + } + } + + Button { + viewModel.playerOverlayDelegate?.didSelectCaptions() + } label: { + if viewModel.captionsEnabled { + Image(systemName: "captions.bubble.fill") + } else { + Image(systemName: "captions.bubble") + } + } + + // MARK: Settings Menu + Menu { + + Menu { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "speaker.wave.3") + Text("Audio") + } + } + + Menu { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "captions.bubble") + Text("Subtitles") + } + } + + Menu { + ForEach(PlaybackSpeed.allCases, id: \.self) { speed in + Button { + viewModel.playbackSpeed = speed + } label: { + if speed == viewModel.playbackSpeed { + Label(speed.displayTitle, systemImage: "checkmark") + } else { + Text(speed.displayTitle) + } + } + } + } label: { + HStack { + Image(systemName: "speedometer") + Text("Playback Speed") + } + } + + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .font(.system(size: 24)) + + if let seriesTitle = viewModel.subtitle { + Text(seriesTitle) + .font(.subheadline) + .foregroundColor(Color.gray) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + .offset(y: -10) + } + } + + Spacer() + + // MARK: Bottom Bar + HStack { + + HStack(spacing: 20) { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: "gobackward.10") + } + + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + } + + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: "goforward.10") + } + } + .font(.system(size: 24, weight: .semibold, design: .default)) + .padding(.trailing, 20) + + Text(viewModel.leftLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + + Slider(value: $viewModel.sliderPercentage) { editing in + viewModel.sliderIsScrubbing = editing + } + .foregroundColor(.purple) + .tint(.purple) + +// ValueSlider(value: $viewModel.sliderPercentage) +// .valueSliderStyle( +// HorizontalValueSliderStyle(thumb: Circle().foregroundColor(.purple), +// thumbSize: CGSize(width: 32, height: 32), +// thumbInteractiveSize: CGSize(width: 50, height: 50), +// options: [.interactiveTrack]) +// ) + + Text(viewModel.rightLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + } + .frame(height: 50) + } + .padding(.top) + .padding(.horizontal) + .ignoresSafeArea(edges: .top) + .tint(Color.white) + .foregroundColor(Color.white) + } + + var body: some View { + mainBody + .background { + Color(uiColor: .black.withAlphaComponent(0.001)) + .ignoresSafeArea() + .onTapGesture { + viewModel.playerOverlayDelegate?.didGenerallyTap() + } + } + } +} + +struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.gray + .ignoresSafeArea() + + VLCPlayerCompactOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 123 * 10_000_000), + 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)], + defaultAudioStreamIndex: -1, + defaultSubtitleStreamIndex: -1, + playerState: .playing, + shouldShowGoogleCast: false, + shouldShowAirplay: false, + subtitlesEnabled: true, + sliderPercentage: 0.5, + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1)) + } + .previewInterfaceOrientation(.landscapeLeft) + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift new file mode 100644 index 00000000..cacb1a3e --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift @@ -0,0 +1,258 @@ +// +// VLCPlayerOverlayView.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/24/21. +// + +import Combine +import MobileVLCKit +import SwiftUI +import JellyfinAPI + + + +struct VLCPlayerOverlayView: View { + + @ObservedObject var viewModel: VideoPlayerViewModel + + @ViewBuilder + private var mainButtonView: some View { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play") + .font(.system(size: 56)) + case .playing: + Image(systemName: "pause") + .font(.system(size: 56)) + default: + ProgressView() + } + } + + @ViewBuilder + private var mainBody: some View { + VStack { + + VStack(alignment: .EpisodeSeriesAlignmentGuide) { + + // MARK: Top Bar + HStack(alignment: .top) { + + VStack(alignment: .leading) { + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectClose() + } label: { + Image(systemName: "chevron.backward") + } + + Text(viewModel.title) + .font(.system(size: 28, weight: .regular, design: .default)) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + } + } + + Spacer() + + HStack(spacing: 20) { + + if viewModel.shouldShowGoogleCast { + Button { + viewModel.playerOverlayDelegate?.didSelectGoogleCast() + } label: { + Image(systemName: "rectangle.badge.plus") + } + } + + if viewModel.shouldShowAirplay { + Button { + viewModel.playerOverlayDelegate?.didSelectAirplay() + } label: { + Image(systemName: "airplayvideo") + } + } + + Button { + viewModel.playerOverlayDelegate?.didSelectCaptions() + } label: { + if viewModel.captionsEnabled { + Image(systemName: "captions.bubble.fill") + } else { + Image(systemName: "captions.bubble") + } + } + + // MARK: Settings Menu + Menu { + + Menu { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "speaker.wave.3") + Text("Audio") + } + } + + Menu { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "captions.bubble") + Text("Subtitles") + } + } + + Menu { + Button { + print("third pressed") + } label: { + Text("TODO") + } + } label: { + HStack { + Image(systemName: "speedometer") + Text("Playback Speed") + } + } + + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .font(.system(size: 24)) + + if let seriesTitle = viewModel.subtitle { + Text(seriesTitle) + .font(.subheadline) + .foregroundColor(Color.gray) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + .offset(y: -10) + } + } + + Spacer() + + // MARK: Center Buttons + HStack(spacing: 80) { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: "gobackward.10") + } + + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + } + + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: "goforward.10") + } + } + .font(.system(size: 48)) + + Spacer() + + // MARK: Bottom Bar + HStack { + Text(viewModel.leftLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + + Slider(value: $viewModel.sliderPercentage) { editing in + viewModel.sliderIsScrubbing = editing + } + .foregroundColor(.purple) + .tint(.purple) + + Text(viewModel.rightLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + } + .frame(height: 50) + } + .padding(.top) + .ignoresSafeArea(edges: .vertical) + .tint(Color.white) + .foregroundColor(Color.white) + } + + var body: some View { + mainBody + .background { + Color(uiColor: .black.withAlphaComponent(0.2)) + .ignoresSafeArea() + .onTapGesture { + viewModel.playerOverlayDelegate?.didGenerallyTap() + } + } + } +} + +struct VLCPlayerOverlayView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.gray + .ignoresSafeArea() + + VLCPlayerOverlayView(viewModel: 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)], + defaultAudioStreamIndex: -1, + defaultSubtitleStreamIndex: -1, + playerState: .playing, + shouldShowGoogleCast: false, + shouldShowAirplay: false, + subtitlesEnabled: true, + sliderPercentage: 0.0, + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1)) + } + .previewInterfaceOrientation(.landscapeLeft) + } +} + +extension HorizontalAlignment { + + private struct EpisodeSeriesTitleAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[HorizontalAlignment.leading] + } + } + + static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(EpisodeSeriesTitleAlignment.self) + +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift new file mode 100644 index 00000000..399a937f --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -0,0 +1,445 @@ +// +// PlayerViewController.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/12/21. +// + +import AVKit +import AVFoundation +import Combine +import JellyfinAPI +import MediaPlayer +import MobileVLCKit +import SwiftUI +import UIKit + +class VLCPlayerViewController: UIViewController { + + // MARK: variables + + private let viewModel: VideoPlayerViewModel + private var vlcMediaPlayer = VLCMediaPlayer() + private var lastPlayerTicks: Int64 + private var cancellables = Set() + private var overlayDismissTimer: Timer? + + private var currentPlayerTicks: Int64 { + return Int64(vlcMediaPlayer.time.intValue) * 100_000 + } + + private var displayingOverlay: Bool { + return overlayHostingController.view.alpha > 0 + } + + private lazy var videoContentView = makeVideoContentView() + private lazy var tapGestureView = makeTapGestureView() + private lazy var overlayHostingController = makeOverlayHostingController() + + // MARK: init + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + + self.lastPlayerTicks = viewModel.item.userData?.playbackPositionTicks ?? 0 + + super.init(nibName: nil, bundle: nil) + + viewModel.playerOverlayDelegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupSubviews() { + view.addSubview(videoContentView) + view.addSubview(tapGestureView) + + addChild(overlayHostingController) + overlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + overlayHostingController.view.backgroundColor = UIColor.black.withAlphaComponent(0.2) + view.addSubview(overlayHostingController.view) + overlayHostingController.didMove(toParent: self) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + videoContentView.topAnchor.constraint(equalTo: view.topAnchor), + videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), + videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor) + ]) + NSLayoutConstraint.activate([ + tapGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), + tapGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + tapGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) + ]) + NSLayoutConstraint.activate([ + overlayHostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + overlayHostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + overlayHostingController.view.leftAnchor.constraint(equalTo: view.leftAnchor), + overlayHostingController.view.rightAnchor.constraint(equalTo: view.rightAnchor) + ]) + } + + // MARK: viewWillAppear + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + +// AppUtility.lockOrientation(.all, andRotateTo: .landscapeLeft) + } + + // MARK: viewWillDisappear + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + +// AppUtility.lockOrientation(.all) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + setupSubviews() + setupConstraints() + + setupViewModelListeners() + + view.backgroundColor = .black + + setupMediaPlayer() + } + + // MARK: setupViewModelListeners + + private func setupViewModelListeners() { + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &cancellables) + + viewModel.$screenFilled.sink { shouldFill in + self.changeFill(to: shouldFill) + }.store(in: &cancellables) + + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if !sliderIsScrubbing { + self.didEndScrubbing(position: self.viewModel.sliderPercentage) + } + }.store(in: &cancellables) + + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &cancellables) + + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &cancellables) + } + + private func changeFill(to shouldFill: Bool) { + if shouldFill { + // TODO: May not be possible with current VLCKit + +// let drawableView = vlcMediaPlayer.drawable as! UIView +// let drawableViewSize = drawableView.frame.size +// let mediaSize = vlcMediaPlayer.videoSize + + // Largest size from mediaSize is how it is currently filled + // in the drawable view, find scaleFactor by filling entire + // drawableView + + vlcMediaPlayer.scaleFactor = 1.5 + } else { + vlcMediaPlayer.scaleFactor = 0 + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + restartOverlayDismissTimer() + } + + // MARK: subviews + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + private func makeTapGestureView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + + let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) + rightSwipeGesture.direction = .right + let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) + leftSwipeGesture.direction = .left + + view.addGestureRecognizer(singleTapGesture) + view.addGestureRecognizer(rightSwipeGesture) + view.addGestureRecognizer(leftSwipeGesture) + + return view + } + + @objc private func didTap() { + self.didGenerallyTap() + } + + @objc private func didRightSwipe() { + self.didSelectForward() + } + + @objc private func didLeftSwipe() { + self.didSelectBackward() + } + + private func makeOverlayHostingController() -> UIHostingController { + let overlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) + return UIHostingController(rootView: overlayView) + } +} + +// MARK: setupMediaPlayer +extension VLCPlayerViewController { + + func setupMediaPlayer() { + + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView + vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) + + let media = VLCMedia(url: viewModel.streamURL) + media.addOption("--prefetch-buffer-size=1048576") + media.addOption("--network-caching=5000") + + vlcMediaPlayer.media = media + } + + func startPlayback() { + vlcMediaPlayer.play() + + viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0) + + // 1 second = 10,000,000 ticks + let startTicks: Int64 = viewModel.item.userData?.playbackPositionTicks ?? 0 + + if startTicks != 0 { + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let secondsScrubbedTo = startTicks / 10_000_000 + let offset = secondsScrubbedTo - Int64(videoPosition) + if offset > 0 { + vlcMediaPlayer.jumpForward(Int32(offset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(offset))) + } + } + } +} + +// MARK: Show/Hide Overlay +extension VLCPlayerViewController { + + private func showOverlay() { + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + self.overlayHostingController.view.alpha = 1 + } + } + + private func hideOverlay() { + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + self.overlayHostingController.view.alpha = 0 + } + } + + private func toggleOverlay() { + if overlayHostingController.view.alpha < 1 { + showOverlay() + } else { + hideOverlay() + } + } +} + +// MARK: OverlayTimer +extension VLCPlayerViewController { + + private func restartOverlayDismissTimer(interval: Double = 2) { + self.overlayDismissTimer?.invalidate() + self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false) + } + + @objc private func dismissTimerFired() { + print("Dismiss timer fired") + self.hideOverlay() + } + + private func stopOverlayDismissTimer() { + self.overlayDismissTimer?.invalidate() + } +} + +// MARK: VLCMediaPlayerDelegate +extension VLCPlayerViewController: VLCMediaPlayerDelegate { + + func mediaPlayerStateChanged(_ aNotification: Notification!) { + + self.viewModel.playerState = vlcMediaPlayer.state + + print("Player state changed: \(viewModel.playerState.rawValue)") + } + + func mediaPlayerTimeChanged(_ aNotification: Notification!) { + + guard !viewModel.sliderIsScrubbing else { + lastPlayerTicks = currentPlayerTicks + return + } + + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + + if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { + + viewModel.playerState = VLCMediaPlayerState.playing + } + + lastPlayerTicks = currentPlayerTicks + +// if CACurrentMediaTime() - lastProgressReportTime > 5 { +// mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack +// sendProgressReport(eventName: "timeupdate") +// lastProgressReportTime = CACurrentMediaTime() +// } + } +} + +// MARK: PlayerOverlayDelegate +extension VLCPlayerViewController: PlayerOverlayDelegate { + + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + print("New audio index: \(index)") + } + + func didSelectSubtitleStream(index: Int) { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + + if index != -1 { + // set in case weren't shown + viewModel.captionsEnabled = true + } + print("New subtitle index: \(index)") + } + + func didSelectClose() { + vlcMediaPlayer.stop() + + viewModel.sendStopReport(ticks: currentPlayerTicks) + + dismiss(animated: true, completion: nil) + } + + func didSelectGoogleCast() { + print("didSelectCast") + } + + func didSelectAirplay() { + print("didSelectAirplay") + } + + func didSelectCaptions() { + + viewModel.captionsEnabled = !viewModel.captionsEnabled + + if viewModel.captionsEnabled { + vlcMediaPlayer.currentVideoSubTitleIndex = vlcMediaPlayer.videoSubTitlesIndexes[1] as! Int32 + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } + + // TODO: Implement properly in overlays + func didSelectMenu() { + stopOverlayDismissTimer() + } + + // TODO: Implement properly in overlays + func didDeselectMenu() { + restartOverlayDismissTimer() + } + + func didSelectBackward() { + vlcMediaPlayer.jumpBackward(10) + + restartOverlayDismissTimer() + } + + func didSelectForward() { + vlcMediaPlayer.jumpForward(10) + + restartOverlayDismissTimer() + } + + func didSelectMain() { + + switch viewModel.playerState { + case .stopped: () + case .opening: () + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .ended: () + case .error: () + case .playing: + vlcMediaPlayer.pause() + restartOverlayDismissTimer(interval: 5) + case .paused: + vlcMediaPlayer.play() + case .esAdded: () + + default: () + } + } + + func didGenerallyTap() { + toggleOverlay() + + restartOverlayDismissTimer(interval: 5) + } + + func didBeginScrubbing() { + + } + + func didEndScrubbing(position: Double) { + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000) + let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) + let newPositionOffset = secondsScrubbedTo - videoPosition + + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + + print("Scrubbed position: \(position)") + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.storyboard b/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.storyboard deleted file mode 100644 index 08b8107e..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.storyboard +++ /dev/null @@ -1,252 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift deleted file mode 100644 index 2053e08d..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift +++ /dev/null @@ -1,1188 +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 Combine -import Defaults -import GoogleCast -import JellyfinAPI -import MediaPlayer -import MobileVLCKit -import Stinsen -import SwiftUI -import SwiftyJSON - -enum PlayerDestination { - case remote - case local -} - -protocol PlayerViewControllerDelegate: AnyObject { - func hideLoadingView(_ viewController: PlayerViewController) - func showLoadingView(_ viewController: PlayerViewController) - func exitPlayer(_ viewController: PlayerViewController) -} - -class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRemoteMediaClientListener { - @RouterObject - var main: MainCoordinator.Router? - - weak var delegate: PlayerViewControllerDelegate? - - var cancellables = Set() - var mediaPlayer = VLCMediaPlayer() - - @IBOutlet weak var upNextView: UIView! - @IBOutlet weak var timeText: UILabel! - @IBOutlet weak var timeLeftText: UILabel! - @IBOutlet weak var videoContentView: UIView! - @IBOutlet weak var videoControlsView: UIView! - @IBOutlet weak var seekSlider: UISlider! - @IBOutlet weak var titleLabel: UILabel! - @IBOutlet weak var jumpBackButton: UIButton! - @IBOutlet weak var jumpForwardButton: UIButton! - @IBOutlet weak var playerSettingsButton: UIButton! - @IBOutlet weak var castButton: UIButton! - - var shouldShowLoadingScreen: Bool = false - var ssTargetValueOffset: Int = 0 - var ssStartValue: Int = 0 - var optionsVC: VideoPlayerSettingsView? - var castDeviceVC: VideoPlayerCastDeviceSelectorView? - - var paused: Bool = true - var lastTime: Float = 0.0 - var startTime: Int = 0 - var controlsAppearTime: Double = 0 - var isSeeking: Bool = false - - var playerDestination: PlayerDestination = .local - var discoveredCastDevices: [GCKDevice] = [] - var selectedCastDevice: GCKDevice? - var jellyfinCastChannel: GCKGenericChannel? - var remotePositionTicks: Int = 0 - private var castDiscoveryManager: GCKDiscoveryManager { - return GCKCastContext.sharedInstance().discoveryManager - } - - private var castSessionManager: GCKSessionManager { - return GCKCastContext.sharedInstance().sessionManager - } - - var hasSentRemoteSeek: Bool = false - - var selectedPlaybackSpeedIndex: Int = 3 - var selectedAudioTrack: Int32 = -1 - var selectedCaptionTrack: Int32 = -1 - var playSessionId: String = "" - var lastProgressReportTime: Double = 0 - var subtitleTrackArray: [Subtitle] = [] - var audioTrackArray: [AudioTrack] = [] - let playbackSpeeds: [Float] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] - var jumpForwardLength: VideoPlayerJumpLength { - return Defaults[.videoPlayerJumpForward] - } - - var jumpBackwardLength: VideoPlayerJumpLength { - return Defaults[.videoPlayerJumpBackward] - } - - var manifest = BaseItemDto() - var playbackItem = PlaybackItem() - var remoteTimeUpdateTimer: Timer? - var upNextViewModel = UpNextViewModel() - var lastOri: UIInterfaceOrientation? - - // MARK: IBActions - - @IBAction func seekSliderStart(_ sender: Any) { - if playerDestination == .local { - sendProgressReport(eventName: "pause") - mediaPlayer.pause() - } else { - isSeeking = true - } - } - - @IBAction func seekSliderValueChanged(_ sender: Any) { - let videoDuration = Double(manifest.runTimeTicks! / Int64(10_000_000)) - let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration) - let secondsScrubbedRemaining = videoDuration - secondsScrubbedTo - - timeText.text = calculateTimeText(from: secondsScrubbedTo) - timeLeftText.text = calculateTimeText(from: secondsScrubbedRemaining) - } - - private func calculateTimeText(from duration: Double) -> String { - let hours = floor(duration / 3600) - let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 - let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) - - let timeText: String - - if hours != 0 { - timeText = - "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } else { - timeText = - "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } - - return timeText - } - - @IBAction func seekSliderEnd(_ sender: Any) { - isSeeking = false - let videoPosition = playerDestination == .local ? Double(mediaPlayer.time.intValue / 1000) : - Double(remotePositionTicks / Int(10_000_000)) - let videoDuration = Double(manifest.runTimeTicks! / Int64(10_000_000)) - // Scrub is value from 0..1 - find position in video and add / or remove. - let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration) - let offset = secondsScrubbedTo - videoPosition - - if playerDestination == .local { - if offset > 0 { - mediaPlayer.jumpForward(Int32(offset)) - } else { - mediaPlayer.jumpBackward(Int32(abs(offset))) - } - mediaPlayer.play() - sendProgressReport(eventName: "unpause") - } else { - sendJellyfinCommand(command: "Seek", options: [ - "position": Int(secondsScrubbedTo) - ]) - } - } - - @IBAction func exitButtonPressed(_ sender: Any) { - sendStopReport() - mediaPlayer.stop() - - if castSessionManager.hasConnectedCastSession() { - castSessionManager.endSessionAndStopCasting(true) - } - - delegate?.exitPlayer(self) - } - - @IBAction func controlViewTapped(_ sender: Any) { - if playerDestination == .local { - videoControlsView.isHidden = true - if manifest.type == "Episode" { - smallNextUpView() - } - } - } - - @IBAction func contentViewTapped(_ sender: Any) { - if playerDestination == .local { - videoControlsView.isHidden = false - controlsAppearTime = CACurrentMediaTime() - } - } - - @IBAction func jumpBackTapped(_ sender: Any) { - if paused == false { - if playerDestination == .local { - mediaPlayer.jumpBackward(jumpBackwardLength.rawValue) - } else { - sendJellyfinCommand(command: "Seek", - options: ["position": (remotePositionTicks / 10_000_000) - Int(jumpBackwardLength.rawValue)]) - } - } - } - - @IBAction func jumpForwardTapped(_ sender: Any) { - if paused == false { - if playerDestination == .local { - mediaPlayer.jumpForward(jumpForwardLength.rawValue) - } else { - sendJellyfinCommand(command: "Seek", - options: ["position": (remotePositionTicks / 10_000_000) + Int(jumpForwardLength.rawValue)]) - } - } - } - - @IBOutlet weak var mainActionButton: UIButton! - @IBAction func mainActionButtonPressed(_ sender: Any) { - if paused { - if playerDestination == .local { - mediaPlayer.play() - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - paused = false - } else { - sendJellyfinCommand(command: "Unpause", options: [:]) - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - paused = false - } - } else { - if playerDestination == .local { - mediaPlayer.pause() - mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - paused = true - } else { - sendJellyfinCommand(command: "Pause", options: [:]) - mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - paused = true - } - } - } - - @IBAction func settingsButtonTapped(_ sender: UIButton) { - optionsVC = VideoPlayerSettingsView() - optionsVC?.playerDelegate = self - - optionsVC?.modalPresentationStyle = .popover - optionsVC?.popoverPresentationController?.sourceView = playerSettingsButton - - // Present the view controller (in a popover). - present(optionsVC!, animated: true) { - print("popover visible, pause playback") - self.mediaPlayer.pause() - self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - } - } - - // MARK: Cast methods - - @IBAction func castButtonPressed(_ sender: Any) { - if selectedCastDevice == nil { - LogManager.shared.log.debug("Presenting Cast modal") - castDeviceVC = VideoPlayerCastDeviceSelectorView() - castDeviceVC?.delegate = self - - castDeviceVC?.modalPresentationStyle = .popover - castDeviceVC?.popoverPresentationController?.sourceView = castButton - - // Present the view controller (in a popover). - present(castDeviceVC!, animated: true) { - self.mediaPlayer.pause() - self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - } - } else { - LogManager.shared.log.info("Stopping casting session: button was pressed.") - castSessionManager.endSessionAndStopCasting(true) - selectedCastDevice = nil - castButton.isEnabled = true - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - playerDestination = .local - } - } - - func castPopoverDismissed() { - LogManager.shared.log.debug("Cast modal dismissed") - castDeviceVC?.dismiss(animated: true, completion: nil) - if playerDestination == .local { - mediaPlayer.play() - } - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - } - - func castDeviceChanged() { - LogManager.shared.log.debug("Cast device changed") - if selectedCastDevice != nil { - LogManager.shared.log.debug("New device: \(selectedCastDevice?.friendlyName ?? "UNKNOWN")") - playerDestination = .remote - castSessionManager.add(self) - castSessionManager.startSession(with: selectedCastDevice!) - } - } - - // MARK: Cast End - - func settingsPopoverDismissed() { - optionsVC?.dismiss(animated: true, completion: nil) - if playerDestination == .local { - mediaPlayer.play() - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - } - } - - func setupNowPlayingCC() { - let commandCenter = MPRemoteCommandCenter.shared() - commandCenter.playCommand.isEnabled = true - commandCenter.pauseCommand.isEnabled = true - commandCenter.seekForwardCommand.isEnabled = true - commandCenter.seekBackwardCommand.isEnabled = true - commandCenter.changePlaybackPositionCommand.isEnabled = true - - // Add handler for Pause Command - commandCenter.pauseCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.pause() - self.sendProgressReport(eventName: "pause") - } else { - self.sendJellyfinCommand(command: "Pause", options: [:]) - } - self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - return .success - } - - // Add handler for Play command - commandCenter.playCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.play() - self.sendProgressReport(eventName: "unpause") - } else { - self.sendJellyfinCommand(command: "Unpause", options: [:]) - } - self.mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - return .success - } - - // Add handler for FF command - commandCenter.seekForwardCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.jumpForward(30) - self.sendProgressReport(eventName: "timeupdate") - } else { - self.sendJellyfinCommand(command: "Seek", options: ["position": (self.remotePositionTicks / 10_000_000) + 30]) - } - return .success - } - - // Add handler for RW command - commandCenter.seekBackwardCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.jumpBackward(15) - self.sendProgressReport(eventName: "timeupdate") - } else { - self.sendJellyfinCommand(command: "Seek", options: ["position": (self.remotePositionTicks / 10_000_000) - 15]) - } - return .success - } - - // Scrubber - commandCenter.changePlaybackPositionCommand.addTarget { [weak self] (remoteEvent) -> MPRemoteCommandHandlerStatus in - guard let self = self else { return .commandFailed } - - if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent { - let targetSeconds = event.positionTime - - let videoPosition = Double(self.mediaPlayer.time.intValue) - let offset = targetSeconds - videoPosition - - if self.playerDestination == .local { - if offset > 0 { - self.mediaPlayer.jumpForward(Int32(offset) / 1000) - } else { - self.mediaPlayer.jumpBackward(Int32(abs(offset)) / 1000) - } - self.sendProgressReport(eventName: "unpause") - } else {} - - return .success - } else { - return .commandFailed - } - } - - var nowPlayingInfo = [String: Any]() - - var runTicks = 0 - var playbackTicks = 0 - - if let ticks = manifest.runTimeTicks { - runTicks = Int(ticks / 10_000_000) - } - - if let ticks = manifest.userData?.playbackPositionTicks { - playbackTicks = Int(ticks / 10_000_000) - } - - nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video" - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 - nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks - - if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 200)) { - if let artworkImage = UIImage(data: imageData as Data) { - let artwork = MPMediaItemArtwork(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in - artworkImage - }) - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork - } - } - - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - - UIApplication.shared.beginReceivingRemoteControlEvents() - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - if manifest.type == "Movie" { - titleLabel.text = manifest.name ?? "" - } else { - titleLabel.text = "\(L10n.seasonAndEpisode(String(manifest.parentIndexNumber ?? 0), String(manifest.indexNumber ?? 0))) “\(manifest.name ?? "")”" - - setupNextUpView() - upNextViewModel.delegate = self - } - - DispatchQueue.main.async { - self.lastOri = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? nil - AppDelegate.orientationLock = .landscape - - if self.lastOri != nil { - if !self.lastOri!.isLandscape { - UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - } - } - - NotificationCenter.default.addObserver(self, selector: #selector(didChangedOrientation), - name: UIDevice.orientationDidChangeNotification, object: nil) - } - - @objc func didChangedOrientation() { - lastOri = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation - } - - func mediaHasStartedPlaying() { - castButton.isHidden = true - let discoveryCriteria = GCKDiscoveryCriteria(applicationID: "F007D354") - let gckCastOptions = GCKCastOptions(discoveryCriteria: discoveryCriteria) - GCKCastContext.setSharedInstanceWith(gckCastOptions) - castDiscoveryManager.passiveScan = true - castDiscoveryManager.add(self) - castDiscoveryManager.startDiscovery() - } - - func didUpdateDeviceList() { - let totalDevices = castDiscoveryManager.deviceCount - discoveredCastDevices = [] - if totalDevices > 0 { - for i in 0 ... totalDevices - 1 { - let device = castDiscoveryManager.device(at: i) - discoveredCastDevices.append(device) - } - } - - if !discoveredCastDevices.isEmpty { - castButton.isHidden = false - castButton.isEnabled = true - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - } else { - castButton.isHidden = true - castButton.isEnabled = false - castButton.setImage(nil, for: .normal) - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - tabBarController?.tabBar.isHidden = false - navigationController?.isNavigationBarHidden = false - overrideUserInterfaceStyle = .unspecified - DispatchQueue.main.async { - if self.lastOri != nil { - AppDelegate.orientationLock = .all - UIDevice.current.setValue(self.lastOri!.rawValue, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - } - } - - // MARK: viewDidAppear - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - overrideUserInterfaceStyle = .dark - tabBarController?.tabBar.isHidden = true - navigationController?.isNavigationBarHidden = true - - mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) - // mediaPlayer.wrappedValue.perform(Selector(("setTextRendererFont:")), with: "Copperplate") - - mediaPlayer.delegate = self - mediaPlayer.drawable = videoContentView - - setupMediaPlayer() - setupJumpLengthButtons() - } - - func setupMediaPlayer() { - // Fetch max bitrate from UserDefaults depending on current connection mode - let maxBitrate = Defaults[.inNetworkBandwidth] - print(maxBitrate) - // Build a device profile - let builder = DeviceProfileBuilder() - builder.setMaxBitrate(bitrate: maxBitrate) - let profile = builder.buildProfile() - let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id, maxStreamingBitrate: Int(maxBitrate), - startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, - autoOpenLiveStream: true) - - DispatchQueue.global(qos: .userInitiated).async { [self] in - delegate?.showLoadingView(self) - MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: Int(maxBitrate), - startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, - playbackInfoDto: playbackInfo) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case let .failure(error): - if let err = error as? ErrorResponse { - switch err { - case .error(401, _, _, _): - self.delegate?.exitPlayer(self) - SessionManager.main.logout() - case .error: - self.delegate?.exitPlayer(self) - } - } - } - }, receiveValue: { [self] response in - dump(response) - playSessionId = response.playSessionId ?? "" - let mediaSource = response.mediaSources!.first.self! - if mediaSource.transcodingUrl != nil { - // Item is being transcoded by request of server - let streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(mediaSource.transcodingUrl!)") - let item = PlaybackItem() - item.videoType = .transcode - item.videoUrl = streamURL! - - let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", - languageCode: "") - subtitleTrackArray.append(disableSubtitleTrack) - - // Loop through media streams and add to array - for stream in mediaSource.mediaStreams ?? [] { - if stream.type == .subtitle { - var deliveryUrl: URL? - if stream.deliveryMethod == .external { - deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl ?? "")")! - } else { - deliveryUrl = nil - } - let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, - delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", - languageCode: stream.language ?? "") - - if subtitle.delivery != .encode { - subtitleTrackArray.append(subtitle) - } - } - - if stream.type == .audio { - let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", - id: Int32(stream.index!)) - if stream.isDefault! == true { - selectedAudioTrack = Int32(stream.index!) - } - audioTrackArray.append(subtitle) - } - } - - if selectedAudioTrack == -1 { - if !audioTrackArray.isEmpty { - selectedAudioTrack = audioTrackArray[0].id - } - } - - self.sendPlayReport() - playbackItem = item - } else { - // TODO: todo - // Item will be directly played by the client. - let streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")! -// URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag ?? "")")! - - let item = PlaybackItem() - item.videoUrl = streamURL - item.videoType = .directPlay - - let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", - languageCode: "") - subtitleTrackArray.append(disableSubtitleTrack) - - // Loop through media streams and add to array - for stream in mediaSource.mediaStreams ?? [] { - if stream.type == .subtitle { - var deliveryUrl: URL? - if stream.deliveryMethod == .external { - deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl!)")! - } else { - deliveryUrl = nil - } - let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, - delivery: stream.deliveryMethod!, codec: stream.codec!, - languageCode: stream.language ?? "") - - if subtitle.delivery != .encode { - subtitleTrackArray.append(subtitle) - } - } - - if stream.type == .audio { - let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", - id: Int32(stream.index!)) - if stream.isDefault! == true { - selectedAudioTrack = Int32(stream.index!) - } - audioTrackArray.append(subtitle) - } - } - - if selectedAudioTrack == -1 { - if !audioTrackArray.isEmpty { - selectedAudioTrack = audioTrackArray[0].id - } - } - - self.sendPlayReport() - playbackItem = item - - // self.setupNowPlayingCC() - } - - startLocalPlaybackEngine(true) - }) - .store(in: &cancellables) - } - } - - private func setupJumpLengthButtons() { - let buttonFont = UIFont.systemFont(ofSize: 35, weight: .regular) - jumpForwardButton.setImage(jumpForwardLength.generateForwardImage(with: buttonFont), for: .normal) - jumpBackButton.setImage(jumpBackwardLength.generateBackwardImage(with: buttonFont), for: .normal) - } - - func setupTracksForPreferredDefaults() { - subtitleTrackArray.forEach { subtitle in - if Defaults[.isAutoSelectSubtitles] { - if Defaults[.autoSelectSubtitlesLangCode] == "Auto", - subtitle.languageCode.contains(Locale.current.languageCode ?? "") { - selectedCaptionTrack = subtitle.id - mediaPlayer.currentVideoSubTitleIndex = subtitle.id - } else if subtitle.languageCode.contains(Defaults[.autoSelectSubtitlesLangCode]) { - selectedCaptionTrack = subtitle.id - mediaPlayer.currentVideoSubTitleIndex = subtitle.id - } - } - } - - audioTrackArray.forEach { audio in - if audio.languageCode.contains(Defaults[.autoSelectAudioLangCode]) { - selectedAudioTrack = audio.id - mediaPlayer.currentAudioTrackIndex = audio.id - } - } - } - - func startLocalPlaybackEngine(_ fetchCaptions: Bool) { - mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl) - mediaPlayer.play() - sendPlayReport() - - // 1 second = 10,000,000 ticks - var startTicks: Int64 = 0 - if remotePositionTicks == 0 { - startTicks = manifest.userData?.playbackPositionTicks ?? 0 - } else { - startTicks = Int64(remotePositionTicks) - } - - if startTicks != 0 { - let videoPosition = Double(mediaPlayer.time.intValue / 1000) - let secondsScrubbedTo = startTicks / 10_000_000 - let offset = secondsScrubbedTo - Int64(videoPosition) - if offset > 0 { - mediaPlayer.jumpForward(Int32(offset)) - } else { - mediaPlayer.jumpBackward(Int32(abs(offset))) - } - } - - if fetchCaptions { - mediaPlayer.pause() - subtitleTrackArray.forEach { sub in - // stupid fxcking jeff decides to re-encode these when added. - // only add playback streams when codec not supported by VLC. - if sub.id != -1, sub.delivery == .external, sub.codec != "subrip" { - mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false) - } - } - } - - mediaHasStartedPlaying() - delegate?.hideLoadingView(self) - - videoContentView.setNeedsLayout() - videoContentView.setNeedsDisplay() - view.setNeedsLayout() - view.setNeedsDisplay() - videoControlsView.setNeedsLayout() - videoControlsView.setNeedsDisplay() - - mediaPlayer.pause() - mediaPlayer.play() - setupTracksForPreferredDefaults() - } - - // MARK: VideoPlayerSettings Delegate - - func subtitleTrackChanged(newTrackID: Int32) { - selectedCaptionTrack = newTrackID - mediaPlayer.currentVideoSubTitleIndex = newTrackID - } - - func audioTrackChanged(newTrackID: Int32) { - selectedAudioTrack = newTrackID - mediaPlayer.currentAudioTrackIndex = newTrackID - } - - func playbackSpeedChanged(index: Int) { - selectedPlaybackSpeedIndex = index - mediaPlayer.rate = playbackSpeeds[index] - } - - func smallNextUpView() { - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) { [self] in - upNextViewModel.largeView = false - } - } - - func setupNextUpView() { - getNextEpisode() - - // Create the swiftUI view - let contentView = UIHostingController(rootView: VideoUpNextView(viewModel: upNextViewModel)) - upNextView.addSubview(contentView.view) - contentView.view.backgroundColor = .clear - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: upNextView.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: upNextView.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: upNextView.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: upNextView.rightAnchor).isActive = true - } - - func getNextEpisode() { - TvShowsAPI.getEpisodes(seriesId: manifest.seriesId!, userId: SessionManager.main.currentLogin.user.id, startItemId: manifest.id, - limit: 2) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { [self] response in - // Returns 2 items, the first is the current episode - // The second is the next episode - if let item = response.items?.last { - self.upNextViewModel.item = item - } - }) - .store(in: &cancellables) - } - - func setPlayerToNextUp() { - mediaPlayer.stop() - - ssTargetValueOffset = 0 - ssStartValue = 0 - - paused = true - lastTime = 0.0 - startTime = 0 - controlsAppearTime = 0 - isSeeking = false - - remotePositionTicks = 0 - - selectedPlaybackSpeedIndex = 3 - selectedAudioTrack = -1 - selectedCaptionTrack = -1 - playSessionId = "" - lastProgressReportTime = 0 - subtitleTrackArray = [] - audioTrackArray = [] - - manifest = upNextViewModel.item! - playbackItem = PlaybackItem() - - upNextViewModel.item = nil - - upNextView.isHidden = true - shouldShowLoadingScreen = true - videoControlsView.isHidden = true - - titleLabel.text = "\(L10n.seasonAndEpisode(String(manifest.parentIndexNumber ?? 0), String(manifest.indexNumber ?? 0))) “\(manifest.name ?? "")”" - - setupMediaPlayer() - getNextEpisode() - } -} - -// MARK: - GCKGenericChannelDelegate - -extension PlayerViewController: GCKGenericChannelDelegate { - @objc func updateRemoteTime() { - castButton.setImage(UIImage(named: "CastConnected"), for: .normal) - if !paused { - remotePositionTicks = remotePositionTicks + 2_000_000 // add 0.2 secs every timer evt. - } - - if isSeeking == false { - let positiveSeconds = Double(remotePositionTicks / 10_000_000) - let remainingSeconds = Double((manifest.runTimeTicks! - Int64(remotePositionTicks)) / 10_000_000) - - timeText.text = calculateTimeText(from: positiveSeconds) - timeLeftText.text = calculateTimeText(from: remainingSeconds) - - let playbackProgress = Float(remotePositionTicks) / Float(manifest.runTimeTicks!) - seekSlider.setValue(playbackProgress, animated: true) - } - } - - func cast(_ channel: GCKGenericChannel, didReceiveTextMessage message: String, withNamespace protocolNamespace: String) { - if let data = message.data(using: .utf8) { - if let json = try? JSON(data: data) { - let messageType = json["type"].string ?? "" - if messageType == "playbackprogress" { - dump(json) - if remotePositionTicks > 100 { - if hasSentRemoteSeek == false { - hasSentRemoteSeek = true - sendJellyfinCommand(command: "Seek", options: [ - "position": Int(Float(manifest.runTimeTicks! / 10_000_000) * mediaPlayer.position) - ]) - } - } - paused = json["data"]["PlayState"]["IsPaused"].boolValue - remotePositionTicks = json["data"]["PlayState"]["PositionTicks"].int ?? 0 - if remoteTimeUpdateTimer == nil { - remoteTimeUpdateTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(updateRemoteTime), - userInfo: nil, repeats: true) - } - } - } - } - } - - func sendJellyfinCommand(command: String, options: [String: Any]) { - let payload: [String: Any] = [ - "options": options, - "command": command, - "userId": SessionManager.main.currentLogin.user.id, -// "deviceId": SessionManager.main.currentLogin.de.deviceID, - "accessToken": SessionManager.main.currentLogin.user.accessToken, - "serverAddress": SessionManager.main.currentLogin.server.currentURI, - "serverId": SessionManager.main.currentLogin.server.id, - "serverVersion": "10.8.0", - "receiverName": castSessionManager.currentCastSession!.device.friendlyName!, - "subtitleBurnIn": false - ] - let jsonData = JSON(payload) - - jellyfinCastChannel?.sendTextMessage(jsonData.rawString()!, error: nil) - - if command == "Seek" { - remotePositionTicks = remotePositionTicks + ((options["position"] as! Int) * 10_000_000) - // Send playback report as Jellyfin Chromecast isn't smarter than a rock. - let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, - mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), - subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: paused, isMuted: false, - positionTicks: Int64(remotePositionTicks), playbackStartTimeTicks: Int64(startTime), - volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, - liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, - nowPlayingQueue: [], playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback progress report sent!") - }) - .store(in: &cancellables) - } - } -} - -// MARK: - GCKSessionManagerListener - -extension PlayerViewController: GCKSessionManagerListener { - func sessionDidStart(manager: GCKSessionManager, didStart session: GCKCastSession) { - sendStopReport() - mediaPlayer.stop() - - playerDestination = .remote - videoContentView.isHidden = true - videoControlsView.isHidden = false - castButton.setImage(UIImage(named: "CastConnected"), for: .normal) - manager.currentCastSession?.start() - - jellyfinCastChannel!.delegate = self - session.add(jellyfinCastChannel!) - - if let client = session.remoteMediaClient { - client.add(self) - } - - let playNowOptions: [String: Any] = [ - "items": [[ - "Id": manifest.id!, - "ServerId": SessionManager.main.currentLogin.server.id, - "Name": manifest.name!, - "Type": manifest.type!, - "MediaType": manifest.mediaType!, - "IsFolder": manifest.isFolder! - ]] - ] - sendJellyfinCommand(command: "PlayNow", options: playNowOptions) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKCastSession) { - jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") - sessionDidStart(manager: sessionManager, didStart: session) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didResumeCastSession session: GCKCastSession) { - jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") - sessionDidStart(manager: sessionManager, didStart: session) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKCastSession, withError error: Error) { - LogManager.shared.log.error((error as NSError).debugDescription) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKCastSession, withError error: Error?) { - if error != nil { - LogManager.shared.log.error((error! as NSError).debugDescription) - } - - playerDestination = .local - videoContentView.isHidden = false - remoteTimeUpdateTimer?.invalidate() - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - startLocalPlaybackEngine(false) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didSuspend session: GCKCastSession, with reason: GCKConnectionSuspendReason) { - playerDestination = .local - videoContentView.isHidden = false - remoteTimeUpdateTimer?.invalidate() - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - startLocalPlaybackEngine(false) - } -} - -// MARK: - VLCMediaPlayer Delegates - -extension PlayerViewController: VLCMediaPlayerDelegate { - func mediaPlayerStateChanged(_ aNotification: Notification!) { - let currentState: VLCMediaPlayerState = mediaPlayer.state - switch currentState { - case .stopped: - LogManager.shared.log.debug("Player state changed: STOPPED") - case .ended: - LogManager.shared.log.debug("Player state changed: ENDED") - case .playing: - LogManager.shared.log.debug("Player state changed: PLAYING") - sendProgressReport(eventName: "unpause") - delegate?.hideLoadingView(self) - paused = false - case .paused: - LogManager.shared.log.debug("Player state changed: PAUSED") - paused = true - case .opening: - LogManager.shared.log.debug("Player state changed: OPENING") - case .buffering: - LogManager.shared.log.debug("Player state changed: BUFFERING") - delegate?.showLoadingView(self) - case .error: - LogManager.shared.log.error("Video had error.") - sendStopReport() - case .esAdded: - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - @unknown default: - break - } - } - - func mediaPlayerTimeChanged(_ aNotification: Notification!) { - let time = mediaPlayer.position - if abs(time - lastTime) > 0.00005 { - paused = false - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - seekSlider.setValue(mediaPlayer.position, animated: true) - delegate?.hideLoadingView(self) - - if manifest.type == "Episode", upNextViewModel.item != nil { - if time > 0.96 { - upNextView.isHidden = false - jumpForwardButton.isHidden = true - } else { - upNextView.isHidden = true - jumpForwardButton.isHidden = false - } - } - - timeText.text = mediaPlayer.time.stringValue - timeLeftText.text = String(mediaPlayer.remainingTime.stringValue.dropFirst()) - - if CACurrentMediaTime() - controlsAppearTime > 5 { - smallNextUpView() - UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: { - self.videoControlsView.alpha = 0.0 - }, completion: { (_: Bool) in - self.videoControlsView.isHidden = true - self.videoControlsView.alpha = 1 - }) - controlsAppearTime = 999_999_999_999_999 - } - lastTime = time - } - - if CACurrentMediaTime() - lastProgressReportTime > 5 { - mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack - sendProgressReport(eventName: "timeupdate") - lastProgressReportTime = CACurrentMediaTime() - } - } -} - -struct VideoPlayerView: View { - var item: BaseItemDto - @State private var isLoading = false - - var body: some View { - // Loading UI needs to be moved into ViewController later - LoadingViewNoBlur(isShowing: $isLoading) { - VLCPlayerWithControls(item: item, loadBinding: $isLoading) - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) - .statusBar(hidden: true) - .edgesIgnoringSafeArea(.all) - .prefersHomeIndicatorAutoHidden(true) - } - } -} - -// MARK: End VideoPlayerVC - -struct VLCPlayerWithControls: UIViewControllerRepresentable { - var item: BaseItemDto - @RouterObject var playerRouter: VideoPlayerCoordinator.Router? - - let loadBinding: Binding - - class Coordinator: NSObject, PlayerViewControllerDelegate { - var parent: VLCPlayerWithControls - let loadBinding: Binding - - init(parent: VLCPlayerWithControls, loadBinding: Binding) { - self.parent = parent - self.loadBinding = loadBinding - } - - func hideLoadingView(_ viewController: PlayerViewController) { - loadBinding.wrappedValue = false - } - - func showLoadingView(_ viewController: PlayerViewController) { - loadBinding.wrappedValue = true - } - - func exitPlayer(_ viewController: PlayerViewController) { - parent.playerRouter?.dismissCoordinator() - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self, loadBinding: loadBinding) - } - - typealias UIViewControllerType = PlayerViewController - func makeUIViewController(context: UIViewControllerRepresentableContext) -> VLCPlayerWithControls - .UIViewControllerType { - let storyboard = UIStoryboard(name: "VideoPlayer", bundle: nil) - let customViewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! PlayerViewController - customViewController.manifest = item - customViewController.delegate = context.coordinator - return customViewController - } - - func updateUIViewController(_ uiViewController: VLCPlayerWithControls.UIViewControllerType, - context: UIViewControllerRepresentableContext) {} -} - -// MARK: - Play State Update Methods - -extension PlayerViewController { - func sendProgressReport(eventName: String) { - if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" { - var ticks = Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)) - if ticks == 0 { - ticks = manifest.userData?.playbackPositionTicks ?? 0 - } - - let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, - mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), - subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: mediaPlayer.state == .paused, - isMuted: false, positionTicks: ticks, playbackStartTimeTicks: Int64(startTime), - volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, - liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, - nowPlayingQueue: [], playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback progress report sent!") - }) - .store(in: &cancellables) - } - } - - func sendStopReport() { - let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, - positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), liveStreamId: nil, - playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", - nowPlayingQueue: []) - - PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback stop report sent!") - }) - .store(in: &cancellables) - } - - func sendPlayReport() { - startTime = Int(Date().timeIntervalSince1970) * 10_000_000 - - print("sending play report!") - - let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, - mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), - subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, - positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), - volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, - liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], - playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback start report sent!") - }) - .store(in: &cancellables) - } -} - -extension UINavigationController { - override open var childForHomeIndicatorAutoHidden: UIViewController? { - return nil - } -} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerCastDeviceSelector.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayerCastDeviceSelector.swift deleted file mode 100644 index 2f018c88..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerCastDeviceSelector.swift +++ /dev/null @@ -1,93 +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 Foundation -import SwiftUI - -class VideoPlayerCastDeviceSelectorView: UIViewController { - private var contentView: UIHostingController! - weak var delegate: PlayerViewController? - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - .landscape - } - - override func viewDidLoad() { - super.viewDidLoad() - contentView = UIHostingController(rootView: VideoPlayerCastDeviceSelector(delegate: self.delegate ?? PlayerViewController())) - self.view.addSubview(contentView.view) - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - self.delegate?.castPopoverDismissed() - } -} - -struct VideoPlayerCastDeviceSelector: View { - weak var delegate: PlayerViewController! - - init(delegate: PlayerViewController) { - self.delegate = delegate - } - - var body: some View { - NavigationView { - Group { - if !delegate.discoveredCastDevices.isEmpty { - List(delegate.discoveredCastDevices, id: \.deviceID) { device in - HStack { - Text(device.friendlyName!) - .font(.subheadline) - .fontWeight(.medium) - Spacer() - Button { - delegate.selectedCastDevice = device - delegate?.castDeviceChanged() - delegate?.castPopoverDismissed() - } label: { - HStack { - L10n.connect.text - .font(.caption) - .fontWeight(.medium) - Image(systemName: "bonjour") - .font(.caption) - } - } - } - } - } else { - L10n.noCastdevicesfound.text - .foregroundColor(.secondary) - .font(.subheadline) - .fontWeight(.medium) - } - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle(L10n.selectCastDestination) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - if UIDevice.current.userInterfaceIdiom == .phone { - Button { - delegate?.castPopoverDismissed() - } label: { - HStack { - Image(systemName: "chevron.left") - L10n.back.text.font(.callout) - } - } - } - } - } - }.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0) - } -} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerSettingsView.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayerSettingsView.swift deleted file mode 100644 index fb73836a..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerSettingsView.swift +++ /dev/null @@ -1,89 +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 Foundation -import SwiftUI - -class VideoPlayerSettingsView: UINavigationController { - private var contentView: UIHostingController! - weak var playerDelegate: PlayerViewController? - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - .landscape - } - - override func viewDidLoad() { - super.viewDidLoad() - - self.viewControllers = [UIHostingController(rootView: VideoPlayerSettings(delegate: self.playerDelegate ?? PlayerViewController()))] - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - self.playerDelegate?.settingsPopoverDismissed() - } -} - -struct VideoPlayerSettings: View { - weak var delegate: PlayerViewController! - @State var captionTrack: Int32 = -99 - @State var audioTrack: Int32 = -99 - @State var playbackSpeedSelection: Int = 3 - - init(delegate: PlayerViewController) { - self.delegate = delegate - } - - var body: some View { - Form { - Picker(L10n.closedCaptions, selection: $captionTrack) { - ForEach(delegate.subtitleTrackArray, id: \.id) { caption in - Text(caption.name).tag(caption.id) - } - } - .onChange(of: captionTrack) { track in - self.delegate.subtitleTrackChanged(newTrackID: track) - } - Picker(L10n.audioTrack, selection: $audioTrack) { - ForEach(delegate.audioTrackArray, id: \.id) { caption in - Text(caption.name).tag(caption.id).lineLimit(1) - } - }.onChange(of: audioTrack) { track in - self.delegate.audioTrackChanged(newTrackID: track) - } - Picker(L10n.playbackSpeed, selection: $playbackSpeedSelection) { - ForEach(delegate.playbackSpeeds.indices, id: \.self) { speedIndex in - let speed = delegate.playbackSpeeds[speedIndex] - Text("\(String(speed))x").tag(speedIndex) - } - } - .onChange(of: playbackSpeedSelection, perform: { index in - self.delegate.playbackSpeedChanged(index: index) - }) - }.navigationBarTitleDisplayMode(.inline) - .navigationTitle(L10n.audioAndCaptions) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - if UIDevice.current.userInterfaceIdiom == .phone { - Button { - self.delegate.settingsPopoverDismissed() - } label: { - HStack { - Image(systemName: "chevron.left") - L10n.back.text.font(.callout) - } - } - } - } - }.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0) - .onAppear(perform: { - captionTrack = self.delegate.selectedCaptionTrack - audioTrack = self.delegate.selectedAudioTrack - playbackSpeedSelection = self.delegate.selectedPlaybackSpeedIndex - }) - } -} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift new file mode 100644 index 00000000..8f9bf3e9 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift @@ -0,0 +1,41 @@ +// +// VideoPlayerView.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/12/21. +// + +import UIKit +import SwiftUI + +struct NativePlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = NativePlayerViewController + + func makeUIViewController(context: Context) -> NativePlayerViewController { + + return NativePlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) { + + } +} + +struct VLCPlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = VLCPlayerViewController + + func makeUIViewController(context: Context) -> VLCPlayerViewController { + + return VLCPlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) { + + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoUpNextView.swift b/JellyfinPlayer/Views/VideoPlayer/VideoUpNextView.swift deleted file mode 100644 index 0f886d2c..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoUpNextView.swift +++ /dev/null @@ -1,54 +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 SwiftUI -import JellyfinAPI - -class UpNextViewModel: ObservableObject { - @Published var largeView: Bool = false - @Published var item: BaseItemDto? - weak var delegate: PlayerViewController? - - func nextUp() { - if delegate != nil { - delegate?.setPlayerToNextUp() - } - } -} - -struct VideoUpNextView: View { - - @ObservedObject var viewModel: UpNextViewModel - - var body: some View { - Button { - viewModel.nextUp() - } label: { - HStack { - VStack { - L10n.playNext.text - .foregroundColor(.white) - .font(.subheadline) - .fontWeight(.semibold) - Text(viewModel.item?.getEpisodeLocator() ?? "") - .foregroundColor(.secondary) - .font(.caption) - } - Image(systemName: "play.fill") - .foregroundColor(.white) - .font(.subheadline) - } - .frame(width: 120, height: 35) - .background(Color.jellyfinPurple) - .cornerRadius(10) - }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 35) - .shadow(color: .black, radius: 20) - } -} diff --git a/Shared/Coordinators/ItemCoordinator.swift b/Shared/Coordinators/ItemCoordinator.swift index 286c2bd2..6c9e6c44 100644 --- a/Shared/Coordinators/ItemCoordinator.swift +++ b/Shared/Coordinators/ItemCoordinator.swift @@ -35,8 +35,8 @@ final class ItemCoordinator: NavigationCoordinatable { ItemCoordinator(item: item) } - func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel)) } @ViewBuilder func makeStart() -> some View { diff --git a/Shared/Coordinators/VideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator.swift deleted file mode 100644 index b4feb6fd..00000000 --- a/Shared/Coordinators/VideoPlayerCoordinator.swift +++ /dev/null @@ -1,30 +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 Foundation -import JellyfinAPI -import Stinsen -import SwiftUI - -final class VideoPlayerCoordinator: NavigationCoordinatable { - - let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) - - @Root var start = makeStart - - let item: BaseItemDto - - init(item: BaseItemDto) { - self.item = item - } - - @ViewBuilder func makeStart() -> some View { - VideoPlayerView(item: item) - } -} diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift new file mode 100644 index 00000000..70c35dfa --- /dev/null +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift @@ -0,0 +1,42 @@ +// +/* + * 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 Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class VideoPlayerCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) + + @Root var start = makeStart + + @Default(.nativeVideoPlayer) var nativeVideoPlayer + let viewModel: VideoPlayerViewModel + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } + + @ViewBuilder func makeStart() -> some View { + if nativeVideoPlayer { + NativePlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + } else { + VLCPlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + } + } +} diff --git a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift new file mode 100644 index 00000000..a8d70173 --- /dev/null +++ b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift @@ -0,0 +1,35 @@ +// + /* + * 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 Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class VideoPlayerCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) + + @Root var start = makeStart + + @Default(.nativeVideoPlayer) var nativeVideoPlayer + let viewModel: VideoPlayerViewModel + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } + + @ViewBuilder func makeStart() -> some View { + NativePlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } +} + diff --git a/Shared/Extensions/URLComponentsExtensions.swift b/Shared/Extensions/URLComponentsExtensions.swift new file mode 100644 index 00000000..17d1235a --- /dev/null +++ b/Shared/Extensions/URLComponentsExtensions.swift @@ -0,0 +1,22 @@ +// + /* + * 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 Foundation + +extension URLComponents { + + mutating func addQueryItem(name: String, value: String?) { + if let _ = self.queryItems { + self.queryItems?.append(URLQueryItem(name: name, value: value)) + } else { + self.queryItems = [] + self.queryItems?.append(URLQueryItem(name: name, value: value)) + } + } +} diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 22146519..06f858ad 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -32,4 +32,5 @@ extension Defaults.Keys { static let appAppearance = Key("appAppearance", default: .system, suite: SwiftfinStore.Defaults.suite) static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .thirty, suite: SwiftfinStore.Defaults.suite) static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .thirty, suite: SwiftfinStore.Defaults.suite) + static let nativeVideoPlayer = Key("nativeVideoPlayer", default: false, suite: SwiftfinStore.Defaults.suite) } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index df73d4b6..716dafea 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -102,7 +102,7 @@ final class HomeViewModel: ViewModel { .store(in: &cancellables) ItemsAPI.getResumeItems(userId: SessionManager.main.currentLogin.user.id, limit: 12, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], mediaTypes: ["Video"], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) @@ -122,7 +122,7 @@ final class HomeViewModel: ViewModel { .store(in: &cancellables) TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, limit: 12, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters]) .trackActivity(loading) .sink(receiveCompletion: { completion in switch completion { diff --git a/Shared/ViewModels/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel.swift index adb14d20..27fd6542 100644 --- a/Shared/ViewModels/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel.swift @@ -7,8 +7,10 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Combine import Foundation import JellyfinAPI +import UIKit class ItemViewModel: ViewModel { @@ -17,6 +19,7 @@ class ItemViewModel: ViewModel { @Published var similarItems: [BaseItemDto] = [] @Published var isWatched = false @Published var isFavorited = false + var itemVideoPlayerViewModel: VideoPlayerViewModel? init(item: BaseItemDto) { self.item = item @@ -32,6 +35,14 @@ class ItemViewModel: ViewModel { super.init() getSimilarItems() + + self.createVideoPlayerViewModel(item: item) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.itemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &cancellables) } func playButtonText() -> String { @@ -100,4 +111,96 @@ class ItemViewModel: ViewModel { .store(in: &cancellables) } } + + func createVideoPlayerViewModel(item: BaseItemDto) -> AnyPublisher { + let builder = DeviceProfileBuilder() + // TODO: fix bitrate settings + builder.setMaxBitrate(bitrate: 60000000) + let profile = builder.buildProfile() + + let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: 60000000, + startTimeTicks: item.userData?.playbackPositionTicks ?? 0, + deviceProfile: profile, + autoOpenLiveStream: true) + + return MediaInfoAPI.getPostedPlaybackInfo(itemId: item.id!, + userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: 60000000, + startTimeTicks: item.userData?.playbackPositionTicks ?? 0, + autoOpenLiveStream: true, + playbackInfoDto: playbackInfo) + .map({ response -> VideoPlayerViewModel in + let mediaSource = response.mediaSources!.first! + + let audioStreams = mediaSource.mediaStreams?.filter({ $0.type == .audio }) ?? [] + let subtitleStreams = mediaSource.mediaStreams?.filter({ $0.type == .subtitle }) ?? [] + + let defaultAudioStream = audioStreams.first(where: { $0.index! == mediaSource.defaultAudioStreamIndex! }) + + let defaultSubtitleStream = subtitleStreams.first(where: { $0.index! == mediaSource.defaultSubtitleStreamIndex ?? -1 }) + + let videoStream = mediaSource.mediaStreams!.first(where: { $0.type! == MediaStreamType.video }) + + let audioCodecs = mediaSource.mediaStreams!.filter({ $0.type! == MediaStreamType.audio }).map({ $0.codec! }) + + // MARK: basic stream + var streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)! + streamURL.path = "/Videos/\(item.id!)/stream" + + streamURL.addQueryItem(name: "Static", value: "true") + streamURL.addQueryItem(name: "MediaSourceId", value: item.id!) + streamURL.addQueryItem(name: "Tag", value: item.etag) + streamURL.addQueryItem(name: "MinSegments", value: "6") + + // MARK: hls stream + var hlsURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)! + hlsURL.path = "/videos/\(item.id!)/master.m3u8" + + hlsURL.addQueryItem(name: "DeviceId", value: UIDevice.vendorUUIDString) + hlsURL.addQueryItem(name: "MediaSourceId", value: item.id!) + hlsURL.addQueryItem(name: "VideoCodec", value: videoStream?.codec!) + hlsURL.addQueryItem(name: "AudioCodec", value: audioCodecs.joined(separator: ",")) + hlsURL.addQueryItem(name: "AudioStreamIndex", value: "\(defaultAudioStream!.index!)") + hlsURL.addQueryItem(name: "VideoBitrate", value: "\(videoStream!.bitRate!)") + hlsURL.addQueryItem(name: "AudioBitrate", value: "\(defaultAudioStream!.bitRate!)") + hlsURL.addQueryItem(name: "PlaySessionId", value: response.playSessionId!) + hlsURL.addQueryItem(name: "TranscodingMaxAudioChannels", value: "6") + hlsURL.addQueryItem(name: "RequireAvc", value: "false") + hlsURL.addQueryItem(name: "Tag", value: mediaSource.eTag!) + hlsURL.addQueryItem(name: "SegmentContainer", value: "ts") + hlsURL.addQueryItem(name: "MinSegments", value: "2") + hlsURL.addQueryItem(name: "BreakOnNonKeyFrames", value: "true") + hlsURL.addQueryItem(name: "TranscodeReasons", value: "VideoCodecNotSupported,AudioCodecNotSupported") + hlsURL.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) + + if defaultSubtitleStream?.index != nil { + hlsURL.addQueryItem(name: "SubtitleMethod", value: "Encode") + hlsURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(defaultSubtitleStream!.index!)") + } + +// startURL.queryItems?.append(URLQueryItem(name: "SubtitleCodec", value: "\(defaultSubtitleStream!.codec!)")) + + let videoPlayerViewModel = VideoPlayerViewModel(item: item, + title: item.name!, + subtitle: item.seriesName, + streamURL: streamURL.url!, + hlsURL: hlsURL.url!, + response: response, + audioStreams: audioStreams, + subtitleStreams: subtitleStreams, + defaultAudioStreamIndex: defaultAudioStream?.index ?? -1, + defaultSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, + playerState: .playing, + shouldShowGoogleCast: false, + shouldShowAirplay: false, + subtitlesEnabled: defaultAudioStream?.index != nil, + sliderPercentage: (item.userData?.playedPercentage ?? 0) / 100, + selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, + selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1) + + return videoPlayerViewModel + }) + .eraseToAnyPublisher() + } } diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index ae134564..4a951a45 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -83,7 +83,7 @@ final class LibraryViewModel: ViewModel { let shouldBeRecursive: Bool = filters.filters.contains(.isFavorite) || personIDs != [] || studioIDs != [] || genreIDs != [] ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, limit: 100, recursive: shouldBeRecursive, searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"], + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"], filters: filters.filters, sortBy: sortBy, tags: filters.tags, enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true) .trackActivity(loading) @@ -115,7 +115,7 @@ final class LibraryViewModel: ViewModel { let shouldBeRecursive: Bool = filters.filters.contains(.isFavorite) || personIDs != [] || studioIDs != [] || genreIDs != [] ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, limit: 100, recursive: shouldBeRecursive, searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"], + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"], filters: filters.filters, sortBy: sortBy, tags: filters.tags, enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true) .sink(receiveCompletion: { [weak self] completion in diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift new file mode 100644 index 00000000..b3632683 --- /dev/null +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -0,0 +1,218 @@ +// +// VideoPlayerViewModel.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/12/21. +// + +import Combine +import Foundation +import JellyfinAPI +#if os(tvOS) +import TVVLCKit +#else +import MobileVLCKit +#endif +import Stinsen +import UIKit + +final class VideoPlayerViewModel: ObservableObject { + + // Manually kept state because VLCKit doesn't properly set "played" + // on the VLCMediaPlayer object + @Published var playerState: VLCMediaPlayerState + @Published var shouldShowGoogleCast: Bool + @Published var shouldShowAirplay: Bool + @Published var captionsEnabled: Bool + @Published var leftLabelText: String = "--:--" + @Published var rightLabelText: String = "--:--" + @Published var screenFilled: Bool = false + @Published var sliderPercentage: Double { + willSet { + sliderScrubbingSubject.send(self) + sliderPercentageChanged(newValue: newValue) + } + } + @Published var sliderIsScrubbing: Bool = false + @Published var selectedAudioStreamIndex: Int + @Published var selectedSubtitleStreamIndex: Int + + let item: BaseItemDto + let title: String + let subtitle: String? + let streamURL: URL + let hlsURL: URL + // Full response kept for convenience + let response: PlaybackInfoResponse + let audioStreams: [MediaStream] + let subtitleStreams: [MediaStream] + let defaultAudioStreamIndex: Int + let defaultSubtitleStreamIndex: Int + + var playerOverlayDelegate: PlayerOverlayDelegate? + + // Ticks of the time the media has begun + var startTimeTicks: Int64? + + // Necessary PassthroughSubject to capture manual scrubbing from sliders + let sliderScrubbingSubject = PassthroughSubject() + + private var cancellables = Set() + + init(item: BaseItemDto, + title: String, + subtitle: String?, + streamURL: URL, + hlsURL: URL, + response: PlaybackInfoResponse, + audioStreams: [MediaStream], + subtitleStreams: [MediaStream], + defaultAudioStreamIndex: Int, + defaultSubtitleStreamIndex: Int, + playerState: VLCMediaPlayerState, + shouldShowGoogleCast: Bool, + shouldShowAirplay: Bool, + subtitlesEnabled: Bool, + sliderPercentage: Double, + selectedAudioStreamIndex: Int, + selectedSubtitleStreamIndex: Int) { + self.item = item + self.title = title + self.subtitle = subtitle + self.streamURL = streamURL + self.hlsURL = hlsURL + self.response = response + self.audioStreams = audioStreams + self.subtitleStreams = subtitleStreams + self.defaultAudioStreamIndex = defaultAudioStreamIndex + self.defaultSubtitleStreamIndex = defaultSubtitleStreamIndex + self.playerState = playerState + self.shouldShowGoogleCast = shouldShowGoogleCast + self.shouldShowAirplay = shouldShowAirplay + self.captionsEnabled = subtitlesEnabled + self.sliderPercentage = sliderPercentage + self.selectedAudioStreamIndex = selectedAudioStreamIndex + self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex + + self.sliderPercentageChanged(newValue: (item.userData?.playedPercentage ?? 0) / 100) + } + + private func sliderPercentageChanged(newValue: Double) { + let videoDuration = Double(item.runTimeTicks! / 10_000_000) + let secondsScrubbedTo = round(sliderPercentage * videoDuration) + let secondsScrubbedRemaining = videoDuration - secondsScrubbedTo + + leftLabelText = calculateTimeText(from: secondsScrubbedTo) + rightLabelText = calculateTimeText(from: secondsScrubbedRemaining) + } + + private func calculateTimeText(from duration: Double) -> String { + let hours = floor(duration / 3600) + let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 + let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) + + let timeText: String + + if hours != 0 { + timeText = + "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" + } else { + timeText = + "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" + } + + return timeText + } + + func sendPlayReport(startTimeTicks: Int64) { + + self.startTimeTicks = startTimeTicks + + let startInfo = PlaybackStartInfo(canSeek: true, + item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index, + subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index, + isPaused: false, + isMuted: false, + positionTicks: item.userData?.playbackPositionTicks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: 100, + brightness: 100, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0" + ) + + PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) + .sink { completion in + print(completion) + } receiveValue: { _ in + print("Playback start report sent!") + } + .store(in: &cancellables) + } + + func sendProgressReport(ticks: Int64) { + + print("Progress ticks: \(ticks)") + + let progressInfo = PlaybackProgressInfo(canSeek: true, + item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index, + subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index, + isPaused: false, + isMuted: false, + positionTicks: ticks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: nil, + brightness: nil, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0") + + PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) + .sink { completion in + print(completion) + } receiveValue: { _ in + print("Playback progress sent!") + } + .store(in: &cancellables) + } + + func sendStopReport(ticks: Int64) { + + let stopInfo = PlaybackStopInfo(item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + positionTicks: ticks, + liveStreamId: nil, + playSessionId: response.playSessionId, + failed: nil, + nextMediaType: nil, + playlistItemId: "playlistItem0", + nowPlayingQueue: nil) + + PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) + .sink { completion in + print(completion) + } receiveValue: { _ in + print("Playback stop report sent!") + } + .store(in: &cancellables) + } +} From 6a3c957807b6184e74eb317ad6d0b1141dfe3136 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 14:13:39 -0700 Subject: [PATCH 02/62] Merge main and fix --- JellyfinPlayer.xcodeproj/project.pbxproj | 46 +- .../Views/VideoPlayer/VideoPlayer.swift | 1190 ----------------- .../iOSVideoPlayerCoordinator.swift | 20 +- 3 files changed, 17 insertions(+), 1239 deletions(-) delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index fa89fb29..c0459157 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -1381,7 +1381,6 @@ buildPhases = ( 3D0F2756C71CDF6B9EEBD4E0 /* [CP] Check Pods Manifest.lock */, 6286F0A3271C0ABA00C40ED5 /* R.swift */, - D2E6FAE5F7D441C818F95CD6 /* [CP] Prepare Artifacts */, 5358705C2669D21600D05A09 /* Sources */, 5358705D2669D21600D05A09 /* Frameworks */, 5358705E2669D21600D05A09 /* Resources */, @@ -1415,7 +1414,6 @@ buildPhases = ( 1C7487D3432E90546DA855B5 /* [CP] Check Pods Manifest.lock */, 6286F09E271C093000C40ED5 /* R.swift */, - EF9FEFA814318DC80C582AC6 /* [CP] Prepare Artifacts */, 5377CBED263B596A003A4E83 /* Sources */, 5377CBEE263B596A003A4E83 /* Frameworks */, 5377CBEF263B596A003A4E83 /* Resources */, @@ -1526,7 +1524,7 @@ 536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */, 53649AAB269CFAEA00A2D8B7 /* XCRemoteSwiftPackageReference "Puppy" */, 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */, - E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */, + E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore.git" */, E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */, E1267D42271A212C003C492E /* XCRemoteSwiftPackageReference "CombineExt" */, E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, @@ -1738,23 +1736,6 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - D2E6FAE5F7D441C818F95CD6 /* [CP] Prepare Artifacts */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS-artifacts-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Prepare Artifacts"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS-artifacts-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS-artifacts.sh\"\n"; - showEnvVarsInLog = 0; - }; D4D3981ADF75BCD341D590C0 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1794,23 +1775,6 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS-resources.sh\"\n"; showEnvVarsInLog = 0; }; - EF9FEFA814318DC80C582AC6 /* [CP] Prepare Artifacts */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS-artifacts-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Prepare Artifacts"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS-artifacts-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS-artifacts.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -2668,7 +2632,7 @@ minimumVersion = 1.0.0; }; }; - E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */ = { + E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore.git" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/JohnEstropia/CoreStore.git"; requirement = { @@ -2792,17 +2756,17 @@ }; E13DD3C52716499E009D4DAF /* CoreStore */ = { isa = XCSwiftPackageProductDependency; - package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */; + package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore.git" */; productName = CoreStore; }; E13DD3CC27164CA7009D4DAF /* CoreStore */ = { isa = XCSwiftPackageProductDependency; - package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */; + package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore.git" */; productName = CoreStore; }; E13DD3CE27164E1F009D4DAF /* CoreStore */ = { isa = XCSwiftPackageProductDependency; - package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */; + package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore.git" */; productName = CoreStore; }; E13DD3D227168E65009D4DAF /* Defaults */ = { diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift deleted file mode 100644 index fb059f00..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoPlayer.swift +++ /dev/null @@ -1,1190 +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 Combine -import Defaults -import GoogleCast -import JellyfinAPI -import MediaPlayer -import MobileVLCKit -import Stinsen -import SwiftUI -import SwiftyJSON - -enum PlayerDestination { - case remote - case local -} - -protocol PlayerViewControllerDelegate: AnyObject { - func hideLoadingView(_ viewController: PlayerViewController) - func showLoadingView(_ viewController: PlayerViewController) - func exitPlayer(_ viewController: PlayerViewController) -} - -class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRemoteMediaClientListener { - @RouterObject - var main: MainCoordinator.Router? - - weak var delegate: PlayerViewControllerDelegate? - - var cancellables = Set() - var mediaPlayer = VLCMediaPlayer() - - @IBOutlet weak var upNextView: UIView! - @IBOutlet weak var timeText: UILabel! - @IBOutlet weak var timeLeftText: UILabel! - @IBOutlet weak var videoContentView: UIView! - @IBOutlet weak var videoControlsView: UIView! - @IBOutlet weak var seekSlider: UISlider! - @IBOutlet weak var titleLabel: UILabel! - @IBOutlet weak var jumpBackButton: UIButton! - @IBOutlet weak var jumpForwardButton: UIButton! - @IBOutlet weak var playerSettingsButton: UIButton! - @IBOutlet weak var castButton: UIButton! - - var shouldShowLoadingScreen: Bool = false - var ssTargetValueOffset: Int = 0 - var ssStartValue: Int = 0 - var optionsVC: VideoPlayerSettingsView? - var castDeviceVC: VideoPlayerCastDeviceSelectorView? - - var paused: Bool = true - var lastTime: Float = 0.0 - var startTime: Int = 0 - var controlsAppearTime: Double = 0 - var isSeeking: Bool = false - - var playerDestination: PlayerDestination = .local - var discoveredCastDevices: [GCKDevice] = [] - var selectedCastDevice: GCKDevice? - var jellyfinCastChannel: GCKGenericChannel? - var remotePositionTicks: Int = 0 - private var castDiscoveryManager: GCKDiscoveryManager { - return GCKCastContext.sharedInstance().discoveryManager - } - - private var castSessionManager: GCKSessionManager { - return GCKCastContext.sharedInstance().sessionManager - } - - var hasSentRemoteSeek: Bool = false - - var selectedPlaybackSpeedIndex: Int = 3 - var selectedAudioTrack: Int32 = -1 - var selectedCaptionTrack: Int32 = -1 - var playSessionId: String = "" - var lastProgressReportTime: Double = 0 - var subtitleTrackArray: [Subtitle] = [] - var audioTrackArray: [AudioTrack] = [] - let playbackSpeeds: [Float] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] - var jumpForwardLength: VideoPlayerJumpLength { - return Defaults[.videoPlayerJumpForward] - } - - var jumpBackwardLength: VideoPlayerJumpLength { - return Defaults[.videoPlayerJumpBackward] - } - - var manifest = BaseItemDto() - var playbackItem = PlaybackItem() - var remoteTimeUpdateTimer: Timer? - var upNextViewModel = UpNextViewModel() - var lastOri: UIInterfaceOrientation? - - // MARK: IBActions - - @IBAction func seekSliderStart(_ sender: Any) { - if playerDestination == .local { - sendProgressReport(eventName: "pause") - mediaPlayer.pause() - } else { - isSeeking = true - } - } - - @IBAction func seekSliderValueChanged(_ sender: Any) { - let videoDuration = Double(manifest.runTimeTicks! / Int64(10_000_000)) - let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration) - let secondsScrubbedRemaining = videoDuration - secondsScrubbedTo - - timeText.text = calculateTimeText(from: secondsScrubbedTo) - timeLeftText.text = calculateTimeText(from: secondsScrubbedRemaining) - } - - private func calculateTimeText(from duration: Double) -> String { - let hours = floor(duration / 3600) - let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 - let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) - - let timeText: String - - if hours != 0 { - timeText = - "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } else { - timeText = - "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } - - return timeText - } - - @IBAction func seekSliderEnd(_ sender: Any) { - isSeeking = false - let videoPosition = playerDestination == .local ? Double(mediaPlayer.time.intValue / 1000) : - Double(remotePositionTicks / Int(10_000_000)) - let videoDuration = Double(manifest.runTimeTicks! / Int64(10_000_000)) - // Scrub is value from 0..1 - find position in video and add / or remove. - let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration) - let offset = secondsScrubbedTo - videoPosition - - if playerDestination == .local { - if offset > 0 { - mediaPlayer.jumpForward(Int32(offset)) - } else { - mediaPlayer.jumpBackward(Int32(abs(offset))) - } - mediaPlayer.play() - sendProgressReport(eventName: "unpause") - } else { - sendJellyfinCommand(command: "Seek", options: [ - "position": Int(secondsScrubbedTo) - ]) - } - } - - @IBAction func exitButtonPressed(_ sender: Any) { - sendStopReport() - mediaPlayer.stop() - - if castSessionManager.hasConnectedCastSession() { - castSessionManager.endSessionAndStopCasting(true) - } - - delegate?.exitPlayer(self) - } - - @IBAction func controlViewTapped(_ sender: Any) { - if playerDestination == .local { - videoControlsView.isHidden = true - if manifest.type == "Episode" { - smallNextUpView() - } - } - } - - @IBAction func contentViewTapped(_ sender: Any) { - if playerDestination == .local { - videoControlsView.isHidden = false - controlsAppearTime = CACurrentMediaTime() - } - } - - @IBAction func jumpBackTapped(_ sender: Any) { - if paused == false { - if playerDestination == .local { - mediaPlayer.jumpBackward(jumpBackwardLength.rawValue) - } else { - sendJellyfinCommand(command: "Seek", - options: ["position": (remotePositionTicks / 10_000_000) - Int(jumpBackwardLength.rawValue)]) - } - } - } - - @IBAction func jumpForwardTapped(_ sender: Any) { - if paused == false { - if playerDestination == .local { - mediaPlayer.jumpForward(jumpForwardLength.rawValue) - } else { - sendJellyfinCommand(command: "Seek", - options: ["position": (remotePositionTicks / 10_000_000) + Int(jumpForwardLength.rawValue)]) - } - } - } - - @IBOutlet weak var mainActionButton: UIButton! - @IBAction func mainActionButtonPressed(_ sender: Any) { - if paused { - if playerDestination == .local { - mediaPlayer.play() - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - paused = false - } else { - sendJellyfinCommand(command: "Unpause", options: [:]) - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - paused = false - } - } else { - if playerDestination == .local { - mediaPlayer.pause() - mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - paused = true - } else { - sendJellyfinCommand(command: "Pause", options: [:]) - mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - paused = true - } - } - } - - @IBAction func settingsButtonTapped(_ sender: UIButton) { - optionsVC = VideoPlayerSettingsView() - optionsVC?.playerDelegate = self - - optionsVC?.modalPresentationStyle = .popover - optionsVC?.popoverPresentationController?.sourceView = playerSettingsButton - - // Present the view controller (in a popover). - present(optionsVC!, animated: true) { - print("popover visible, pause playback") - self.mediaPlayer.pause() - self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - } - } - - // MARK: Cast methods - - @IBAction func castButtonPressed(_ sender: Any) { - if selectedCastDevice == nil { - LogManager.shared.log.debug("Presenting Cast modal") - castDeviceVC = VideoPlayerCastDeviceSelectorView() - castDeviceVC?.delegate = self - - castDeviceVC?.modalPresentationStyle = .popover - castDeviceVC?.popoverPresentationController?.sourceView = castButton - - // Present the view controller (in a popover). - present(castDeviceVC!, animated: true) { - self.mediaPlayer.pause() - self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - } - } else { - LogManager.shared.log.info("Stopping casting session: button was pressed.") - castSessionManager.endSessionAndStopCasting(true) - selectedCastDevice = nil - castButton.isEnabled = true - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - playerDestination = .local - } - } - - func castPopoverDismissed() { - LogManager.shared.log.debug("Cast modal dismissed") - castDeviceVC?.dismiss(animated: true, completion: nil) - if playerDestination == .local { - mediaPlayer.play() - } - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - } - - func castDeviceChanged() { - LogManager.shared.log.debug("Cast device changed") - if selectedCastDevice != nil { - LogManager.shared.log.debug("New device: \(selectedCastDevice?.friendlyName ?? "UNKNOWN")") - playerDestination = .remote - castSessionManager.add(self) - castSessionManager.startSession(with: selectedCastDevice!) - } - } - - // MARK: Cast End - - func settingsPopoverDismissed() { - optionsVC?.dismiss(animated: true, completion: nil) - if playerDestination == .local { - mediaPlayer.play() - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - } - } - - func setupNowPlayingCC() { - let commandCenter = MPRemoteCommandCenter.shared() - commandCenter.playCommand.isEnabled = true - commandCenter.pauseCommand.isEnabled = true - commandCenter.seekForwardCommand.isEnabled = true - commandCenter.seekBackwardCommand.isEnabled = true - commandCenter.changePlaybackPositionCommand.isEnabled = true - - // Add handler for Pause Command - commandCenter.pauseCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.pause() - self.sendProgressReport(eventName: "pause") - } else { - self.sendJellyfinCommand(command: "Pause", options: [:]) - } - self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) - return .success - } - - // Add handler for Play command - commandCenter.playCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.play() - self.sendProgressReport(eventName: "unpause") - } else { - self.sendJellyfinCommand(command: "Unpause", options: [:]) - } - self.mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - return .success - } - - // Add handler for FF command - commandCenter.seekForwardCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.jumpForward(30) - self.sendProgressReport(eventName: "timeupdate") - } else { - self.sendJellyfinCommand(command: "Seek", options: ["position": (self.remotePositionTicks / 10_000_000) + 30]) - } - return .success - } - - // Add handler for RW command - commandCenter.seekBackwardCommand.addTarget { _ in - if self.playerDestination == .local { - self.mediaPlayer.jumpBackward(15) - self.sendProgressReport(eventName: "timeupdate") - } else { - self.sendJellyfinCommand(command: "Seek", options: ["position": (self.remotePositionTicks / 10_000_000) - 15]) - } - return .success - } - - // Scrubber - commandCenter.changePlaybackPositionCommand.addTarget { [weak self] (remoteEvent) -> MPRemoteCommandHandlerStatus in - guard let self = self else { return .commandFailed } - - if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent { - let targetSeconds = event.positionTime - - let videoPosition = Double(self.mediaPlayer.time.intValue) - let offset = targetSeconds - videoPosition - - if self.playerDestination == .local { - if offset > 0 { - self.mediaPlayer.jumpForward(Int32(offset) / 1000) - } else { - self.mediaPlayer.jumpBackward(Int32(abs(offset)) / 1000) - } - self.sendProgressReport(eventName: "unpause") - } else {} - - return .success - } else { - return .commandFailed - } - } - - var nowPlayingInfo = [String: Any]() - - var runTicks = 0 - var playbackTicks = 0 - - if let ticks = manifest.runTimeTicks { - runTicks = Int(ticks / 10_000_000) - } - - if let ticks = manifest.userData?.playbackPositionTicks { - playbackTicks = Int(ticks / 10_000_000) - } - - nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video" - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 - nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks - - if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 200)) { - if let artworkImage = UIImage(data: imageData as Data) { - let artwork = MPMediaItemArtwork(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in - artworkImage - }) - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork - } - } - - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - - UIApplication.shared.beginReceivingRemoteControlEvents() - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - if manifest.type == "Movie" { - titleLabel.text = manifest.name ?? "" - } else { - titleLabel.text = "\(L10n.seasonAndEpisode(String(manifest.parentIndexNumber ?? 0), String(manifest.indexNumber ?? 0))) “\(manifest.name ?? "")”" - - setupNextUpView() - upNextViewModel.delegate = self - } - - DispatchQueue.main.async { - self.lastOri = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? nil - AppDelegate.orientationLock = .landscape - - if self.lastOri != nil { - if !self.lastOri!.isLandscape { - UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - } - } - - NotificationCenter.default.addObserver(self, selector: #selector(didChangedOrientation), - name: UIDevice.orientationDidChangeNotification, object: nil) - } - - @objc func didChangedOrientation() { - lastOri = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation - } - - func mediaHasStartedPlaying() { - castButton.isHidden = true - let discoveryCriteria = GCKDiscoveryCriteria(applicationID: "F007D354") - let gckCastOptions = GCKCastOptions(discoveryCriteria: discoveryCriteria) - GCKCastContext.setSharedInstanceWith(gckCastOptions) - castDiscoveryManager.passiveScan = true - castDiscoveryManager.add(self) - castDiscoveryManager.startDiscovery() - } - - func didUpdateDeviceList() { - let totalDevices = castDiscoveryManager.deviceCount - discoveredCastDevices = [] - if totalDevices > 0 { - for i in 0 ... totalDevices - 1 { - let device = castDiscoveryManager.device(at: i) - discoveredCastDevices.append(device) - } - } - - if !discoveredCastDevices.isEmpty { - castButton.isHidden = false - castButton.isEnabled = true - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - } else { - castButton.isHidden = true - castButton.isEnabled = false - castButton.setImage(nil, for: .normal) - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - tabBarController?.tabBar.isHidden = false - navigationController?.isNavigationBarHidden = false - overrideUserInterfaceStyle = .unspecified - DispatchQueue.main.async { - if self.lastOri != nil { - AppDelegate.orientationLock = .all - UIDevice.current.setValue(self.lastOri!.rawValue, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - } - } - - // MARK: viewDidAppear - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - overrideUserInterfaceStyle = .dark - tabBarController?.tabBar.isHidden = true - navigationController?.isNavigationBarHidden = true - - mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) - // mediaPlayer.wrappedValue.perform(Selector(("setTextRendererFont:")), with: "Copperplate") - - mediaPlayer.delegate = self - mediaPlayer.drawable = videoContentView - - setupMediaPlayer() - setupJumpLengthButtons() - } - - func setupMediaPlayer() { - // Fetch max bitrate from UserDefaults depending on current connection mode - let maxBitrate = Defaults[.inNetworkBandwidth] - print(maxBitrate) - // Build a device profile - let builder = DeviceProfileBuilder() - builder.setMaxBitrate(bitrate: maxBitrate) - let profile = builder.buildProfile() - let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id, maxStreamingBitrate: Int(maxBitrate), - startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, - autoOpenLiveStream: true) - - DispatchQueue.global(qos: .userInitiated).async { [self] in - delegate?.showLoadingView(self) - MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: Int(maxBitrate), - startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, - playbackInfoDto: playbackInfo) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case let .failure(error): - if let err = error as? ErrorResponse { - switch err { - case .error(401, _, _, _): - self.delegate?.exitPlayer(self) - SessionManager.main.logout() - case .error: - self.delegate?.exitPlayer(self) - } - } - } - }, receiveValue: { [self] response in - dump(response) - playSessionId = response.playSessionId ?? "" - let mediaSource = response.mediaSources!.first.self! - if mediaSource.transcodingUrl != nil { - // Item is being transcoded by request of server - let streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(mediaSource.transcodingUrl!)") - let item = PlaybackItem() - item.videoType = .transcode - item.videoUrl = streamURL! - - let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", - languageCode: "") - subtitleTrackArray.append(disableSubtitleTrack) - - // Loop through media streams and add to array - for stream in mediaSource.mediaStreams ?? [] { - if stream.type == .subtitle { - var deliveryUrl: URL? - if stream.deliveryMethod == .external { - deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl ?? "")")! - } else { - deliveryUrl = nil - } - let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, - delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", - languageCode: stream.language ?? "") - - if subtitle.delivery != .encode { - subtitleTrackArray.append(subtitle) - } - } - - if stream.type == .audio { - let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", - id: Int32(stream.index!)) - if stream.isDefault! == true { - selectedAudioTrack = Int32(stream.index!) - } - audioTrackArray.append(subtitle) - } - } - - if selectedAudioTrack == -1 { - if !audioTrackArray.isEmpty { - selectedAudioTrack = audioTrackArray[0].id - } - } - - self.sendPlayReport() - playbackItem = item - } else { - // TODO: todo - // Item will be directly played by the client. - let streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")! -// URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag ?? "")")! - - let item = PlaybackItem() - item.videoUrl = streamURL - item.videoType = .directPlay - - let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", - languageCode: "") - subtitleTrackArray.append(disableSubtitleTrack) - - // Loop through media streams and add to array - for stream in mediaSource.mediaStreams ?? [] { - if stream.type == .subtitle { - var deliveryUrl: URL? - if stream.deliveryMethod == .external { - deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl!)")! - } else { - deliveryUrl = nil - } - let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, - delivery: stream.deliveryMethod!, codec: stream.codec!, - languageCode: stream.language ?? "") - - if subtitle.delivery != .encode { - subtitleTrackArray.append(subtitle) - } - } - - if stream.type == .audio { - let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", - id: Int32(stream.index!)) - if stream.isDefault! == true { - selectedAudioTrack = Int32(stream.index!) - } - audioTrackArray.append(subtitle) - } - } - - if selectedAudioTrack == -1 { - if !audioTrackArray.isEmpty { - selectedAudioTrack = audioTrackArray[0].id - } - } - - self.sendPlayReport() - playbackItem = item - - // self.setupNowPlayingCC() - } - - startLocalPlaybackEngine(true) - }) - .store(in: &cancellables) - } - } - - private func setupJumpLengthButtons() { - let buttonFont = UIFont.systemFont(ofSize: 35, weight: .regular) - jumpForwardButton.setImage(jumpForwardLength.generateForwardImage(with: buttonFont), for: .normal) - jumpBackButton.setImage(jumpBackwardLength.generateBackwardImage(with: buttonFont), for: .normal) - } - - func setupTracksForPreferredDefaults() { - subtitleTrackArray.forEach { subtitle in - if Defaults[.isAutoSelectSubtitles] { - if Defaults[.autoSelectSubtitlesLangCode] == "Auto", - subtitle.languageCode.contains(Locale.current.languageCode ?? "") { - selectedCaptionTrack = subtitle.id - mediaPlayer.currentVideoSubTitleIndex = subtitle.id - } else if subtitle.languageCode.contains(Defaults[.autoSelectSubtitlesLangCode]) { - selectedCaptionTrack = subtitle.id - mediaPlayer.currentVideoSubTitleIndex = subtitle.id - } - } - } - - audioTrackArray.forEach { audio in - if audio.languageCode.contains(Defaults[.autoSelectAudioLangCode]) { - selectedAudioTrack = audio.id - mediaPlayer.currentAudioTrackIndex = audio.id - } - } - } - - func startLocalPlaybackEngine(_ fetchCaptions: Bool) { - mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl) - mediaPlayer.play() - sendPlayReport() - - // 1 second = 10,000,000 ticks - var startTicks: Int64 = 0 - if remotePositionTicks == 0 { - startTicks = manifest.userData?.playbackPositionTicks ?? 0 - } else { - startTicks = Int64(remotePositionTicks) - } - - if startTicks != 0 { - let videoPosition = Double(mediaPlayer.time.intValue / 1000) - let secondsScrubbedTo = startTicks / 10_000_000 - let offset = secondsScrubbedTo - Int64(videoPosition) - if offset > 0 { - mediaPlayer.jumpForward(Int32(offset)) - } else { - mediaPlayer.jumpBackward(Int32(abs(offset))) - } - } - - if fetchCaptions { - mediaPlayer.pause() - subtitleTrackArray.forEach { sub in - // stupid fxcking jeff decides to re-encode these when added. - // only add playback streams when codec not supported by VLC. - if sub.id != -1, sub.delivery == .external, sub.codec != "subrip" { - mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false) - } - } - } - - mediaHasStartedPlaying() - delegate?.hideLoadingView(self) - - videoContentView.setNeedsLayout() - videoContentView.setNeedsDisplay() - view.setNeedsLayout() - view.setNeedsDisplay() - videoControlsView.setNeedsLayout() - videoControlsView.setNeedsDisplay() - - mediaPlayer.pause() - mediaPlayer.play() - setupTracksForPreferredDefaults() - } - - // MARK: VideoPlayerSettings Delegate - - func subtitleTrackChanged(newTrackID: Int32) { - selectedCaptionTrack = newTrackID - mediaPlayer.currentVideoSubTitleIndex = newTrackID - } - - func audioTrackChanged(newTrackID: Int32) { - selectedAudioTrack = newTrackID - mediaPlayer.currentAudioTrackIndex = newTrackID - } - - func playbackSpeedChanged(index: Int) { - selectedPlaybackSpeedIndex = index - mediaPlayer.rate = playbackSpeeds[index] - } - - func smallNextUpView() { - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) { [self] in - upNextViewModel.largeView = false - } - } - - func setupNextUpView() { - getNextEpisode() - - // Create the swiftUI view - let contentView = UIHostingController(rootView: VideoUpNextView(viewModel: upNextViewModel)) - upNextView.addSubview(contentView.view) - contentView.view.backgroundColor = .clear - contentView.view.translatesAutoresizingMaskIntoConstraints = false - contentView.view.topAnchor.constraint(equalTo: upNextView.topAnchor).isActive = true - contentView.view.bottomAnchor.constraint(equalTo: upNextView.bottomAnchor).isActive = true - contentView.view.leftAnchor.constraint(equalTo: upNextView.leftAnchor).isActive = true - contentView.view.rightAnchor.constraint(equalTo: upNextView.rightAnchor).isActive = true - } - - func getNextEpisode() { - TvShowsAPI.getEpisodes(seriesId: manifest.seriesId!, userId: SessionManager.main.currentLogin.user.id, startItemId: manifest.id, - limit: 2) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { [self] response in - // Returns 2 items, the first is the current episode - // The second is the next episode - if let item = response.items?.last { - self.upNextViewModel.item = item - } - }) - .store(in: &cancellables) - } - - func setPlayerToNextUp() { - mediaPlayer.stop() - - ssTargetValueOffset = 0 - ssStartValue = 0 - - paused = true - lastTime = 0.0 - startTime = 0 - controlsAppearTime = 0 - isSeeking = false - - remotePositionTicks = 0 - - selectedPlaybackSpeedIndex = 3 - selectedAudioTrack = -1 - selectedCaptionTrack = -1 - playSessionId = "" - lastProgressReportTime = 0 - subtitleTrackArray = [] - audioTrackArray = [] - - manifest = upNextViewModel.item! - playbackItem = PlaybackItem() - - upNextViewModel.item = nil - - upNextView.isHidden = true - shouldShowLoadingScreen = true - videoControlsView.isHidden = true - - titleLabel.text = "\(L10n.seasonAndEpisode(String(manifest.parentIndexNumber ?? 0), String(manifest.indexNumber ?? 0))) “\(manifest.name ?? "")”" - - setupMediaPlayer() - getNextEpisode() - } -} - -// MARK: - GCKGenericChannelDelegate - -extension PlayerViewController: GCKGenericChannelDelegate { - @objc func updateRemoteTime() { - castButton.setImage(UIImage(named: "CastConnected"), for: .normal) - if !paused { - remotePositionTicks = remotePositionTicks + 2_000_000 // add 0.2 secs every timer evt. - } - - if isSeeking == false { - let positiveSeconds = Double(remotePositionTicks / 10_000_000) - let remainingSeconds = Double((manifest.runTimeTicks! - Int64(remotePositionTicks)) / 10_000_000) - - timeText.text = calculateTimeText(from: positiveSeconds) - timeLeftText.text = calculateTimeText(from: remainingSeconds) - - let playbackProgress = Float(remotePositionTicks) / Float(manifest.runTimeTicks!) - seekSlider.setValue(playbackProgress, animated: true) - } - } - - func cast(_ channel: GCKGenericChannel, didReceiveTextMessage message: String, withNamespace protocolNamespace: String) { - if let data = message.data(using: .utf8) { - if let json = try? JSON(data: data) { - let messageType = json["type"].string ?? "" - if messageType == "playbackprogress" { - dump(json) - if remotePositionTicks > 100 { - if hasSentRemoteSeek == false { - hasSentRemoteSeek = true - sendJellyfinCommand(command: "Seek", options: [ - "position": Int(Float(manifest.runTimeTicks! / 10_000_000) * mediaPlayer.position) - ]) - } - } - paused = json["data"]["PlayState"]["IsPaused"].boolValue - remotePositionTicks = json["data"]["PlayState"]["PositionTicks"].int ?? 0 - if remoteTimeUpdateTimer == nil { - remoteTimeUpdateTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(updateRemoteTime), - userInfo: nil, repeats: true) - } - } - } - } - } - - func sendJellyfinCommand(command: String, options: [String: Any]) { - let payload: [String: Any] = [ - "options": options, - "command": command, - "userId": SessionManager.main.currentLogin.user.id, -// "deviceId": SessionManager.main.currentLogin.de.deviceID, - "accessToken": SessionManager.main.currentLogin.user.accessToken, - "serverAddress": SessionManager.main.currentLogin.server.currentURI, - "serverId": SessionManager.main.currentLogin.server.id, - "serverVersion": "10.8.0", - "receiverName": castSessionManager.currentCastSession!.device.friendlyName!, - "subtitleBurnIn": false - ] - let jsonData = JSON(payload) - - jellyfinCastChannel?.sendTextMessage(jsonData.rawString()!, error: nil) - - if command == "Seek" { - remotePositionTicks = remotePositionTicks + ((options["position"] as! Int) * 10_000_000) - // Send playback report as Jellyfin Chromecast isn't smarter than a rock. - let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, - mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), - subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: paused, isMuted: false, - positionTicks: Int64(remotePositionTicks), playbackStartTimeTicks: Int64(startTime), - volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, - liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, - nowPlayingQueue: [], playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback progress report sent!") - }) - .store(in: &cancellables) - } - } -} - -// MARK: - GCKSessionManagerListener - -extension PlayerViewController: GCKSessionManagerListener { - func sessionDidStart(manager: GCKSessionManager, didStart session: GCKCastSession) { - sendStopReport() - mediaPlayer.stop() - - playerDestination = .remote - videoContentView.isHidden = true - videoControlsView.isHidden = false - castButton.setImage(UIImage(named: "CastConnected"), for: .normal) - manager.currentCastSession?.start() - - jellyfinCastChannel!.delegate = self - session.add(jellyfinCastChannel!) - - if let client = session.remoteMediaClient { - client.add(self) - } - - let playNowOptions: [String: Any] = [ - "items": [[ - "Id": manifest.id!, - "ServerId": SessionManager.main.currentLogin.server.id, - "Name": manifest.name!, - "Type": manifest.type!, - "MediaType": manifest.mediaType!, - "IsFolder": manifest.isFolder! - ]] - ] - sendJellyfinCommand(command: "PlayNow", options: playNowOptions) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKCastSession) { - jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") - sessionDidStart(manager: sessionManager, didStart: session) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didResumeCastSession session: GCKCastSession) { - jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") - sessionDidStart(manager: sessionManager, didStart: session) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKCastSession, withError error: Error) { - LogManager.shared.log.error((error as NSError).debugDescription) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didEnd session: GCKCastSession, withError error: Error?) { - if error != nil { - LogManager.shared.log.error((error! as NSError).debugDescription) - } - - playerDestination = .local - videoContentView.isHidden = false - remoteTimeUpdateTimer?.invalidate() - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - startLocalPlaybackEngine(false) - } - - func sessionManager(_ sessionManager: GCKSessionManager, didSuspend session: GCKCastSession, with reason: GCKConnectionSuspendReason) { - playerDestination = .local - videoContentView.isHidden = false - remoteTimeUpdateTimer?.invalidate() - castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) - startLocalPlaybackEngine(false) - } -} - -// MARK: - VLCMediaPlayer Delegates - -extension PlayerViewController: VLCMediaPlayerDelegate { - func mediaPlayerStateChanged(_ aNotification: Notification!) { - let currentState: VLCMediaPlayerState = mediaPlayer.state - switch currentState { - case .stopped: - LogManager.shared.log.debug("Player state changed: STOPPED") - case .ended: - LogManager.shared.log.debug("Player state changed: ENDED") - case .playing: - LogManager.shared.log.debug("Player state changed: PLAYING") - sendProgressReport(eventName: "unpause") - delegate?.hideLoadingView(self) - paused = false - case .paused: - LogManager.shared.log.debug("Player state changed: PAUSED") - paused = true - case .opening: - LogManager.shared.log.debug("Player state changed: OPENING") - case .buffering: - LogManager.shared.log.debug("Player state changed: BUFFERING") - delegate?.showLoadingView(self) - case .error: - LogManager.shared.log.error("Video had error.") - sendStopReport() - case .esAdded: - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - @unknown default: - break - } - } - - func mediaPlayerTimeChanged(_ aNotification: Notification!) { - let time = mediaPlayer.position - if abs(time - lastTime) > 0.00005 { - paused = false - mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) - seekSlider.setValue(mediaPlayer.position, animated: true) - delegate?.hideLoadingView(self) - - if manifest.type == "Episode", upNextViewModel.item != nil { - if time > 0.96 { - upNextView.isHidden = false - jumpForwardButton.isHidden = true - } else { - upNextView.isHidden = true - jumpForwardButton.isHidden = false - } - } - - timeText.text = mediaPlayer.time.stringValue - timeLeftText.text = String(mediaPlayer.remainingTime.stringValue.dropFirst()) - - if CACurrentMediaTime() - controlsAppearTime > 5 { - smallNextUpView() - UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: { - self.videoControlsView.alpha = 0.0 - }, completion: { (_: Bool) in - self.videoControlsView.isHidden = true - self.videoControlsView.alpha = 1 - }) - controlsAppearTime = 999_999_999_999_999 - } - lastTime = time - } - - if CACurrentMediaTime() - lastProgressReportTime > 5 { - mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack - sendProgressReport(eventName: "timeupdate") - lastProgressReportTime = CACurrentMediaTime() - } - } -} - -struct VideoPlayerView: View { - var item: BaseItemDto - @State private var isLoading = false - - var body: some View { - // Loading UI needs to be moved into ViewController later - LoadingViewNoBlur(isShowing: $isLoading) { - PreferenceUIHostingControllerView { - VLCPlayerWithControls(item: item, loadBinding: $isLoading) - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) - .statusBar(hidden: true) - .edgesIgnoringSafeArea(.all) - .prefersHomeIndicatorAutoHidden(true) - }.edgesIgnoringSafeArea(.all) - } - } -} - -// MARK: End VideoPlayerVC - -struct VLCPlayerWithControls: UIViewControllerRepresentable { - var item: BaseItemDto - @RouterObject var playerRouter: VideoPlayerCoordinator.Router? - - let loadBinding: Binding - - class Coordinator: NSObject, PlayerViewControllerDelegate { - var parent: VLCPlayerWithControls - let loadBinding: Binding - - init(parent: VLCPlayerWithControls, loadBinding: Binding) { - self.parent = parent - self.loadBinding = loadBinding - } - - func hideLoadingView(_ viewController: PlayerViewController) { - loadBinding.wrappedValue = false - } - - func showLoadingView(_ viewController: PlayerViewController) { - loadBinding.wrappedValue = true - } - - func exitPlayer(_ viewController: PlayerViewController) { - parent.playerRouter?.dismissCoordinator() - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self, loadBinding: loadBinding) - } - - typealias UIViewControllerType = PlayerViewController - func makeUIViewController(context: UIViewControllerRepresentableContext) -> VLCPlayerWithControls - .UIViewControllerType { - let storyboard = UIStoryboard(name: "VideoPlayer", bundle: nil) - let customViewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! PlayerViewController - customViewController.manifest = item - customViewController.delegate = context.coordinator - return customViewController - } - - func updateUIViewController(_ uiViewController: VLCPlayerWithControls.UIViewControllerType, - context: UIViewControllerRepresentableContext) {} -} - -// MARK: - Play State Update Methods - -extension PlayerViewController { - func sendProgressReport(eventName: String) { - if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" { - var ticks = Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)) - if ticks == 0 { - ticks = manifest.userData?.playbackPositionTicks ?? 0 - } - - let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, - mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), - subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: mediaPlayer.state == .paused, - isMuted: false, positionTicks: ticks, playbackStartTimeTicks: Int64(startTime), - volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, - liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, - nowPlayingQueue: [], playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback progress report sent!") - }) - .store(in: &cancellables) - } - } - - func sendStopReport() { - let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, - positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), liveStreamId: nil, - playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", - nowPlayingQueue: []) - - PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback stop report sent!") - }) - .store(in: &cancellables) - } - - func sendPlayReport() { - startTime = Int(Date().timeIntervalSince1970) * 10_000_000 - - print("sending play report!") - - let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, - mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), - subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, - positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), - volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, - liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], - playlistItemId: "playlistItem0") - - PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) - .sink(receiveCompletion: { result in - print(result) - }, receiveValue: { _ in - print("Playback start report sent!") - }) - .store(in: &cancellables) - } -} - -extension UINavigationController { - override open var childForHomeIndicatorAutoHidden: UIViewController? { - return nil - } -} diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift index 70c35dfa..315f0b93 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift @@ -28,15 +28,19 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { if nativeVideoPlayer { - NativePlayerView(viewModel: viewModel) - .navigationBarHidden(true) - .statusBar(hidden: true) - .ignoresSafeArea() + PreferenceUIHostingControllerView { + NativePlayerView(viewModel: self.viewModel) + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + }.ignoresSafeArea() } else { - VLCPlayerView(viewModel: viewModel) - .navigationBarHidden(true) - .statusBar(hidden: true) - .ignoresSafeArea() + PreferenceUIHostingControllerView { + VLCPlayerView(viewModel: self.viewModel) + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + }.ignoresSafeArea() } } } From a566415ee1eaa13a1ab67197da65a25ebadd49ee Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 14:23:35 -0700 Subject: [PATCH 03/62] Fixes and port over 2 --- JellyfinPlayer.xcodeproj/project.pbxproj | 101 ++++++---- .../xcshareddata/swiftpm/Package.resolved | 9 + .../VLCPlayerCompactOverlayView.swift | 185 ++++++++++-------- .../{ => Overlays}/VLCPlayerOverlayView.swift | 0 .../Overlays/VideoPlayerOverlay.swift | 14 ++ .../VideoPlayer/VLCPlayerViewController.swift | 16 +- Shared/ViewModels/VideoPlayerViewModel.swift | 5 +- 7 files changed, 205 insertions(+), 125 deletions(-) rename JellyfinPlayer/Views/VideoPlayer/{ => Overlays}/VLCPlayerCompactOverlayView.swift (61%) rename JellyfinPlayer/Views/VideoPlayer/{ => Overlays}/VLCPlayerOverlayView.swift (100%) create mode 100644 JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index c0459157..8bdc639d 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -121,8 +121,6 @@ 5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624C21742685CF60007F1390 /* SearchablePickerView.swift */; }; 5398514726B64E4100101B49 /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; }; 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */; }; - 53A431BD266B0FF20016769F /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 53A431BC266B0FF20016769F /* JellyfinAPI */; }; - 53A431BF266B0FFE0016769F /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 53A431BE266B0FFE0016769F /* JellyfinAPI */; }; 53A83C33268A309300DF3D92 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A83C32268A309300DF3D92 /* LibraryView.swift */; }; 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53ABFDDB267972BF00886593 /* TVServices.framework */; }; 53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5742678C33500530A6E /* LibraryListViewModel.swift */; }; @@ -179,7 +177,6 @@ 628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628B95262670CABD0091AF3B /* NextUpWidget.swift */; }; 628B95292670CABE0091AF3B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 628B95282670CABE0091AF3B /* Assets.xcassets */; }; 628B952D2670CABE0091AF3B /* WidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 628B95202670CABD0091AF3B /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 628B95352670CAEA0091AF3B /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 628B95342670CAEA0091AF3B /* JellyfinAPI */; }; 628B95372670CB800091AF3B /* JellyfinWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628B95362670CB800091AF3B /* JellyfinWidget.swift */; }; 628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 62C29E9B26D0FE4200C1D2E7 /* Stinsen */; }; @@ -233,6 +230,10 @@ C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; }; C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; }; E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */; }; + E10EAA45277BB646000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA44277BB646000269ED /* JellyfinAPI */; }; + E10EAA47277BB670000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA46277BB670000269ED /* JellyfinAPI */; }; + E10EAA4A277BB6F5000269ED /* VideoPlayerOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA49277BB6F5000269ED /* VideoPlayerOverlay.swift */; }; + E10EAA4D277BB716000269ED /* Sliders in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA4C277BB716000269ED /* Sliders */; }; E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6E2718CDBA006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; @@ -558,6 +559,7 @@ C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; + E10EAA49277BB6F5000269ED /* VideoPlayerOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerOverlay.swift; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailCoordinator.swift; sourceTree = ""; }; E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; @@ -638,7 +640,6 @@ E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */, E1218CA0271A2CF200EA0737 /* Nuke in Frameworks */, 6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */, - 53A431BF266B0FFE0016769F /* JellyfinAPI in Frameworks */, 535870912669D7A800D05A09 /* Introspect in Frameworks */, 536D3D84267BEA550004248C /* ParallaxView in Frameworks */, 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */, @@ -656,6 +657,7 @@ files = ( E13DD3D327168E65009D4DAF /* Defaults in Frameworks */, 53649AAD269CFAEA00A2D8B7 /* Puppy in Frameworks */, + E10EAA4D277BB716000269ED /* Sliders in Frameworks */, 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */, E1A99999271A3429008E78C0 /* SwiftUICollection in Frameworks */, E1218C9A271A26BA00EA0737 /* Nuke in Frameworks */, @@ -664,7 +666,7 @@ E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */, 625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */, E1B6DCE8271A23780015B715 /* CombineExt in Frameworks */, - 53A431BD266B0FF20016769F /* JellyfinAPI in Frameworks */, + E10EAA45277BB646000269ED /* JellyfinAPI in Frameworks */, 560CA59B3956A4CA13EDAC05 /* Pods_JellyfinPlayer_iOS.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -679,8 +681,8 @@ 53649AB5269D423A00A2D8B7 /* Puppy in Frameworks */, 536D3D7D267BD5F90004248C /* ActivityIndicator in Frameworks */, E13DD3CF27164E1F009D4DAF /* CoreStore in Frameworks */, - 628B95352670CAEA0091AF3B /* JellyfinAPI in Frameworks */, E1218C9C271A26C400EA0737 /* Nuke in Frameworks */, + E10EAA47277BB670000269ED /* JellyfinAPI in Frameworks */, EABFD69FA6D5DBB248A494AA /* Pods_WidgetExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1174,6 +1176,16 @@ path = Pods; sourceTree = ""; }; + E10EAA48277BB6D7000269ED /* Overlays */ = { + isa = PBXGroup; + children = ( + E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */, + E10EAA49277BB6F5000269ED /* VideoPlayerOverlay.swift */, + E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */, + ); + path = Overlays; + sourceTree = ""; + }; E12186DF2718F2030010884C /* App */ = { isa = PBXGroup; children = ( @@ -1300,8 +1312,7 @@ E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */, - E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */, - E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */, + E10EAA48277BB6D7000269ED /* Overlays */, E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */, ); path = VideoPlayer; @@ -1393,7 +1404,6 @@ name = "JellyfinPlayer tvOS"; packageProductDependencies = ( 535870902669D7A800D05A09 /* Introspect */, - 53A431BE266B0FFE0016769F /* JellyfinAPI */, 53ABFDEC26799D7700886593 /* ActivityIndicator */, 536D3D83267BEA550004248C /* ParallaxView */, 53649AAE269CFAF600A2D8B7 /* Puppy */, @@ -1430,7 +1440,6 @@ name = "JellyfinPlayer iOS"; packageProductDependencies = ( 53352570265EA0A0006CCA86 /* Introspect */, - 53A431BC266B0FF20016769F /* JellyfinAPI */, 625CB5792678C4A400530A6E /* ActivityIndicator */, 53649AAC269CFAEA00A2D8B7 /* Puppy */, 62C29E9B26D0FE4200C1D2E7 /* Stinsen */, @@ -1440,6 +1449,8 @@ E1B6DCE9271A23880015B715 /* SwiftyJSON */, E1218C99271A26BA00EA0737 /* Nuke */, E1A99998271A3429008E78C0 /* SwiftUICollection */, + E10EAA44277BB646000269ED /* JellyfinAPI */, + E10EAA4C277BB716000269ED /* Sliders */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* JellyfinPlayer iOS.app */; @@ -1460,12 +1471,12 @@ ); name = WidgetExtension; packageProductDependencies = ( - 628B95342670CAEA0091AF3B /* JellyfinAPI */, 536D3D7C267BD5F90004248C /* ActivityIndicator */, 53649AB4269D423A00A2D8B7 /* Puppy */, E13DD3CE27164E1F009D4DAF /* CoreStore */, E13DD3DC27175CE3009D4DAF /* Defaults */, E1218C9B271A26C400EA0737 /* Nuke */, + E10EAA46277BB670000269ED /* JellyfinAPI */, ); productName = WidgetExtensionExtension; productReference = 628B95202670CABD0091AF3B /* WidgetExtension.appex */; @@ -1519,17 +1530,18 @@ mainGroup = 5377CBE8263B596A003A4E83; packageReferences = ( 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, - 53A431BB266B0FF20016769F /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */, 536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */, 53649AAB269CFAEA00A2D8B7 /* XCRemoteSwiftPackageReference "Puppy" */, 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */, - E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore.git" */, + E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */, E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */, E1267D42271A212C003C492E /* XCRemoteSwiftPackageReference "CombineExt" */, E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, E1218C98271A26BA00EA0737 /* XCRemoteSwiftPackageReference "Nuke" */, C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */, + E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, + E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -2035,6 +2047,7 @@ E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, + E10EAA4A277BB6F5000269ED /* VideoPlayerOverlay.swift in Sources */, 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */, 625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */, ); @@ -2584,14 +2597,6 @@ minimumVersion = 3.0.0; }; }; - 53A431BB266B0FF20016769F /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/jellyfin/jellyfin-sdk-swift"; - requirement = { - branch = main; - kind = branch; - }; - }; 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/duyquang91/ActivityIndicator"; @@ -2616,6 +2621,22 @@ kind = branch; }; }; + E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jellyfin/jellyfin-sdk-swift"; + requirement = { + branch = main; + kind = branch; + }; + }; + E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/spacenation/swiftui-sliders"; + requirement = { + branch = master; + kind = branch; + }; + }; E1218C98271A26BA00EA0737 /* XCRemoteSwiftPackageReference "Nuke" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/Nuke"; @@ -2632,7 +2653,7 @@ minimumVersion = 1.0.0; }; }; - E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore.git" */ = { + E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/JohnEstropia/CoreStore.git"; requirement = { @@ -2694,16 +2715,6 @@ package = 536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */; productName = ParallaxView; }; - 53A431BC266B0FF20016769F /* JellyfinAPI */ = { - isa = XCSwiftPackageProductDependency; - package = 53A431BB266B0FF20016769F /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; - productName = JellyfinAPI; - }; - 53A431BE266B0FFE0016769F /* JellyfinAPI */ = { - isa = XCSwiftPackageProductDependency; - package = 53A431BB266B0FF20016769F /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; - productName = JellyfinAPI; - }; 53ABFDEC26799D7700886593 /* ActivityIndicator */ = { isa = XCSwiftPackageProductDependency; package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */; @@ -2719,16 +2730,26 @@ package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */; productName = ActivityIndicator; }; - 628B95342670CAEA0091AF3B /* JellyfinAPI */ = { - isa = XCSwiftPackageProductDependency; - package = 53A431BB266B0FF20016769F /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; - productName = JellyfinAPI; - }; 62C29E9B26D0FE4200C1D2E7 /* Stinsen */ = { isa = XCSwiftPackageProductDependency; package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; productName = Stinsen; }; + E10EAA44277BB646000269ED /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; + E10EAA46277BB670000269ED /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; + E10EAA4C277BB716000269ED /* Sliders */ = { + isa = XCSwiftPackageProductDependency; + package = E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */; + productName = Sliders; + }; E12186DD2718F1C50010884C /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; @@ -2756,17 +2777,17 @@ }; E13DD3C52716499E009D4DAF /* CoreStore */ = { isa = XCSwiftPackageProductDependency; - package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore.git" */; + package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */; productName = CoreStore; }; E13DD3CC27164CA7009D4DAF /* CoreStore */ = { isa = XCSwiftPackageProductDependency; - package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore.git" */; + package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */; productName = CoreStore; }; E13DD3CE27164E1F009D4DAF /* CoreStore */ = { isa = XCSwiftPackageProductDependency; - package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore.git" */; + package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */; productName = CoreStore; }; E13DD3D227168E65009D4DAF /* Defaults */ = { diff --git a/JellyfinPlayer.xcworkspace/xcshareddata/swiftpm/Package.resolved b/JellyfinPlayer.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5378c461..39771200 100644 --- a/JellyfinPlayer.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/JellyfinPlayer.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -109,6 +109,15 @@ "version": "0.1.3" } }, + { + "package": "Sliders", + "repositoryURL": "https://github.com/spacenation/swiftui-sliders", + "state": { + "branch": "master", + "revision": "518bed3bfc7bd522f3c49404a0d1efb98fa1bf2c", + "version": null + } + }, { "package": "SwiftUICollection", "repositoryURL": "https://github.com/ABJC/SwiftUICollection", diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift similarity index 61% rename from JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift rename to JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 67dceb15..756a387b 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -7,10 +7,11 @@ import Combine import MobileVLCKit +import Sliders import SwiftUI import JellyfinAPI -struct VLCPlayerCompactOverlayView: View { +struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { @ObservedObject var viewModel: VideoPlayerViewModel @@ -35,23 +36,22 @@ struct VLCPlayerCompactOverlayView: View { VStack(alignment: .EpisodeSeriesAlignmentGuide) { // MARK: Top Bar - HStack(alignment: .top) { + HStack(alignment: .center) { - VStack(alignment: .leading) { - HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectClose() - } label: { - Image(systemName: "chevron.left.circle.fill") - .font(.system(size: 28, weight: .regular, design: .default)) - } - - Text(viewModel.title) - .font(.system(size: 28, weight: .regular, design: .default)) - .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in - context[.leading] - } + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectClose() + } label: { + Image(systemName: "chevron.backward") + .padding() + .padding(.trailing, -10) } + + Text(viewModel.title) + .font(.system(size: 28, weight: .regular, design: .default)) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } } Spacer() @@ -74,22 +74,22 @@ struct VLCPlayerCompactOverlayView: View { } } - Button { - viewModel.screenFilled = !viewModel.screenFilled - } label: { - if viewModel.screenFilled { - Image(systemName: "rectangle.arrowtriangle.2.inward") - .rotationEffect(Angle(degrees: 90)) - } else { - Image(systemName: "rectangle.arrowtriangle.2.outward") - .rotationEffect(Angle(degrees: 90)) - } - } +// Button { +// viewModel.screenFilled = !viewModel.screenFilled +// } label: { +// if viewModel.screenFilled { +// Image(systemName: "rectangle.arrowtriangle.2.inward") +// .rotationEffect(Angle(degrees: 90)) +// } else { +// Image(systemName: "rectangle.arrowtriangle.2.outward") +// .rotationEffect(Angle(degrees: 90)) +// } +// } Button { viewModel.playerOverlayDelegate?.didSelectCaptions() } label: { - if viewModel.captionsEnabled { + if viewModel.subtitlesEnabled { Image(systemName: "captions.bubble.fill") } else { Image(systemName: "captions.bubble") @@ -162,6 +162,7 @@ struct VLCPlayerCompactOverlayView: View { } } .font(.system(size: 24)) + .frame(height: 50) if let seriesTitle = viewModel.subtitle { Text(seriesTitle) @@ -177,54 +178,74 @@ struct VLCPlayerCompactOverlayView: View { Spacer() // MARK: Bottom Bar - HStack { + ZStack { - HStack(spacing: 20) { - Button { - viewModel.playerOverlayDelegate?.didSelectBackward() - } label: { - Image(systemName: "gobackward.10") - } +// VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) +// .cornerRadius(25) +// .mask { +// Rectangle() +// } + + HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectMain() - } label: { - mainButtonView + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: "gobackward.10") + .padding(.horizontal, 5) + } + + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + .padding(.horizontal, 5) + .frame(minWidth: 30, maxWidth: 30) + } + + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: "goforward.10") + .padding(.horizontal, 5) + } } + .font(.system(size: 24, weight: .semibold, design: .default)) +// .padding(.trailing, 10) - Button { - viewModel.playerOverlayDelegate?.didSelectForward() - } label: { - Image(systemName: "goforward.10") - } + Text(viewModel.leftLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + .frame(minWidth: 70, maxWidth: 70) + + ValueSlider(value: $viewModel.sliderPercentage, onEditingChanged: { editing in + viewModel.sliderIsScrubbing = editing + }) + .valueSliderStyle( + HorizontalValueSliderStyle(track: + HorizontalValueTrack(view: + Capsule().foregroundColor(.purple)) + .background(Capsule().foregroundColor(Color.gray.opacity(0.25))) + .frame(height: 4), + thumb: Circle().foregroundColor(.purple) + .onLongPressGesture(perform: { + print("got it here") + }), + thumbSize: CGSize.Circle(radius: viewModel.sliderIsScrubbing ? 25 : 20), + thumbInteractiveSize: CGSize.Circle(radius: 40), + options: .defaultOptions) + ) + + Text(viewModel.rightLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + .frame(minWidth: 70, maxWidth: 70) } - .font(.system(size: 24, weight: .semibold, design: .default)) - .padding(.trailing, 20) - - Text(viewModel.leftLabelText) - .font(.system(size: 18, weight: .semibold, design: .default)) - - Slider(value: $viewModel.sliderPercentage) { editing in - viewModel.sliderIsScrubbing = editing - } - .foregroundColor(.purple) - .tint(.purple) - -// ValueSlider(value: $viewModel.sliderPercentage) -// .valueSliderStyle( -// HorizontalValueSliderStyle(thumb: Circle().foregroundColor(.purple), -// thumbSize: CGSize(width: 32, height: 32), -// thumbInteractiveSize: CGSize(width: 50, height: 50), -// options: [.interactiveTrack]) -// ) - - Text(viewModel.rightLabelText) - .font(.system(size: 18, weight: .semibold, design: .default)) + .padding(.horizontal) } - .frame(height: 50) + .frame(maxWidth: 800, maxHeight: 50) } .padding(.top) - .padding(.horizontal) +// .padding(.horizontal) .ignoresSafeArea(edges: .top) .tint(Color.white) .foregroundColor(Color.white) @@ -232,23 +253,26 @@ struct VLCPlayerCompactOverlayView: View { var body: some View { mainBody - .background { - Color(uiColor: .black.withAlphaComponent(0.001)) - .ignoresSafeArea() - .onTapGesture { - viewModel.playerOverlayDelegate?.didGenerallyTap() - } + .contentShape(Rectangle()) + .onTapGesture { + viewModel.playerOverlayDelegate?.didGenerallyTap() } } } +struct VisualEffectView: UIViewRepresentable { + var effect: UIVisualEffect? + func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() } + func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect } +} + struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { static var previews: some View { ZStack { - Color.gray + Color.black .ignoresSafeArea() - VLCPlayerCompactOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 123 * 10_000_000), + VLCPlayerCompactOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 720 * 10_000_000), title: "Glorious Purpose", subtitle: "Loki - S1E1", streamURL: URL(string: "www.apple.com")!, @@ -262,10 +286,17 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { shouldShowGoogleCast: false, shouldShowAirplay: false, subtitlesEnabled: true, - sliderPercentage: 0.5, + sliderPercentage: 0.432, selectedAudioStreamIndex: -1, selectedSubtitleStreamIndex: -1)) } .previewInterfaceOrientation(.landscapeLeft) } } + +extension CGSize { + + static func Circle(radius: CGFloat) -> CGSize { + return CGSize(width: radius, height: radius) + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift similarity index 100% rename from JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift rename to JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift new file mode 100644 index 00000000..17281c60 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift @@ -0,0 +1,14 @@ +// + /* + * 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 SwiftUI + +protocol VideoPlayerOverlay: View { + var viewModel: VideoPlayerViewModel { get set } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 399a937f..99bd630d 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -128,7 +128,9 @@ class VLCPlayerViewController: UIViewController { }.store(in: &cancellables) viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if !sliderIsScrubbing { + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { self.didEndScrubbing(position: self.viewModel.sliderPercentage) } }.store(in: &cancellables) @@ -343,8 +345,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { if index != -1 { // set in case weren't shown - viewModel.captionsEnabled = true - } + viewModel.subtitlesEnabled = true + } print("New subtitle index: \(index)") } @@ -366,9 +368,9 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { func didSelectCaptions() { - viewModel.captionsEnabled = !viewModel.captionsEnabled + viewModel.subtitlesEnabled = !viewModel.subtitlesEnabled - if viewModel.captionsEnabled { + if viewModel.subtitlesEnabled { vlcMediaPlayer.currentVideoSubTitleIndex = vlcMediaPlayer.videoSubTitlesIndexes[1] as! Int32 } else { vlcMediaPlayer.currentVideoSubTitleIndex = -1 @@ -425,7 +427,7 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } func didBeginScrubbing() { - + stopOverlayDismissTimer() } func didEndScrubbing(position: Double) { @@ -440,6 +442,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) } + restartOverlayDismissTimer() + print("Scrubbed position: \(position)") } } diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index b3632683..0c1b2177 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -23,9 +23,10 @@ final class VideoPlayerViewModel: ObservableObject { @Published var playerState: VLCMediaPlayerState @Published var shouldShowGoogleCast: Bool @Published var shouldShowAirplay: Bool - @Published var captionsEnabled: Bool + @Published var subtitlesEnabled: Bool @Published var leftLabelText: String = "--:--" @Published var rightLabelText: String = "--:--" + @Published var playbackSpeed: PlaybackSpeed = .one @Published var screenFilled: Bool = false @Published var sliderPercentage: Double { willSet { @@ -89,7 +90,7 @@ final class VideoPlayerViewModel: ObservableObject { self.playerState = playerState self.shouldShowGoogleCast = shouldShowGoogleCast self.shouldShowAirplay = shouldShowAirplay - self.captionsEnabled = subtitlesEnabled + self.subtitlesEnabled = subtitlesEnabled self.sliderPercentage = sliderPercentage self.selectedAudioStreamIndex = selectedAudioStreamIndex self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex From fb9d990f9236bdc74a113cae2a60bbf432613c43 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 14:24:19 -0700 Subject: [PATCH 04/62] Fix subtitlesEnabled --- .../Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index cacb1a3e..bb26372a 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -78,7 +78,7 @@ struct VLCPlayerOverlayView: View { Button { viewModel.playerOverlayDelegate?.didSelectCaptions() } label: { - if viewModel.captionsEnabled { + if viewModel.subtitlesEnabled { Image(systemName: "captions.bubble.fill") } else { Image(systemName: "captions.bubble") From d1e3e08921f55f9091074862e44ae8e26aeb2b11 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 14:26:44 -0700 Subject: [PATCH 05/62] Fix home indicator hidden --- .../VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift index 315f0b93..494901d3 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift @@ -33,6 +33,7 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { .navigationBarHidden(true) .statusBar(hidden: true) .ignoresSafeArea() + .prefersHomeIndicatorAutoHidden(true) }.ignoresSafeArea() } else { PreferenceUIHostingControllerView { @@ -40,6 +41,7 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { .navigationBarHidden(true) .statusBar(hidden: true) .ignoresSafeArea() + .prefersHomeIndicatorAutoHidden(true) }.ignoresSafeArea() } } From 114c07032853bff69e4b1c73d0a71514a9e934c4 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 14:43:22 -0700 Subject: [PATCH 06/62] Fix and add default video jump lengths --- JellyfinPlayer.xcodeproj/project.pbxproj | 4 +-- .../VLCPlayerCompactOverlayView.swift | 9 +++-- .../VideoPlayer/VLCPlayerViewController.swift | 21 +++++++----- Shared/Objects/VideoPlayerJumpLength.swift | 34 +++++++------------ 4 files changed, 32 insertions(+), 36 deletions(-) diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 8bdc639d..1271c899 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -1179,8 +1179,8 @@ E10EAA48277BB6D7000269ED /* Overlays */ = { isa = PBXGroup; children = ( - E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */, E10EAA49277BB6F5000269ED /* VideoPlayerOverlay.swift */, + E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */, E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */, ); path = Overlays; @@ -1309,10 +1309,10 @@ isa = PBXGroup; children = ( E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */, + E10EAA48277BB6D7000269ED /* Overlays */, E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */, - E10EAA48277BB6D7000269ED /* Overlays */, E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */, ); path = VideoPlayer; diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 756a387b..0afc65a3 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -6,14 +6,17 @@ // import Combine +import Defaults +import JellyfinAPI import MobileVLCKit import Sliders import SwiftUI -import JellyfinAPI struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { @ObservedObject var viewModel: VideoPlayerViewModel + @Default(.videoPlayerJumpForward) var jumpForwardLength + @Default(.videoPlayerJumpBackward) var jumpBackwardLength @ViewBuilder private var mainButtonView: some View { @@ -192,7 +195,7 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { Button { viewModel.playerOverlayDelegate?.didSelectBackward() } label: { - Image(systemName: "gobackward.10") + Image(systemName: jumpBackwardLength.backwardImageLabel) .padding(.horizontal, 5) } @@ -207,7 +210,7 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { Button { viewModel.playerOverlayDelegate?.didSelectForward() } label: { - Image(systemName: "goforward.10") + Image(systemName: jumpForwardLength.forwardImageLabel) .padding(.horizontal, 5) } } diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 99bd630d..92d129d7 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -8,6 +8,7 @@ import AVKit import AVFoundation import Combine +import Defaults import JellyfinAPI import MediaPlayer import MobileVLCKit @@ -32,6 +33,14 @@ class VLCPlayerViewController: UIViewController { return overlayHostingController.view.alpha > 0 } + private var jumpForwardLength: VideoPlayerJumpLength { + return Defaults[.videoPlayerJumpForward] + } + + private var jumpBackwardLength: VideoPlayerJumpLength { + return Defaults[.videoPlayerJumpBackward] + } + private lazy var videoContentView = makeVideoContentView() private lazy var tapGestureView = makeTapGestureView() private lazy var overlayHostingController = makeOverlayHostingController() @@ -283,7 +292,7 @@ extension VLCPlayerViewController { // MARK: OverlayTimer extension VLCPlayerViewController { - private func restartOverlayDismissTimer(interval: Double = 2) { + private func restartOverlayDismissTimer(interval: Double = 3) { self.overlayDismissTimer?.invalidate() self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false) } @@ -304,8 +313,6 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { func mediaPlayerStateChanged(_ aNotification: Notification!) { self.viewModel.playerState = vlcMediaPlayer.state - - print("Player state changed: \(viewModel.playerState.rawValue)") } func mediaPlayerTimeChanged(_ aNotification: Notification!) { @@ -337,7 +344,6 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { func didSelectAudioStream(index: Int) { vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - print("New audio index: \(index)") } func didSelectSubtitleStream(index: Int) { @@ -347,7 +353,6 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { // set in case weren't shown viewModel.subtitlesEnabled = true } - print("New subtitle index: \(index)") } func didSelectClose() { @@ -388,13 +393,13 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } func didSelectBackward() { - vlcMediaPlayer.jumpBackward(10) + vlcMediaPlayer.jumpBackward(jumpBackwardLength.rawValue) restartOverlayDismissTimer() } func didSelectForward() { - vlcMediaPlayer.jumpForward(10) + vlcMediaPlayer.jumpForward(jumpForwardLength.rawValue) restartOverlayDismissTimer() } @@ -443,7 +448,5 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } restartOverlayDismissTimer() - - print("Scrubbed position: \(position)") } } diff --git a/Shared/Objects/VideoPlayerJumpLength.swift b/Shared/Objects/VideoPlayerJumpLength.swift index 6a2a38b4..2d660cd4 100644 --- a/Shared/Objects/VideoPlayerJumpLength.swift +++ b/Shared/Objects/VideoPlayerJumpLength.swift @@ -19,40 +19,30 @@ enum VideoPlayerJumpLength: Int32, CaseIterable, Defaults.Serializable { var label: String { return "\(self.rawValue) seconds" } - - func generateForwardImage(with font: UIFont) -> UIImage { - let config = UIImage.SymbolConfiguration(font: font) - let systemName: String - + + var forwardImageLabel: String { switch self { case .thirty: - systemName = "goforward.30" + return "goforward.30" case .fifteen: - systemName = "goforward.15" + return "goforward.15" case .ten: - systemName = "goforward.10" + return "goforward.10" case .five: - systemName = "goforward.5" + return "goforward.5" } - - return UIImage(systemName: systemName, withConfiguration: config)! } - - func generateBackwardImage(with font: UIFont) -> UIImage { - let config = UIImage.SymbolConfiguration(font: font) - let systemName: String - + + var backwardImageLabel: String { switch self { case .thirty: - systemName = "gobackward.30" + return "gobackward.30" case .fifteen: - systemName = "gobackward.15" + return "gobackward.15" case .ten: - systemName = "gobackward.10" + return "gobackward.10" case .five: - systemName = "gobackward.5" + return "gobackward.5" } - - return UIImage(systemName: systemName, withConfiguration: config)! } } From c51f11c5bca1ce7e6da5b3e3fe6ef702f70ca0e5 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 14:45:25 -0700 Subject: [PATCH 07/62] Close on media end and CGSize extension --- JellyfinPlayer.xcodeproj/project.pbxproj | 10 +++++++++- .../Overlays/VLCPlayerCompactOverlayView.swift | 7 +------ .../VideoPlayer/VLCPlayerViewController.swift | 3 ++- Shared/Extensions/CGSizeExtensions.swift | 17 +++++++++++++++++ 4 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 Shared/Extensions/CGSizeExtensions.swift diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 1271c899..217961ba 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -234,6 +234,9 @@ E10EAA47277BB670000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA46277BB670000269ED /* JellyfinAPI */; }; E10EAA4A277BB6F5000269ED /* VideoPlayerOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA49277BB6F5000269ED /* VideoPlayerOverlay.swift */; }; E10EAA4D277BB716000269ED /* Sliders in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA4C277BB716000269ED /* Sliders */; }; + E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; + E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; + E10EAA51277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6E2718CDBA006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; @@ -560,6 +563,7 @@ C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; E10EAA49277BB6F5000269ED /* VideoPlayerOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerOverlay.swift; sourceTree = ""; }; + E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailCoordinator.swift; sourceTree = ""; }; E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; @@ -1079,7 +1083,7 @@ isa = PBXGroup; children = ( 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */, - 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */, + E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */, 6267B3D526710B8900A7371D /* CollectionExtensions.swift */, E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */, 6267B3D92671138200A7371D /* ImageExtensions.swift */, @@ -1087,6 +1091,7 @@ 621338922660107500A81A2A /* StringExtensions.swift */, E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */, E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */, + 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */, 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */, ); path = Extensions; @@ -1821,6 +1826,7 @@ 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */, 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */, 091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */, + E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, @@ -1941,6 +1947,7 @@ C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */, E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, + E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, C40CD925271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, @@ -2077,6 +2084,7 @@ E13DD3D7271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */, E131691926C583BC0074BFEE /* LogConstructor.swift in Sources */, E13DD3CA27164B80009D4DAF /* SwiftfinStore.swift in Sources */, + E10EAA51277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, 62E1DCC5273CE19800C9AE76 /* URLExtensions.swift in Sources */, 62EC353226766849000E9F2D /* SessionManager.swift in Sources */, 536D3D79267BD5D00004248C /* ViewModel.swift in Sources */, diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 0afc65a3..a8a91095 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -297,9 +297,4 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { } } -extension CGSize { - - static func Circle(radius: CGFloat) -> CGSize { - return CGSize(width: radius, height: radius) - } -} + diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 92d129d7..6af5e61c 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -412,7 +412,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { case .buffering: vlcMediaPlayer.play() restartOverlayDismissTimer() - case .ended: () + case .ended: + self.didSelectClose() case .error: () case .playing: vlcMediaPlayer.pause() diff --git a/Shared/Extensions/CGSizeExtensions.swift b/Shared/Extensions/CGSizeExtensions.swift new file mode 100644 index 00000000..6269df00 --- /dev/null +++ b/Shared/Extensions/CGSizeExtensions.swift @@ -0,0 +1,17 @@ +// + /* + * 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 UIKit + +extension CGSize { + + static func Circle(radius: CGFloat) -> CGSize { + return CGSize(width: radius, height: radius) + } +} From 467d0d4937445701042083f37b4d402ae387ef75 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 14:48:43 -0700 Subject: [PATCH 08/62] Move createVideoPlayerViewModel --- JellyfinPlayer.xcodeproj/project.pbxproj | 6 + .../BaseItemDto+VideoPlayerViewModel.swift | 104 ++++++++++++++++++ Shared/ViewModels/ItemViewModel.swift | 94 +--------------- 3 files changed, 111 insertions(+), 93 deletions(-) create mode 100644 Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 217961ba..6a711e6e 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -237,6 +237,8 @@ E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; E10EAA51277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; + E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */; }; + E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */; }; E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6E2718CDBA006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; @@ -564,6 +566,7 @@ E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; E10EAA49277BB6F5000269ED /* VideoPlayerOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerOverlay.swift; sourceTree = ""; }; E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = ""; }; + E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailCoordinator.swift; sourceTree = ""; }; E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; @@ -1340,6 +1343,7 @@ children = ( E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */, E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */, + E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */, 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */, E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */, E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */, @@ -1868,6 +1872,7 @@ 536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */, 62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */, 5398514726B64E4100101B49 /* SearchBarView.swift in Sources */, + E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */, E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */, @@ -2029,6 +2034,7 @@ 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */, C40CD922271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, + E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */, diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift new file mode 100644 index 00000000..f3065cd0 --- /dev/null +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -0,0 +1,104 @@ +// + /* + * 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 Combine +import JellyfinAPI +import UIKit + +extension BaseItemDto { + func createVideoPlayerViewModel() -> AnyPublisher { + let builder = DeviceProfileBuilder() + // TODO: fix bitrate settings + builder.setMaxBitrate(bitrate: 60000000) + let profile = builder.buildProfile() + + let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: 60000000, + startTimeTicks: self.userData?.playbackPositionTicks ?? 0, + deviceProfile: profile, + autoOpenLiveStream: true) + + return MediaInfoAPI.getPostedPlaybackInfo(itemId: self.id!, + userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: 60000000, + startTimeTicks: self.userData?.playbackPositionTicks ?? 0, + autoOpenLiveStream: true, + playbackInfoDto: playbackInfo) + .map({ response -> VideoPlayerViewModel in + let mediaSource = response.mediaSources!.first! + + let audioStreams = mediaSource.mediaStreams?.filter({ $0.type == .audio }) ?? [] + let subtitleStreams = mediaSource.mediaStreams?.filter({ $0.type == .subtitle }) ?? [] + + let defaultAudioStream = audioStreams.first(where: { $0.index! == mediaSource.defaultAudioStreamIndex! }) + + let defaultSubtitleStream = subtitleStreams.first(where: { $0.index! == mediaSource.defaultSubtitleStreamIndex ?? -1 }) + + let videoStream = mediaSource.mediaStreams!.first(where: { $0.type! == MediaStreamType.video }) + + let audioCodecs = mediaSource.mediaStreams!.filter({ $0.type! == MediaStreamType.audio }).map({ $0.codec! }) + + // MARK: basic stream + var streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)! + streamURL.path = "/Videos/\(self.id!)/stream" + + streamURL.addQueryItem(name: "Static", value: "true") + streamURL.addQueryItem(name: "MediaSourceId", value: self.id!) + streamURL.addQueryItem(name: "Tag", value: self.etag) + streamURL.addQueryItem(name: "MinSegments", value: "6") + + // MARK: hls stream + var hlsURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)! + hlsURL.path = "/videos/\(self.id!)/master.m3u8" + + hlsURL.addQueryItem(name: "DeviceId", value: UIDevice.vendorUUIDString) + hlsURL.addQueryItem(name: "MediaSourceId", value: self.id!) + hlsURL.addQueryItem(name: "VideoCodec", value: videoStream?.codec!) + hlsURL.addQueryItem(name: "AudioCodec", value: audioCodecs.joined(separator: ",")) + hlsURL.addQueryItem(name: "AudioStreamIndex", value: "\(defaultAudioStream!.index!)") + hlsURL.addQueryItem(name: "VideoBitrate", value: "\(videoStream!.bitRate!)") + hlsURL.addQueryItem(name: "AudioBitrate", value: "\(defaultAudioStream!.bitRate!)") + hlsURL.addQueryItem(name: "PlaySessionId", value: response.playSessionId!) + hlsURL.addQueryItem(name: "TranscodingMaxAudioChannels", value: "6") + hlsURL.addQueryItem(name: "RequireAvc", value: "false") + hlsURL.addQueryItem(name: "Tag", value: mediaSource.eTag!) + hlsURL.addQueryItem(name: "SegmentContainer", value: "ts") + hlsURL.addQueryItem(name: "MinSegments", value: "2") + hlsURL.addQueryItem(name: "BreakOnNonKeyFrames", value: "true") + hlsURL.addQueryItem(name: "TranscodeReasons", value: "VideoCodecNotSupported,AudioCodecNotSupported") + hlsURL.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) + + if defaultSubtitleStream?.index != nil { + hlsURL.addQueryItem(name: "SubtitleMethod", value: "Encode") + hlsURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(defaultSubtitleStream!.index!)") + } + + let videoPlayerViewModel = VideoPlayerViewModel(item: self, + title: self.name!, + subtitle: self.seriesName, + streamURL: streamURL.url!, + hlsURL: hlsURL.url!, + response: response, + audioStreams: audioStreams, + subtitleStreams: subtitleStreams, + defaultAudioStreamIndex: defaultAudioStream?.index ?? -1, + defaultSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, + playerState: .playing, + shouldShowGoogleCast: false, + shouldShowAirplay: false, + subtitlesEnabled: defaultAudioStream?.index != nil, + sliderPercentage: (self.userData?.playedPercentage ?? 0) / 100, + selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, + selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1) + + return videoPlayerViewModel + }) + .eraseToAnyPublisher() + } +} diff --git a/Shared/ViewModels/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel.swift index 27fd6542..0a0b6664 100644 --- a/Shared/ViewModels/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel.swift @@ -36,7 +36,7 @@ class ItemViewModel: ViewModel { getSimilarItems() - self.createVideoPlayerViewModel(item: item) + item.createVideoPlayerViewModel() .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in @@ -111,96 +111,4 @@ class ItemViewModel: ViewModel { .store(in: &cancellables) } } - - func createVideoPlayerViewModel(item: BaseItemDto) -> AnyPublisher { - let builder = DeviceProfileBuilder() - // TODO: fix bitrate settings - builder.setMaxBitrate(bitrate: 60000000) - let profile = builder.buildProfile() - - let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: 60000000, - startTimeTicks: item.userData?.playbackPositionTicks ?? 0, - deviceProfile: profile, - autoOpenLiveStream: true) - - return MediaInfoAPI.getPostedPlaybackInfo(itemId: item.id!, - userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: 60000000, - startTimeTicks: item.userData?.playbackPositionTicks ?? 0, - autoOpenLiveStream: true, - playbackInfoDto: playbackInfo) - .map({ response -> VideoPlayerViewModel in - let mediaSource = response.mediaSources!.first! - - let audioStreams = mediaSource.mediaStreams?.filter({ $0.type == .audio }) ?? [] - let subtitleStreams = mediaSource.mediaStreams?.filter({ $0.type == .subtitle }) ?? [] - - let defaultAudioStream = audioStreams.first(where: { $0.index! == mediaSource.defaultAudioStreamIndex! }) - - let defaultSubtitleStream = subtitleStreams.first(where: { $0.index! == mediaSource.defaultSubtitleStreamIndex ?? -1 }) - - let videoStream = mediaSource.mediaStreams!.first(where: { $0.type! == MediaStreamType.video }) - - let audioCodecs = mediaSource.mediaStreams!.filter({ $0.type! == MediaStreamType.audio }).map({ $0.codec! }) - - // MARK: basic stream - var streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)! - streamURL.path = "/Videos/\(item.id!)/stream" - - streamURL.addQueryItem(name: "Static", value: "true") - streamURL.addQueryItem(name: "MediaSourceId", value: item.id!) - streamURL.addQueryItem(name: "Tag", value: item.etag) - streamURL.addQueryItem(name: "MinSegments", value: "6") - - // MARK: hls stream - var hlsURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)! - hlsURL.path = "/videos/\(item.id!)/master.m3u8" - - hlsURL.addQueryItem(name: "DeviceId", value: UIDevice.vendorUUIDString) - hlsURL.addQueryItem(name: "MediaSourceId", value: item.id!) - hlsURL.addQueryItem(name: "VideoCodec", value: videoStream?.codec!) - hlsURL.addQueryItem(name: "AudioCodec", value: audioCodecs.joined(separator: ",")) - hlsURL.addQueryItem(name: "AudioStreamIndex", value: "\(defaultAudioStream!.index!)") - hlsURL.addQueryItem(name: "VideoBitrate", value: "\(videoStream!.bitRate!)") - hlsURL.addQueryItem(name: "AudioBitrate", value: "\(defaultAudioStream!.bitRate!)") - hlsURL.addQueryItem(name: "PlaySessionId", value: response.playSessionId!) - hlsURL.addQueryItem(name: "TranscodingMaxAudioChannels", value: "6") - hlsURL.addQueryItem(name: "RequireAvc", value: "false") - hlsURL.addQueryItem(name: "Tag", value: mediaSource.eTag!) - hlsURL.addQueryItem(name: "SegmentContainer", value: "ts") - hlsURL.addQueryItem(name: "MinSegments", value: "2") - hlsURL.addQueryItem(name: "BreakOnNonKeyFrames", value: "true") - hlsURL.addQueryItem(name: "TranscodeReasons", value: "VideoCodecNotSupported,AudioCodecNotSupported") - hlsURL.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) - - if defaultSubtitleStream?.index != nil { - hlsURL.addQueryItem(name: "SubtitleMethod", value: "Encode") - hlsURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(defaultSubtitleStream!.index!)") - } - -// startURL.queryItems?.append(URLQueryItem(name: "SubtitleCodec", value: "\(defaultSubtitleStream!.codec!)")) - - let videoPlayerViewModel = VideoPlayerViewModel(item: item, - title: item.name!, - subtitle: item.seriesName, - streamURL: streamURL.url!, - hlsURL: hlsURL.url!, - response: response, - audioStreams: audioStreams, - subtitleStreams: subtitleStreams, - defaultAudioStreamIndex: defaultAudioStream?.index ?? -1, - defaultSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, - playerState: .playing, - shouldShowGoogleCast: false, - shouldShowAirplay: false, - subtitlesEnabled: defaultAudioStream?.index != nil, - sliderPercentage: (item.userData?.playedPercentage ?? 0) / 100, - selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, - selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1) - - return videoPlayerViewModel - }) - .eraseToAnyPublisher() - } } From fb5ad0fa9b304ff9f330638a1370202a92971f6e Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 14:53:39 -0700 Subject: [PATCH 09/62] Cleanup --- .../Views/BasicAppSettingsView.swift | 4 +- JellyfinPlayer.xcodeproj/project.pbxproj | 4 - JellyfinPlayer/App/EmailHelper.swift | 82 ------------------- JellyfinPlayer/App/JellyfinPlayerApp.swift | 8 +- .../Views/BasicAppSettingsView.swift | 4 +- JellyfinPlayer/Views/SettingsView.swift | 4 +- 6 files changed, 4 insertions(+), 102 deletions(-) delete mode 100644 JellyfinPlayer/App/EmailHelper.swift diff --git a/JellyfinPlayer tvOS/Views/BasicAppSettingsView.swift b/JellyfinPlayer tvOS/Views/BasicAppSettingsView.swift index 241557f9..4f4265ec 100644 --- a/JellyfinPlayer tvOS/Views/BasicAppSettingsView.swift +++ b/JellyfinPlayer tvOS/Views/BasicAppSettingsView.swift @@ -26,9 +26,7 @@ struct BasicAppSettingsView: View { ForEach(self.viewModel.appearances, id: \.self) { appearance in Text(appearance.localizedName).tag(appearance.rawValue) } - }.onChange(of: appAppearance, perform: { _ in - UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style - }) + } } header: { L10n.accessibility.text } diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 6a711e6e..ecab7ffb 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -253,7 +253,6 @@ E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; E131691926C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; - E13DD3BD27163C63009D4DAF /* EmailHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3BC27163C63009D4DAF /* EmailHelper.swift */; }; E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */; }; E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C127164941009D4DAF /* SwiftfinStore.swift */; }; E13DD3C327164941009D4DAF /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C127164941009D4DAF /* SwiftfinStore.swift */; }; @@ -571,7 +570,6 @@ E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailCoordinator.swift; sourceTree = ""; }; E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; E131691626C583BC0074BFEE /* LogConstructor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogConstructor.swift; sourceTree = ""; }; - E13DD3BC27163C63009D4DAF /* EmailHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailHelper.swift; sourceTree = ""; }; E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; E13DD3C127164941009D4DAF /* SwiftfinStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftfinStore.swift; sourceTree = ""; }; E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDeviceExtensions.swift; sourceTree = ""; }; @@ -1232,7 +1230,6 @@ isa = PBXGroup; children = ( E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */, - E13DD3BC27163C63009D4DAF /* EmailHelper.swift */, 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */, 5D64683B277B15E4009E09AE /* PreferenceUIHosting */, ); @@ -2017,7 +2014,6 @@ 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */, - E13DD3BD27163C63009D4DAF /* EmailHelper.swift in Sources */, E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */, E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */, 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, diff --git a/JellyfinPlayer/App/EmailHelper.swift b/JellyfinPlayer/App/EmailHelper.swift deleted file mode 100644 index 87fd8523..00000000 --- a/JellyfinPlayer/App/EmailHelper.swift +++ /dev/null @@ -1,82 +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 SwiftUI -import MessageUI - -class EmailHelper: NSObject, MFMailComposeViewControllerDelegate { - - public static let shared = EmailHelper() - - override private init() { } - - func sendLogs(logURL: URL) { - if !MFMailComposeViewController.canSendMail() { - // Utilities.showErrorBanner(title: "No mail account found", subtitle: "Please setup a mail account") - return // EXIT - } - - let picker = MFMailComposeViewController() - - let fileManager = FileManager() - let data = fileManager.contents(atPath: logURL.path) - - picker.setSubject("[DEV-BUG] SwiftFin") - picker - .setMessageBody("Please don't edit this email.\n Please don't change the subject. \nUDID: \(UIDevice.current.identifierForVendor?.uuidString ?? "NIL")\n", - isHTML: false) - picker.setToRecipients(["SwiftFin Bug Reports "]) - picker.addAttachmentData(data!, mimeType: "text/plain", fileName: logURL.lastPathComponent) - picker.mailComposeDelegate = self - - EmailHelper.getRootViewController()?.present(picker, animated: true, completion: nil) - } - - func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { - EmailHelper.getRootViewController()?.dismiss(animated: true, completion: nil) - } - - static func getRootViewController() -> UIViewController? { - UIApplication.shared.windows.first?.rootViewController - } -} - -// A view modifier that detects shaking and calls a function of our choosing. -struct DeviceShakeViewModifier: ViewModifier { - let action: () -> Void - - func body(content: Self.Content) -> some View { - content - .onAppear() - .onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in - action() - } - } -} - -// A View extension to make the modifier easier to use. -extension View { - func onShake(perform action: @escaping () -> Void) -> some View { - modifier(DeviceShakeViewModifier(action: action)) - } -} - -// The notification we'll send when a shake gesture happens. -extension UIDevice { - static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification") -} - -// Override the default behavior of shake gestures to send our notification instead. -extension UIWindow { - override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { - if motion == .motionShake { - NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil) - } - } -} diff --git a/JellyfinPlayer/App/JellyfinPlayerApp.swift b/JellyfinPlayer/App/JellyfinPlayerApp.swift index 2d58ea07..c0fa6b35 100644 --- a/JellyfinPlayer/App/JellyfinPlayerApp.swift +++ b/JellyfinPlayer/App/JellyfinPlayerApp.swift @@ -19,17 +19,11 @@ struct JellyfinPlayerApp: App { var body: some Scene { WindowGroup { - EmptyView() + MainCoordinator().view() .ignoresSafeArea() .onAppear { setupAppearance() } - .withHostingWindow { window in - window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view()) - } - .onShake { - EmailHelper.shared.sendLogs(logURL: LogManager.shared.logFileURL()) - } .onOpenURL { url in AppURLHandler.shared.processDeepLink(url: url) } diff --git a/JellyfinPlayer/Views/BasicAppSettingsView.swift b/JellyfinPlayer/Views/BasicAppSettingsView.swift index 711454b2..71fe5a0b 100644 --- a/JellyfinPlayer/Views/BasicAppSettingsView.swift +++ b/JellyfinPlayer/Views/BasicAppSettingsView.swift @@ -27,9 +27,7 @@ struct BasicAppSettingsView: View { ForEach(self.viewModel.appearances, id: \.self) { appearance in Text(appearance.localizedName).tag(appearance.rawValue) } - }.onChange(of: appAppearance, perform: { _ in - UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style - }) + } } header: { L10n.accessibility.text } diff --git a/JellyfinPlayer/Views/SettingsView.swift b/JellyfinPlayer/Views/SettingsView.swift index f5f405d4..63819e44 100644 --- a/JellyfinPlayer/Views/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView.swift @@ -135,9 +135,7 @@ struct SettingsView: View { ForEach(self.viewModel.appearances, id: \.self) { appearance in Text(appearance.localizedName).tag(appearance.rawValue) } - }.onChange(of: appAppearance, perform: { _ in - UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style - }) + } } } .navigationBarTitle("Settings", displayMode: .inline) From c69bb5db0ae7664fd05c346b091489c89e1a59d2 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 15:00:27 -0700 Subject: [PATCH 10/62] Simplift where appearance is changed --- JellyfinPlayer/App/JellyfinPlayerApp.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/JellyfinPlayer/App/JellyfinPlayerApp.swift b/JellyfinPlayer/App/JellyfinPlayerApp.swift index c0fa6b35..3d11e057 100644 --- a/JellyfinPlayer/App/JellyfinPlayerApp.swift +++ b/JellyfinPlayer/App/JellyfinPlayerApp.swift @@ -27,6 +27,9 @@ struct JellyfinPlayerApp: App { .onOpenURL { url in AppURLHandler.shared.processDeepLink(url: url) } + .onChange(of: appAppearance) { newValue in + setupAppearance() + } } } From f1612a0e304d5451e86e0f0f4a34fc93da0e1178 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 15:06:39 -0700 Subject: [PATCH 11/62] Add close when ended --- .../Views/VideoPlayer/VLCPlayerViewController.swift | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 6af5e61c..17e45565 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -298,7 +298,6 @@ extension VLCPlayerViewController { } @objc private func dismissTimerFired() { - print("Dismiss timer fired") self.hideOverlay() } @@ -313,6 +312,10 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { func mediaPlayerStateChanged(_ aNotification: Notification!) { self.viewModel.playerState = vlcMediaPlayer.state + + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + didSelectClose() + } } func mediaPlayerTimeChanged(_ aNotification: Notification!) { @@ -407,21 +410,14 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { func didSelectMain() { switch viewModel.playerState { - case .stopped: () - case .opening: () case .buffering: vlcMediaPlayer.play() restartOverlayDismissTimer() - case .ended: - self.didSelectClose() - case .error: () case .playing: vlcMediaPlayer.pause() restartOverlayDismissTimer(interval: 5) case .paused: vlcMediaPlayer.play() - case .esAdded: () - default: () } } From 6712ef279dc427b3daf3ae40cf32ee5faffc631a Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 15:55:02 -0700 Subject: [PATCH 12/62] Cleanup and hide subtitle button if there is none --- .../NativePlayerViewController.swift | 8 -------- .../Overlays/VLCPlayerCompactOverlayView.swift | 18 +++++++++--------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift index 9692626d..75ca6056 100644 --- a/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift @@ -29,14 +29,6 @@ class NativePlayerViewController: AVPlayerViewController { player.appliesMediaSelectionCriteriaAutomatically = false player.currentItem?.externalMetadata = createMetadata() - let chevron = UIImage(systemName: "chevron.right.circle.fill")! - let testAction = UIAction(title: "Next", image: chevron) { action in - print("next item selected") - } - - // tvos -// self.transportBarCustomMenuItems = [testAction] - let timeScale = CMTimeScale(NSEC_PER_SEC) let time = CMTime(seconds: 5, preferredTimescale: timeScale) diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index a8a91095..65495c6f 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -89,13 +89,15 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { // } // } - Button { - viewModel.playerOverlayDelegate?.didSelectCaptions() - } label: { - if viewModel.subtitlesEnabled { - Image(systemName: "captions.bubble.fill") - } else { - Image(systemName: "captions.bubble") + if !viewModel.subtitleStreams.isEmpty { + Button { + viewModel.playerOverlayDelegate?.didSelectCaptions() + } label: { + if viewModel.subtitlesEnabled { + Image(systemName: "captions.bubble.fill") + } else { + Image(systemName: "captions.bubble") + } } } @@ -247,8 +249,6 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { } .frame(maxWidth: 800, maxHeight: 50) } - .padding(.top) -// .padding(.horizontal) .ignoresSafeArea(edges: .top) .tint(Color.white) .foregroundColor(Color.white) From 99445e387cf74464a354a11caf99b039927e70de Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 28 Dec 2021 16:45:45 -0700 Subject: [PATCH 13/62] Right progress ticks for VLCPlayer --- .../NativePlayerViewController.swift | 11 ++- .../VideoPlayer/VLCPlayerViewController.swift | 36 ++++++--- Shared/ViewModels/VideoPlayerViewModel.swift | 74 ++++++++++++++----- 3 files changed, 88 insertions(+), 33 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift index 75ca6056..e6cb3f16 100644 --- a/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift @@ -14,9 +14,9 @@ class NativePlayerViewController: AVPlayerViewController { let viewModel: VideoPlayerViewModel - var timeObserverToken: Any? + private var timeObserverToken: Any? - var lastProgressTicks: Int64 = 0 + private var lastProgressTicks: Int64 = 0 init(viewModel: VideoPlayerViewModel) { @@ -99,15 +99,14 @@ class NativePlayerViewController: AVPlayerViewController { private func play() { player?.play() - - viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0) + viewModel.sendPlayReport() } private func sendProgressReport(seconds: Double) { - viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000) + viewModel.sendProgressReport() } private func stop() { - viewModel.sendStopReport(ticks: 10_000_000) + viewModel.sendStopReport() } } diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 17e45565..d76b792d 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -22,6 +22,7 @@ class VLCPlayerViewController: UIViewController { private let viewModel: VideoPlayerViewModel private var vlcMediaPlayer = VLCMediaPlayer() private var lastPlayerTicks: Int64 + private var lastProgressReportTicks: Int64 private var cancellables = Set() private var overlayDismissTimer: Timer? @@ -52,6 +53,7 @@ class VLCPlayerViewController: UIViewController { self.viewModel = viewModel self.lastPlayerTicks = viewModel.item.userData?.playbackPositionTicks ?? 0 + self.lastProgressReportTicks = viewModel.item.userData?.playbackPositionTicks ?? 0 super.init(nibName: nil, bundle: nil) @@ -243,7 +245,7 @@ extension VLCPlayerViewController { func startPlayback() { vlcMediaPlayer.play() - viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0) + viewModel.sendPlayReport() // 1 second = 10,000,000 ticks let startTicks: Int64 = viewModel.item.userData?.playbackPositionTicks ?? 0 @@ -327,18 +329,18 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { + if abs(currentPlayerTicks - lastPlayerTicks) >= 10_000 { viewModel.playerState = VLCMediaPlayerState.playing } lastPlayerTicks = currentPlayerTicks - -// if CACurrentMediaTime() - lastProgressReportTime > 5 { -// mediaPlayer.currentVideoSubTitleIndex = selectedCaptionTrack -// sendProgressReport(eventName: "timeupdate") -// lastProgressReportTime = CACurrentMediaTime() -// } + + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } } } @@ -361,7 +363,7 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { func didSelectClose() { vlcMediaPlayer.stop() - viewModel.sendStopReport(ticks: currentPlayerTicks) + viewModel.sendStopReport() dismiss(animated: true, completion: nil) } @@ -399,12 +401,20 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { vlcMediaPlayer.jumpBackward(jumpBackwardLength.rawValue) restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + self.lastProgressReportTicks = currentPlayerTicks } func didSelectForward() { vlcMediaPlayer.jumpForward(jumpForwardLength.rawValue) restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + self.lastProgressReportTicks = currentPlayerTicks } func didSelectMain() { @@ -414,9 +424,11 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { vlcMediaPlayer.play() restartOverlayDismissTimer() case .playing: + viewModel.sendPauseReport(paused: true) vlcMediaPlayer.pause() restartOverlayDismissTimer(interval: 5) case .paused: + viewModel.sendPauseReport(paused: false) vlcMediaPlayer.play() default: () } @@ -433,6 +445,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } func didEndScrubbing(position: Double) { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000) let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) @@ -445,5 +459,9 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + self.lastProgressReportTicks = currentPlayerTicks } } diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 0c1b2177..dfb16919 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -8,15 +8,15 @@ import Combine import Foundation import JellyfinAPI +import UIKit + #if os(tvOS) import TVVLCKit #else import MobileVLCKit #endif -import Stinsen -import UIKit -final class VideoPlayerViewModel: ObservableObject { +final class VideoPlayerViewModel: ViewModel { // Manually kept state because VLCKit doesn't properly set "played" // on the VLCMediaPlayer object @@ -55,11 +55,18 @@ final class VideoPlayerViewModel: ObservableObject { // Ticks of the time the media has begun var startTimeTicks: Int64? + var currentSeconds: Double { + let videoDuration = Double(item.runTimeTicks! / 10_000_000) + return round(sliderPercentage * videoDuration) + } + + var currentSecondTicks: Int64 { + return Int64(currentSeconds) * 10_000_000 + } + // Necessary PassthroughSubject to capture manual scrubbing from sliders let sliderScrubbingSubject = PassthroughSubject() - private var cancellables = Set() - init(item: BaseItemDto, title: String, subtitle: String?, @@ -95,15 +102,16 @@ final class VideoPlayerViewModel: ObservableObject { self.selectedAudioStreamIndex = selectedAudioStreamIndex self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex + super.init() + self.sliderPercentageChanged(newValue: (item.userData?.playedPercentage ?? 0) / 100) } private func sliderPercentageChanged(newValue: Double) { let videoDuration = Double(item.runTimeTicks! / 10_000_000) - let secondsScrubbedTo = round(sliderPercentage * videoDuration) - let secondsScrubbedRemaining = videoDuration - secondsScrubbedTo + let secondsScrubbedRemaining = videoDuration - currentSeconds - leftLabelText = calculateTimeText(from: secondsScrubbedTo) + leftLabelText = calculateTimeText(from: currentSeconds) rightLabelText = calculateTimeText(from: secondsScrubbedRemaining) } @@ -125,9 +133,9 @@ final class VideoPlayerViewModel: ObservableObject { return timeText } - func sendPlayReport(startTimeTicks: Int64) { + func sendPlayReport() { - self.startTimeTicks = startTimeTicks + self.startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000 let startInfo = PlaybackStartInfo(canSeek: true, item: item, @@ -153,16 +161,46 @@ final class VideoPlayerViewModel: ObservableObject { PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) .sink { completion in - print(completion) + self.handleAPIRequestError(completion: completion) } receiveValue: { _ in print("Playback start report sent!") } .store(in: &cancellables) } - func sendProgressReport(ticks: Int64) { + func sendPauseReport(paused: Bool) { + let startInfo = PlaybackStartInfo(canSeek: true, + item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index, + subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index, + isPaused: paused, + isMuted: false, + positionTicks: currentSecondTicks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: 100, + brightness: 100, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0" + ) - print("Progress ticks: \(ticks)") + PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { _ in + print("Pause report sent!") + } + .store(in: &cancellables) + } + + func sendProgressReport() { let progressInfo = PlaybackProgressInfo(canSeek: true, item: item, @@ -173,7 +211,7 @@ final class VideoPlayerViewModel: ObservableObject { subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index, isPaused: false, isMuted: false, - positionTicks: ticks, + positionTicks: currentSecondTicks, playbackStartTimeTicks: startTimeTicks, volumeLevel: nil, brightness: nil, @@ -187,20 +225,20 @@ final class VideoPlayerViewModel: ObservableObject { PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) .sink { completion in - print(completion) + self.handleAPIRequestError(completion: completion) } receiveValue: { _ in print("Playback progress sent!") } .store(in: &cancellables) } - func sendStopReport(ticks: Int64) { + func sendStopReport() { let stopInfo = PlaybackStopInfo(item: item, itemId: item.id, sessionId: response.playSessionId, mediaSourceId: item.id, - positionTicks: ticks, + positionTicks: currentSecondTicks, liveStreamId: nil, playSessionId: response.playSessionId, failed: nil, @@ -210,7 +248,7 @@ final class VideoPlayerViewModel: ObservableObject { PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) .sink { completion in - print(completion) + self.handleAPIRequestError(completion: completion) } receiveValue: { _ in print("Playback stop report sent!") } From fe0c8ee03b6bd91adbb8fd12bea9e8b4329309cb Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Wed, 29 Dec 2021 12:33:43 -0700 Subject: [PATCH 14/62] initial previous and next item feature --- .../VLCPlayerCompactOverlayView.swift | 23 ++- .../Overlays/VLCPlayerOverlayView.swift | 15 +- .../Overlays/VideoPlayerOverlay.swift | 12 ++ .../VideoPlayer/PlayerOverlayDelegate.swift | 3 + .../VideoPlayer/VLCPlayerViewController.swift | 191 ++++++++++++------ .../BaseItemDto+VideoPlayerViewModel.swift | 3 +- Shared/ViewModels/VideoPlayerViewModel.swift | 92 ++++++++- 7 files changed, 261 insertions(+), 78 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 65495c6f..8f198282 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -61,6 +61,24 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { HStack(spacing: 20) { + if viewModel.showAdjacentItems { + Button { + viewModel.playerOverlayDelegate?.didSelectPreviousItem() + } label: { + Image(systemName: "chevron.left.circle") + } + .disabled(viewModel.previousItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + + Button { + viewModel.playerOverlayDelegate?.didSelectNextItem() + } label: { + Image(systemName: "chevron.right.circle") + } + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } + if viewModel.shouldShowGoogleCast { Button { viewModel.playerOverlayDelegate?.didSelectGoogleCast() @@ -205,8 +223,8 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { viewModel.playerOverlayDelegate?.didSelectMain() } label: { mainButtonView - .padding(.horizontal, 5) .frame(minWidth: 30, maxWidth: 30) + .padding(.horizontal, 10) } Button { @@ -291,7 +309,8 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { subtitlesEnabled: true, sliderPercentage: 0.432, selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1)) + selectedSubtitleStreamIndex: -1, + showAdjacentItems: true)) } .previewInterfaceOrientation(.landscapeLeft) } diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index bb26372a..dd494e50 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -239,20 +239,11 @@ struct VLCPlayerOverlayView_Previews: PreviewProvider { subtitlesEnabled: true, sliderPercentage: 0.0, selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1)) + selectedSubtitleStreamIndex: -1, + showAdjacentItems: true)) } .previewInterfaceOrientation(.landscapeLeft) } } -extension HorizontalAlignment { - - private struct EpisodeSeriesTitleAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[HorizontalAlignment.leading] - } - } - - static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(EpisodeSeriesTitleAlignment.self) - -} + diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift index 17281c60..720266c7 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift @@ -12,3 +12,15 @@ import SwiftUI protocol VideoPlayerOverlay: View { var viewModel: VideoPlayerViewModel { get set } } + +extension HorizontalAlignment { + + private struct EpisodeSeriesTitleAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[HorizontalAlignment.leading] + } + } + + static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(EpisodeSeriesTitleAlignment.self) + +} diff --git a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift index 338ddd85..4b4fce33 100644 --- a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -27,4 +27,7 @@ protocol PlayerOverlayDelegate { func didSelectAudioStream(index: Int) func didSelectSubtitleStream(index: Int) + + func didSelectPreviousItem() + func didSelectNextItem() } diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index d76b792d..33b7ee5d 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -15,14 +15,18 @@ import MobileVLCKit import SwiftUI import UIKit +// TODO: Make the VLC player layer a view +// This will allow changing media and putting the view somewhere else +// in a compact state, like a small viewer while navigating the app + class VLCPlayerViewController: UIViewController { // MARK: variables - private let viewModel: VideoPlayerViewModel + private var viewModel: VideoPlayerViewModel private var vlcMediaPlayer = VLCMediaPlayer() - private var lastPlayerTicks: Int64 - private var lastProgressReportTicks: Int64 + private var lastPlayerTicks: Int64 = 0 + private var lastProgressReportTicks: Int64 = 0 private var cancellables = Set() private var overlayDismissTimer: Timer? @@ -31,7 +35,7 @@ class VLCPlayerViewController: UIViewController { } private var displayingOverlay: Bool { - return overlayHostingController.view.alpha > 0 + return currentOverlayHostingController?.view.alpha ?? 0 > 0 } private var jumpForwardLength: VideoPlayerJumpLength { @@ -44,7 +48,7 @@ class VLCPlayerViewController: UIViewController { private lazy var videoContentView = makeVideoContentView() private lazy var tapGestureView = makeTapGestureView() - private lazy var overlayHostingController = makeOverlayHostingController() + private var currentOverlayHostingController: UIHostingController? // MARK: init @@ -52,9 +56,6 @@ class VLCPlayerViewController: UIViewController { self.viewModel = viewModel - self.lastPlayerTicks = viewModel.item.userData?.playbackPositionTicks ?? 0 - self.lastProgressReportTicks = viewModel.item.userData?.playbackPositionTicks ?? 0 - super.init(nibName: nil, bundle: nil) viewModel.playerOverlayDelegate = self @@ -67,12 +68,6 @@ class VLCPlayerViewController: UIViewController { private func setupSubviews() { view.addSubview(videoContentView) view.addSubview(tapGestureView) - - addChild(overlayHostingController) - overlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - overlayHostingController.view.backgroundColor = UIColor.black.withAlphaComponent(0.2) - view.addSubview(overlayHostingController.view) - overlayHostingController.didMove(toParent: self) } private func setupConstraints() { @@ -88,12 +83,6 @@ class VLCPlayerViewController: UIViewController { tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), tapGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) ]) - NSLayoutConstraint.activate([ - overlayHostingController.view.topAnchor.constraint(equalTo: view.topAnchor), - overlayHostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - overlayHostingController.view.leftAnchor.constraint(equalTo: view.leftAnchor), - overlayHostingController.view.rightAnchor.constraint(equalTo: view.rightAnchor) - ]) } // MARK: viewWillAppear @@ -120,39 +109,15 @@ class VLCPlayerViewController: UIViewController { setupSubviews() setupConstraints() - setupViewModelListeners() - view.backgroundColor = .black - setupMediaPlayer() - } - - // MARK: setupViewModelListeners - - private func setupViewModelListeners() { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &cancellables) + // These are kept outside of 'setupMediaPlayer' such that + // they aren't unnecessarily set more than once + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView + vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) - viewModel.$screenFilled.sink { shouldFill in - self.changeFill(to: shouldFill) - }.store(in: &cancellables) - - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing(position: self.viewModel.sliderPercentage) - } - }.store(in: &cancellables) - - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &cancellables) - - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &cancellables) + setupMediaPlayer(newViewModel: viewModel) } private func changeFill(to shouldFill: Bool) { @@ -177,7 +142,6 @@ class VLCPlayerViewController: UIViewController { super.viewDidAppear(animated) startPlayback() - restartOverlayDismissTimer() } // MARK: subviews @@ -193,7 +157,6 @@ class VLCPlayerViewController: UIViewController { private func makeTapGestureView() -> UIView { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .clear let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) @@ -220,33 +183,93 @@ class VLCPlayerViewController: UIViewController { self.didSelectBackward() } - private func makeOverlayHostingController() -> UIHostingController { - let overlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) - return UIHostingController(rootView: overlayView) + // MARK: setupOverlayHostingController + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + + if let currentOverlayHostingController = currentOverlayHostingController { + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() + self.currentOverlayHostingController = nil + } + + let newOverlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) + let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + + newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayHostingController.view.backgroundColor = UIColor.clear + addChild(newOverlayHostingController) + view.addSubview(newOverlayHostingController.view) + newOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) + ]) + + self.currentOverlayHostingController = newOverlayHostingController + + // There is a behavior when setting this that the navigation bar + // on the current navigation controller pops up, re-hide it + self.navigationController?.isNavigationBarHidden = true } } // MARK: setupMediaPlayer extension VLCPlayerViewController { - func setupMediaPlayer() { + /// Main function that handles setting up the media player with the current VideoPlayerViewModel + /// and also takes the role of setting the 'viewModel' property with the given viewModel + /// + /// Use case for this is setting new media within the same VLCPlayerViewController + func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView - vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) + stopOverlayDismissTimer() + + // UX improvement + (vlcMediaPlayer.drawable as! UIView).isHidden = true + + // Stop current media if there is one + if vlcMediaPlayer.media != nil { + cancellables.forEach({ $0.cancel() }) + + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + vlcMediaPlayer.media = nil + } + + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - let media = VLCMedia(url: viewModel.streamURL) + let media = VLCMedia(url: newViewModel.streamURL) media.addOption("--prefetch-buffer-size=1048576") media.addOption("--network-caching=5000") vlcMediaPlayer.media = media + + setupOverlayHostingController(viewModel: newViewModel) + setupViewModelListeners(viewModel: newViewModel) + + newViewModel.getAdjacentEpisodes() + newViewModel.playerOverlayDelegate = self + + viewModel = newViewModel } func startPlayback() { + // UX improvement + (vlcMediaPlayer.drawable as! UIView).isHidden = false + vlcMediaPlayer.play() viewModel.sendPlayReport() + restartOverlayDismissTimer() + // 1 second = 10,000,000 ticks let startTicks: Int64 = viewModel.item.userData?.playbackPositionTicks ?? 0 @@ -261,28 +284,62 @@ extension VLCPlayerViewController { } } } + + // MARK: setupViewModelListeners + + private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &cancellables) + + viewModel.$screenFilled.sink { shouldFill in + self.changeFill(to: shouldFill) + }.store(in: &cancellables) + + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { + self.didEndScrubbing(position: self.viewModel.sliderPercentage) + } + }.store(in: &cancellables) + + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &cancellables) + + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &cancellables) + } } // MARK: Show/Hide Overlay extension VLCPlayerViewController { private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + guard overlayHostingController.view.alpha != 1 else { return } UIView.animate(withDuration: 0.2) { - self.overlayHostingController.view.alpha = 1 + overlayHostingController.view.alpha = 1 } } private func hideOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + guard overlayHostingController.view.alpha != 0 else { return } UIView.animate(withDuration: 0.2) { - self.overlayHostingController.view.alpha = 0 + overlayHostingController.view.alpha = 0 } } private func toggleOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + if overlayHostingController.view.alpha < 1 { showOverlay() } else { @@ -464,4 +521,14 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { self.lastProgressReportTicks = currentPlayerTicks } + + func didSelectPreviousItem() { + setupMediaPlayer(newViewModel: viewModel.previousItemVideoPlayerViewModel!) + startPlayback() + } + + func didSelectNextItem() { + setupMediaPlayer(newViewModel: viewModel.nextItemVideoPlayerViewModel!) + startPlayback() + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index f3065cd0..b5b3a969 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -95,7 +95,8 @@ extension BaseItemDto { subtitlesEnabled: defaultAudioStream?.index != nil, sliderPercentage: (self.userData?.playedPercentage ?? 0) / 100, selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, - selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1) + selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, + showAdjacentItems: true) return videoPlayerViewModel }) diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index dfb16919..7fcecd21 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -37,6 +37,9 @@ final class VideoPlayerViewModel: ViewModel { @Published var sliderIsScrubbing: Bool = false @Published var selectedAudioStreamIndex: Int @Published var selectedSubtitleStreamIndex: Int + @Published var showAdjacentItems: Bool + @Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel? + @Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel? let item: BaseItemDto let title: String @@ -67,6 +70,8 @@ final class VideoPlayerViewModel: ViewModel { // Necessary PassthroughSubject to capture manual scrubbing from sliders let sliderScrubbingSubject = PassthroughSubject() + // MARK: init + init(item: BaseItemDto, title: String, subtitle: String?, @@ -83,7 +88,8 @@ final class VideoPlayerViewModel: ViewModel { subtitlesEnabled: Bool, sliderPercentage: Double, selectedAudioStreamIndex: Int, - selectedSubtitleStreamIndex: Int) { + selectedSubtitleStreamIndex: Int, + showAdjacentItems: Bool) { self.item = item self.title = title self.subtitle = subtitle @@ -101,6 +107,7 @@ final class VideoPlayerViewModel: ViewModel { self.sliderPercentage = sliderPercentage self.selectedAudioStreamIndex = selectedAudioStreamIndex self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex + self.showAdjacentItems = showAdjacentItems super.init() @@ -132,7 +139,87 @@ final class VideoPlayerViewModel: ViewModel { return timeText } +} + +// MARK: Adjacent Items +extension VideoPlayerViewModel { + func getAdjacentEpisodes() { + guard let seriesID = item.seriesId, item.itemType == .episode else { return } + + TvShowsAPI.getEpisodes(seriesId: seriesID, + userId: SessionManager.main.currentLogin.user.id, + adjacentTo: item.id, + limit: 3) + .sink(receiveCompletion: { completion in + print(completion) + }, receiveValue: { response in + + // 4 possible states: + // 1 - only current episode + // 2 - two episodes with next episode + // 3 - two episodes with previous episode + // 4 - three episodes with current in middle + + // State 1 + guard let items = response.items, items.count > 1 else { return } + + if items.count == 2 { + if items[0].id == self.item.id { + // State 2 + let nextItem = items[1] + + nextItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.nextItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + } else { + // State 3 + let previousItem = items[0] + + previousItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.previousItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + } + } else { + // State 4 + + let previousItem = items[0] + let nextItem = items[2] + + previousItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.previousItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + + nextItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.nextItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + } + }) + .store(in: &cancellables) + } +} + +// MARK: Reports +extension VideoPlayerViewModel { + + + // MARK: sendPlayReport func sendPlayReport() { self.startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000 @@ -168,6 +255,7 @@ final class VideoPlayerViewModel: ViewModel { .store(in: &cancellables) } + // MARK: sendPauseReport func sendPauseReport(paused: Bool) { let startInfo = PlaybackStartInfo(canSeek: true, item: item, @@ -200,6 +288,7 @@ final class VideoPlayerViewModel: ViewModel { .store(in: &cancellables) } + // MARK: sendProgressReport func sendProgressReport() { let progressInfo = PlaybackProgressInfo(canSeek: true, @@ -232,6 +321,7 @@ final class VideoPlayerViewModel: ViewModel { .store(in: &cancellables) } + // MARK: sendStopReport func sendStopReport() { let stopInfo = PlaybackStopInfo(item: item, From bc542dad8ddec2029a932138330dc7fed3bb880c Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Wed, 29 Dec 2021 15:25:50 -0700 Subject: [PATCH 15/62] begin matching subtitle and audio streams among adjacent items --- .../VideoPlayer/PlayerOverlayDelegate.swift | 2 +- .../Landscape/ItemLandscapeMainView.swift | 5 +- .../VLCPlayerCompactOverlayView.swift | 2 + .../VideoPlayer/PlayerOverlayDelegate.swift | 2 +- .../VideoPlayer/VLCPlayerViewController.swift | 85 +++++++++---------- .../BaseItemDto+VideoPlayerViewModel.swift | 13 ++- .../SwiftfinStore/SwiftfinStoreDefaults.swift | 4 +- Shared/ViewModels/VideoPlayerViewModel.swift | 45 +++++++++- 8 files changed, 104 insertions(+), 54 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index 338ddd85..2284fbd1 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -23,7 +23,7 @@ protocol PlayerOverlayDelegate { func didGenerallyTap() func didBeginScrubbing() - func didEndScrubbing(position: Double) + func didEndScrubbing() func didSelectAudioStream(index: Int) func didSelectSubtitleStream(index: Int) diff --git a/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift b/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift index 4cfc520c..a01b1f87 100644 --- a/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeMainView.swift @@ -21,7 +21,7 @@ struct ItemLandscapeMainView: View { // MARK: Sidebar Image VStack { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 130), + ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 130), bh: viewModel.item.getPrimaryImageBlurHash()) .frame(width: 130, height: 195) .cornerRadius(10) @@ -44,7 +44,8 @@ struct ItemLandscapeMainView: View { .frame(width: 130, height: 40) .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) .cornerRadius(10) - }.disabled(viewModel.playButtonItem == nil) + } + .disabled(viewModel.playButtonItem == nil || viewModel.itemVideoPlayerViewModel == nil) Spacer() } diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 8f198282..0d039e93 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -117,6 +117,8 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { Image(systemName: "captions.bubble") } } + .disabled(viewModel.selectedSubtitleStreamIndex == -1) + .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) } // MARK: Settings Menu diff --git a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift index 4b4fce33..e30045ab 100644 --- a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -23,7 +23,7 @@ protocol PlayerOverlayDelegate { func didGenerallyTap() func didBeginScrubbing() - func didEndScrubbing(position: Double) + func didEndScrubbing() func didSelectAudioStream(index: Int) func didSelectSubtitleStream(index: Int) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 33b7ee5d..575fd84e 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -27,7 +27,7 @@ class VLCPlayerViewController: UIViewController { private var vlcMediaPlayer = VLCMediaPlayer() private var lastPlayerTicks: Int64 = 0 private var lastProgressReportTicks: Int64 = 0 - private var cancellables = Set() + private var viewModelReactCancellables = Set() private var overlayDismissTimer: Timer? private var currentPlayerTicks: Int64 { @@ -229,17 +229,13 @@ extension VLCPlayerViewController { stopOverlayDismissTimer() - // UX improvement - (vlcMediaPlayer.drawable as! UIView).isHidden = true - // Stop current media if there is one if vlcMediaPlayer.media != nil { - cancellables.forEach({ $0.cancel() }) + viewModelReactCancellables.forEach({ $0.cancel() }) vlcMediaPlayer.stop() viewModel.sendStopReport() viewModel.playerOverlayDelegate = nil - vlcMediaPlayer.media = nil } lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 @@ -257,32 +253,27 @@ extension VLCPlayerViewController { newViewModel.getAdjacentEpisodes() newViewModel.playerOverlayDelegate = self + let startPercentage = viewModel.item.userData?.playedPercentage ?? 0 + + if startPercentage > 0 { + newViewModel.sliderPercentage = startPercentage / 100 + } + + didSelectSubtitleStream(index: newViewModel.selectedSubtitleStreamIndex) + didSelectAudioStream(index: newViewModel.selectedAudioStreamIndex) + viewModel = newViewModel } + // MARK: startPlayback func startPlayback() { - // UX improvement - (vlcMediaPlayer.drawable as! UIView).isHidden = false - vlcMediaPlayer.play() + setMediaPlayerTimeAtCurrentSlider() + viewModel.sendPlayReport() restartOverlayDismissTimer() - - // 1 second = 10,000,000 ticks - let startTicks: Int64 = viewModel.item.userData?.playbackPositionTicks ?? 0 - - if startTicks != 0 { - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let secondsScrubbedTo = startTicks / 10_000_000 - let offset = secondsScrubbedTo - Int64(videoPosition) - if offset > 0 { - vlcMediaPlayer.jumpForward(Int32(offset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(offset))) - } - } } // MARK: setupViewModelListeners @@ -290,27 +281,42 @@ extension VLCPlayerViewController { private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { viewModel.$playbackSpeed.sink { newSpeed in self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &cancellables) + }.store(in: &viewModelReactCancellables) viewModel.$screenFilled.sink { shouldFill in self.changeFill(to: shouldFill) - }.store(in: &cancellables) + }.store(in: &viewModelReactCancellables) viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in if sliderIsScrubbing { self.didBeginScrubbing() } else { - self.didEndScrubbing(position: self.viewModel.sliderPercentage) + self.didEndScrubbing() } - }.store(in: &cancellables) + }.store(in: &viewModelReactCancellables) viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &cancellables) + }.store(in: &viewModelReactCancellables) viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &cancellables) + }.store(in: &viewModelReactCancellables) + } + + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000) + let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) + let newPositionOffset = secondsScrubbedTo - videoPosition + + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } } } @@ -386,13 +392,15 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + // Have to manually set playing because VLCMediaPlayer doesn't + // properly set it itself if abs(currentPlayerTicks - lastPlayerTicks) >= 10_000 { - viewModel.playerState = VLCMediaPlayerState.playing } lastPlayerTicks = currentPlayerTicks + // Send progress report every 5 seconds if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { viewModel.sendProgressReport() @@ -438,7 +446,7 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { viewModel.subtitlesEnabled = !viewModel.subtitlesEnabled if viewModel.subtitlesEnabled { - vlcMediaPlayer.currentVideoSubTitleIndex = vlcMediaPlayer.videoSubTitlesIndexes[1] as! Int32 + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) } else { vlcMediaPlayer.currentVideoSubTitleIndex = -1 } @@ -501,19 +509,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { stopOverlayDismissTimer() } - func didEndScrubbing(position: Double) { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() restartOverlayDismissTimer() diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index b5b3a969..7adbffb0 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -79,9 +79,18 @@ extension BaseItemDto { hlsURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(defaultSubtitleStream!.index!)") } + var subtitle: String? = nil + + // TODO: other forms of media subtitle + if self.itemType == .episode { + if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { + subtitle = "\(seriesName) - \(episodeLocator)" + } + } + let videoPlayerViewModel = VideoPlayerViewModel(item: self, title: self.name!, - subtitle: self.seriesName, + subtitle: subtitle, streamURL: streamURL.url!, hlsURL: hlsURL.url!, response: response, @@ -92,7 +101,7 @@ extension BaseItemDto { playerState: .playing, shouldShowGoogleCast: false, shouldShowAirplay: false, - subtitlesEnabled: defaultAudioStream?.index != nil, + subtitlesEnabled: defaultSubtitleStream?.index != nil, sliderPercentage: (self.userData?.playedPercentage ?? 0) / 100, selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 06f858ad..d6c70b5e 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -30,7 +30,7 @@ extension Defaults.Keys { static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", default: "Auto", suite: SwiftfinStore.Defaults.suite) static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.suite) static let appAppearance = Key("appAppearance", default: .system, suite: SwiftfinStore.Defaults.suite) - static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .thirty, suite: SwiftfinStore.Defaults.suite) - static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .thirty, suite: SwiftfinStore.Defaults.suite) + static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.suite) + static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.suite) static let nativeVideoPlayer = Key("nativeVideoPlayer", default: false, suite: SwiftfinStore.Defaults.suite) } diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 7fcecd21..6f0080ec 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -35,8 +35,18 @@ final class VideoPlayerViewModel: ViewModel { } } @Published var sliderIsScrubbing: Bool = false - @Published var selectedAudioStreamIndex: Int - @Published var selectedSubtitleStreamIndex: Int + @Published var selectedAudioStreamIndex: Int { + didSet { + previousItemVideoPlayerViewModel?.matchAudioStream(with: self) + nextItemVideoPlayerViewModel?.matchAudioStream(with: self) + } + } + @Published var selectedSubtitleStreamIndex: Int { + didSet { + previousItemVideoPlayerViewModel?.matchSubtitleStream(with: self) + nextItemVideoPlayerViewModel?.matchSubtitleStream(with: self) + } + } @Published var showAdjacentItems: Bool @Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel? @Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel? @@ -173,6 +183,9 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + self.nextItemVideoPlayerViewModel = videoPlayerViewModel } .store(in: &self.cancellables) @@ -184,6 +197,9 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + self.previousItemVideoPlayerViewModel = videoPlayerViewModel } .store(in: &self.cancellables) @@ -198,6 +214,9 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + self.previousItemVideoPlayerViewModel = videoPlayerViewModel } .store(in: &self.cancellables) @@ -206,6 +225,9 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + self.nextItemVideoPlayerViewModel = videoPlayerViewModel } .store(in: &self.cancellables) @@ -213,6 +235,25 @@ extension VideoPlayerViewModel { }) .store(in: &cancellables) } + + private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) { + guard let currentSubtitleStream = masterViewModel.subtitleStreams.first(where: { $0.index == masterViewModel.selectedSubtitleStreamIndex }) else { return } + guard let matchingSubtitleStream = self.subtitleStreams.first(where: { mediaStreamAboutEqual($0, currentSubtitleStream) }) else { return } + + self.subtitlesEnabled = masterViewModel.subtitlesEnabled + self.selectedSubtitleStreamIndex = matchingSubtitleStream.index ?? -1 + } + + private func matchAudioStream(with masterViewModel: VideoPlayerViewModel) { + guard let currentAudioStream = masterViewModel.audioStreams.first(where: { $0.index == masterViewModel.selectedAudioStreamIndex }), + let matchingAudioStream = self.audioStreams.first(where: { mediaStreamAboutEqual($0, currentAudioStream) }) else { return } + + self.selectedAudioStreamIndex = matchingAudioStream.index ?? -1 + } + + private func mediaStreamAboutEqual(_ lhs: MediaStream, _ rhs: MediaStream) -> Bool { + return lhs.displayTitle == rhs.displayTitle && lhs.language == rhs.language + } } // MARK: Reports From 4219ecc8dc5cb0e63353681ef4d5cb7d01d317ff Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 30 Dec 2021 00:54:21 -0700 Subject: [PATCH 16/62] persist subtitle state across items --- .../VideoPlayer/VLCPlayerViewController.swift | 29 ++++++++--- Shared/ViewModels/VideoPlayerViewModel.swift | 50 +++++++++++++------ 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 575fd84e..dcf53148 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -259,9 +259,6 @@ extension VLCPlayerViewController { newViewModel.sliderPercentage = startPercentage / 100 } - didSelectSubtitleStream(index: newViewModel.selectedSubtitleStreamIndex) - didSelectAudioStream(index: newViewModel.selectedAudioStreamIndex) - viewModel = newViewModel } @@ -374,6 +371,8 @@ extension VLCPlayerViewController { // MARK: VLCMediaPlayerDelegate extension VLCPlayerViewController: VLCMediaPlayerDelegate { + + // MARK: mediaPlayerStateChanged func mediaPlayerStateChanged(_ aNotification: Notification!) { self.viewModel.playerState = vlcMediaPlayer.state @@ -383,6 +382,7 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { } } + // MARK: mediaPlayerTimeChanged func mediaPlayerTimeChanged(_ aNotification: Notification!) { guard !viewModel.sliderIsScrubbing else { @@ -398,6 +398,12 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { viewModel.playerState = VLCMediaPlayerState.playing } + // If needing to fix subtitle streams during playback + if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && viewModel.subtitlesEnabled { + didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) + } + lastPlayerTicks = currentPlayerTicks // Send progress report every 5 seconds @@ -414,15 +420,22 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { func didSelectAudioStream(index: Int) { vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks } func didSelectSubtitleStream(index: Int) { - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) - - if index != -1 { - // set in case weren't shown - viewModel.subtitlesEnabled = true + if viewModel.subtitlesEnabled { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks } func didSelectClose() { diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 6f0080ec..67788454 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -23,7 +23,14 @@ final class VideoPlayerViewModel: ViewModel { @Published var playerState: VLCMediaPlayerState @Published var shouldShowGoogleCast: Bool @Published var shouldShowAirplay: Bool - @Published var subtitlesEnabled: Bool + @Published var subtitlesEnabled: Bool { + didSet { + if subtitlesEnabled != oldValue { + previousItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) + nextItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) + } + } + } @Published var leftLabelText: String = "--:--" @Published var rightLabelText: String = "--:--" @Published var playbackSpeed: PlaybackSpeed = .one @@ -237,11 +244,15 @@ extension VideoPlayerViewModel { } private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) { - guard let currentSubtitleStream = masterViewModel.subtitleStreams.first(where: { $0.index == masterViewModel.selectedSubtitleStreamIndex }) else { return } - guard let matchingSubtitleStream = self.subtitleStreams.first(where: { mediaStreamAboutEqual($0, currentSubtitleStream) }) else { return } + if !masterViewModel.subtitlesEnabled { + matchSubtitlesEnabled(with: masterViewModel) + } - self.subtitlesEnabled = masterViewModel.subtitlesEnabled - self.selectedSubtitleStreamIndex = matchingSubtitleStream.index ?? -1 + guard let masterSubtitleStream = masterViewModel.subtitleStreams.first(where: { $0.index == masterViewModel.selectedSubtitleStreamIndex }), + let matchingSubtitleStream = self.subtitleStreams.first(where: { mediaStreamAboutEqual($0, masterSubtitleStream) }), + let matchingSubtitleStreamIndex = matchingSubtitleStream.index else { return } + + self.selectedSubtitleStreamIndex = matchingSubtitleStreamIndex } private func matchAudioStream(with masterViewModel: VideoPlayerViewModel) { @@ -251,12 +262,16 @@ extension VideoPlayerViewModel { self.selectedAudioStreamIndex = matchingAudioStream.index ?? -1 } + private func matchSubtitlesEnabled(with masterViewModel: VideoPlayerViewModel) { + self.subtitlesEnabled = masterViewModel.subtitlesEnabled + } + private func mediaStreamAboutEqual(_ lhs: MediaStream, _ rhs: MediaStream) -> Bool { return lhs.displayTitle == rhs.displayTitle && lhs.language == rhs.language } } -// MARK: Reports +// MARK: Updates extension VideoPlayerViewModel { @@ -265,13 +280,15 @@ extension VideoPlayerViewModel { self.startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000 + let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil + let startInfo = PlaybackStartInfo(canSeek: true, item: item, itemId: item.id, sessionId: response.playSessionId, mediaSourceId: item.id, - audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index, - subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index, + audioStreamIndex: selectedAudioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, isPaused: false, isMuted: false, positionTicks: item.userData?.playbackPositionTicks, @@ -298,13 +315,16 @@ extension VideoPlayerViewModel { // MARK: sendPauseReport func sendPauseReport(paused: Bool) { - let startInfo = PlaybackStartInfo(canSeek: true, + + let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil + + let pauseInfo = PlaybackStartInfo(canSeek: true, item: item, itemId: item.id, sessionId: response.playSessionId, mediaSourceId: item.id, - audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index, - subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index, + audioStreamIndex: selectedAudioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, isPaused: paused, isMuted: false, positionTicks: currentSecondTicks, @@ -320,7 +340,7 @@ extension VideoPlayerViewModel { playlistItemId: "playlistItem0" ) - PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) + PlaystateAPI.reportPlaybackStart(playbackStartInfo: pauseInfo) .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { _ in @@ -332,13 +352,15 @@ extension VideoPlayerViewModel { // MARK: sendProgressReport func sendProgressReport() { + let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil + let progressInfo = PlaybackProgressInfo(canSeek: true, item: item, itemId: item.id, sessionId: response.playSessionId, mediaSourceId: item.id, - audioStreamIndex: audioStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultAudioStreamIndex! })?.index, - subtitleStreamIndex: subtitleStreams.first(where: { $0.index! == response.mediaSources?.first?.defaultSubtitleStreamIndex ?? -1 })?.index, + audioStreamIndex: selectedAudioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, isPaused: false, isMuted: false, positionTicks: currentSecondTicks, From a25a62a2bebb5cf865acff07d1eb4147b8b7cf83 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 30 Dec 2021 01:28:08 -0700 Subject: [PATCH 17/62] Add black gradient behind compact overlay buttons --- .../VLCPlayerCompactOverlayView.swift | 338 +++++++++--------- 1 file changed, 170 insertions(+), 168 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 0d039e93..e2ddc63d 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -36,167 +36,176 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { private var mainBody: some View { VStack { - VStack(alignment: .EpisodeSeriesAlignmentGuide) { + // MARK: Top Bar + ZStack { - // MARK: Top Bar - HStack(alignment: .center) { + LinearGradient(gradient: Gradient(colors: [.black, .clear]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 80) + + VStack(alignment: .EpisodeSeriesAlignmentGuide) { - HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectClose() - } label: { - Image(systemName: "chevron.backward") - .padding() - .padding(.trailing, -10) + HStack(alignment: .center) { + + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectClose() + } label: { + Image(systemName: "chevron.backward") + .padding() + .padding(.trailing, -10) + } + + Text(viewModel.title) + .font(.system(size: 28, weight: .regular, design: .default)) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } } - Text(viewModel.title) - .font(.system(size: 28, weight: .regular, design: .default)) + Spacer() + + HStack(spacing: 20) { + + if viewModel.showAdjacentItems { + Button { + viewModel.playerOverlayDelegate?.didSelectPreviousItem() + } label: { + Image(systemName: "chevron.left.circle") + } + .disabled(viewModel.previousItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + + Button { + viewModel.playerOverlayDelegate?.didSelectNextItem() + } label: { + Image(systemName: "chevron.right.circle") + } + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } + + if viewModel.shouldShowGoogleCast { + Button { + viewModel.playerOverlayDelegate?.didSelectGoogleCast() + } label: { + Image(systemName: "rectangle.badge.plus") + } + } + + if viewModel.shouldShowAirplay { + Button { + viewModel.playerOverlayDelegate?.didSelectAirplay() + } label: { + Image(systemName: "airplayvideo") + } + } + + // Button { + // viewModel.screenFilled = !viewModel.screenFilled + // } label: { + // if viewModel.screenFilled { + // Image(systemName: "rectangle.arrowtriangle.2.inward") + // .rotationEffect(Angle(degrees: 90)) + // } else { + // Image(systemName: "rectangle.arrowtriangle.2.outward") + // .rotationEffect(Angle(degrees: 90)) + // } + // } + + if !viewModel.subtitleStreams.isEmpty { + Button { + viewModel.playerOverlayDelegate?.didSelectCaptions() + } label: { + if viewModel.subtitlesEnabled { + Image(systemName: "captions.bubble.fill") + } else { + Image(systemName: "captions.bubble") + } + } + .disabled(viewModel.selectedSubtitleStreamIndex == -1) + .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) + } + + // MARK: Settings Menu + Menu { + + Menu { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "speaker.wave.3") + Text("Audio") + } + } + + Menu { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "captions.bubble") + Text("Subtitles") + } + } + + Menu { + ForEach(PlaybackSpeed.allCases, id: \.self) { speed in + Button { + viewModel.playbackSpeed = speed + } label: { + if speed == viewModel.playbackSpeed { + Label(speed.displayTitle, systemImage: "checkmark") + } else { + Text(speed.displayTitle) + } + } + } + } label: { + HStack { + Image(systemName: "speedometer") + Text("Playback Speed") + } + } + + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .font(.system(size: 24)) + .frame(height: 50) + + if let seriesTitle = viewModel.subtitle { + Text(seriesTitle) + .font(.subheadline) + .foregroundColor(Color.gray) .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in context[.leading] } + .offset(y: -10) } - - Spacer() - - HStack(spacing: 20) { - - if viewModel.showAdjacentItems { - Button { - viewModel.playerOverlayDelegate?.didSelectPreviousItem() - } label: { - Image(systemName: "chevron.left.circle") - } - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - - Button { - viewModel.playerOverlayDelegate?.didSelectNextItem() - } label: { - Image(systemName: "chevron.right.circle") - } - .disabled(viewModel.nextItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - if viewModel.shouldShowGoogleCast { - Button { - viewModel.playerOverlayDelegate?.didSelectGoogleCast() - } label: { - Image(systemName: "rectangle.badge.plus") - } - } - - if viewModel.shouldShowAirplay { - Button { - viewModel.playerOverlayDelegate?.didSelectAirplay() - } label: { - Image(systemName: "airplayvideo") - } - } - -// Button { -// viewModel.screenFilled = !viewModel.screenFilled -// } label: { -// if viewModel.screenFilled { -// Image(systemName: "rectangle.arrowtriangle.2.inward") -// .rotationEffect(Angle(degrees: 90)) -// } else { -// Image(systemName: "rectangle.arrowtriangle.2.outward") -// .rotationEffect(Angle(degrees: 90)) -// } -// } - - if !viewModel.subtitleStreams.isEmpty { - Button { - viewModel.playerOverlayDelegate?.didSelectCaptions() - } label: { - if viewModel.subtitlesEnabled { - Image(systemName: "captions.bubble.fill") - } else { - Image(systemName: "captions.bubble") - } - } - .disabled(viewModel.selectedSubtitleStreamIndex == -1) - .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) - } - - // MARK: Settings Menu - Menu { - - Menu { - ForEach(viewModel.audioStreams, id: \.self) { audioStream in - Button { - viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 - } label: { - if audioStream.index == viewModel.selectedAudioStreamIndex { - Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") - } else { - Text(audioStream.displayTitle ?? "No Title") - } - } - } - } label: { - HStack { - Image(systemName: "speaker.wave.3") - Text("Audio") - } - } - - Menu { - ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in - Button { - viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 - } label: { - if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { - Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") - } else { - Text(subtitleStream.displayTitle ?? "No Title") - } - } - } - } label: { - HStack { - Image(systemName: "captions.bubble") - Text("Subtitles") - } - } - - Menu { - ForEach(PlaybackSpeed.allCases, id: \.self) { speed in - Button { - viewModel.playbackSpeed = speed - } label: { - if speed == viewModel.playbackSpeed { - Label(speed.displayTitle, systemImage: "checkmark") - } else { - Text(speed.displayTitle) - } - } - } - } label: { - HStack { - Image(systemName: "speedometer") - Text("Playback Speed") - } - } - - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - .font(.system(size: 24)) - .frame(height: 50) - - if let seriesTitle = viewModel.subtitle { - Text(seriesTitle) - .font(.subheadline) - .foregroundColor(Color.gray) - .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in - context[.leading] - } - .offset(y: -10) } } @@ -205,11 +214,11 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { // MARK: Bottom Bar ZStack { -// VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) -// .cornerRadius(25) -// .mask { -// Rectangle() -// } + LinearGradient(gradient: Gradient(colors: [.clear, .black]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 70) HStack { @@ -266,8 +275,9 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { .frame(minWidth: 70, maxWidth: 70) } .padding(.horizontal) +// .frame(maxWidth: 800, maxHeight: 50) } - .frame(maxWidth: 800, maxHeight: 50) + .frame(maxHeight: 50) } .ignoresSafeArea(edges: .top) .tint(Color.white) @@ -283,16 +293,10 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { } } -struct VisualEffectView: UIViewRepresentable { - var effect: UIVisualEffect? - func makeUIView(context: UIViewRepresentableContext) -> UIVisualEffectView { UIVisualEffectView() } - func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext) { uiView.effect = effect } -} - struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { static var previews: some View { ZStack { - Color.black + Color.red .ignoresSafeArea() VLCPlayerCompactOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 720 * 10_000_000), @@ -317,5 +321,3 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { .previewInterfaceOrientation(.landscapeLeft) } } - - From 01e52e59b704b6d3a446afe4766802e49f9731f8 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 30 Dec 2021 02:22:36 -0700 Subject: [PATCH 18/62] add stop/pause for backgrounding and terminating --- .../VideoPlayer/VLCPlayerViewController.swift | 25 +++++++++++++++++++ Shared/ViewModels/VideoPlayerViewModel.swift | 4 +++ 2 files changed, 29 insertions(+) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index dcf53148..c028052c 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -98,6 +98,11 @@ class VLCPlayerViewController: UIViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) + // AppUtility.lockOrientation(.all) } @@ -118,6 +123,25 @@ class VLCPlayerViewController: UIViewController { vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) setupMediaPlayer(newViewModel: viewModel) + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil) + defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + @objc private func appWillTerminate() { + viewModel.sendStopReport() + } + + @objc private func appWillResignActive() { + showOverlay() + + stopOverlayDismissTimer() + + vlcMediaPlayer.pause() + + viewModel.sendPauseReport(paused: true) } private func changeFill(to shouldFill: Bool) { @@ -508,6 +532,7 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { case .paused: viewModel.sendPauseReport(paused: false) vlcMediaPlayer.play() + restartOverlayDismissTimer() default: () } } diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 67788454..961abff2 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -129,6 +129,10 @@ final class VideoPlayerViewModel: ViewModel { super.init() self.sliderPercentageChanged(newValue: (item.userData?.playedPercentage ?? 0) / 100) + + if item.itemType != .episode { + self.showAdjacentItems = false + } } private func sliderPercentageChanged(newValue: Double) { From 604f41bffd37b05b369aba8f26d59ab0af800c4d Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 30 Dec 2021 03:13:02 -0700 Subject: [PATCH 19/62] auto-landscape rotate, overlay fade in/out, and appearance setting fix --- JellyfinPlayer/App/JellyfinPlayerApp.swift | 9 ++++- .../VideoPlayer/VLCPlayerViewController.swift | 37 +++++++++++-------- .../iOSVideoPlayerCoordinator.swift | 2 + 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/JellyfinPlayer/App/JellyfinPlayerApp.swift b/JellyfinPlayer/App/JellyfinPlayerApp.swift index 3d11e057..88d6880a 100644 --- a/JellyfinPlayer/App/JellyfinPlayerApp.swift +++ b/JellyfinPlayer/App/JellyfinPlayerApp.swift @@ -19,8 +19,11 @@ struct JellyfinPlayerApp: App { var body: some Scene { WindowGroup { - MainCoordinator().view() + EmptyView() .ignoresSafeArea() + .withHostingWindow({ window in + window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view()) + }) .onAppear { setupAppearance() } @@ -34,7 +37,9 @@ struct JellyfinPlayerApp: App { } private func setupAppearance() { - UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first as? UIWindowScene + windowScene?.windows.first?.overrideUserInterfaceStyle = appAppearance.style } } diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index c028052c..d2648ca6 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -85,14 +85,6 @@ class VLCPlayerViewController: UIViewController { ]) } - // MARK: viewWillAppear - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - -// AppUtility.lockOrientation(.all, andRotateTo: .landscapeLeft) - } - // MARK: viewWillDisappear override func viewWillDisappear(_ animated: Bool) { @@ -102,8 +94,6 @@ class VLCPlayerViewController: UIViewController { defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) - -// AppUtility.lockOrientation(.all) } // MARK: viewDidLoad @@ -209,13 +199,19 @@ class VLCPlayerViewController: UIViewController { // MARK: setupOverlayHostingController private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { - + + // TODO: Look at injecting viewModel into the environment so it updates the current overlay if let currentOverlayHostingController = currentOverlayHostingController { - currentOverlayHostingController.view.isHidden = true - - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - self.currentOverlayHostingController = nil + // UX fade-out + UIView.animate(withDuration: 0.5) { + currentOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() +// self.currentOverlayHostingController = nil + } } let newOverlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) @@ -223,6 +219,10 @@ class VLCPlayerViewController: UIViewController { newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false newOverlayHostingController.view.backgroundColor = UIColor.clear + + // UX fade-in + newOverlayHostingController.view.alpha = 0 + addChild(newOverlayHostingController) view.addSubview(newOverlayHostingController.view) newOverlayHostingController.didMove(toParent: self) @@ -234,6 +234,11 @@ class VLCPlayerViewController: UIViewController { newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) ]) + // UX fade-in + UIView.animate(withDuration: 0.5) { + newOverlayHostingController.view.alpha = 1 + } + self.currentOverlayHostingController = newOverlayHostingController // There is a behavior when setting this that the navigation bar diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift index 494901d3..f15a8b63 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift @@ -34,6 +34,7 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { .statusBar(hidden: true) .ignoresSafeArea() .prefersHomeIndicatorAutoHidden(true) + .supportedOrientations(.landscape) }.ignoresSafeArea() } else { PreferenceUIHostingControllerView { @@ -42,6 +43,7 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { .statusBar(hidden: true) .ignoresSafeArea() .prefersHomeIndicatorAutoHidden(true) + .supportedOrientations(.landscape) }.ignoresSafeArea() } } From d5e225dce1fa9f81be898383b4a872f715c2621b Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 30 Dec 2021 13:54:24 -0700 Subject: [PATCH 20/62] remove VLC screen filled --- .../VLCPlayerCompactOverlayView.swift | 12 ---------- .../VideoPlayer/VLCPlayerViewController.swift | 22 ------------------- Shared/ViewModels/VideoPlayerViewModel.swift | 1 - 3 files changed, 35 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index e2ddc63d..544af8df 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -103,18 +103,6 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { } } - // Button { - // viewModel.screenFilled = !viewModel.screenFilled - // } label: { - // if viewModel.screenFilled { - // Image(systemName: "rectangle.arrowtriangle.2.inward") - // .rotationEffect(Angle(degrees: 90)) - // } else { - // Image(systemName: "rectangle.arrowtriangle.2.outward") - // .rotationEffect(Angle(degrees: 90)) - // } - // } - if !viewModel.subtitleStreams.isEmpty { Button { viewModel.playerOverlayDelegate?.didSelectCaptions() diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index d2648ca6..4f2cada4 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -134,24 +134,6 @@ class VLCPlayerViewController: UIViewController { viewModel.sendPauseReport(paused: true) } - private func changeFill(to shouldFill: Bool) { - if shouldFill { - // TODO: May not be possible with current VLCKit - -// let drawableView = vlcMediaPlayer.drawable as! UIView -// let drawableViewSize = drawableView.frame.size -// let mediaSize = vlcMediaPlayer.videoSize - - // Largest size from mediaSize is how it is currently filled - // in the drawable view, find scaleFactor by filling entire - // drawableView - - vlcMediaPlayer.scaleFactor = 1.5 - } else { - vlcMediaPlayer.scaleFactor = 0 - } - } - override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -309,10 +291,6 @@ extension VLCPlayerViewController { self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) }.store(in: &viewModelReactCancellables) - viewModel.$screenFilled.sink { shouldFill in - self.changeFill(to: shouldFill) - }.store(in: &viewModelReactCancellables) - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in if sliderIsScrubbing { self.didBeginScrubbing() diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 961abff2..45a1956d 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -34,7 +34,6 @@ final class VideoPlayerViewModel: ViewModel { @Published var leftLabelText: String = "--:--" @Published var rightLabelText: String = "--:--" @Published var playbackSpeed: PlaybackSpeed = .one - @Published var screenFilled: Bool = false @Published var sliderPercentage: Double { willSet { sliderScrubbingSubject.send(self) From c4466c2cb9dfb77923a959e4faf6a5b61576382e Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 30 Dec 2021 14:03:29 -0700 Subject: [PATCH 21/62] allow edit jump forward and backward during playback --- .../VLCPlayerCompactOverlayView.swift | 38 +++++++++++++++++++ Shared/Objects/VideoPlayerJumpLength.swift | 4 ++ 2 files changed, 42 insertions(+) diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 544af8df..f0fe74a0 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -176,6 +176,44 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { Text("Playback Speed") } } + + Menu { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in + Button { + jumpForwardLength = forwardLength + } label: { + if forwardLength == jumpForwardLength { + Label(forwardLength.shortLabel, systemImage: "checkmark") + } else { + Text(forwardLength.shortLabel) + } + } + } + } label: { + HStack { + Image(systemName: "goforward") + Text("Jump Forward Length") + } + } + + Menu { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { backwardLength in + Button { + jumpBackwardLength = backwardLength + } label: { + if backwardLength == jumpBackwardLength { + Label(backwardLength.shortLabel, systemImage: "checkmark") + } else { + Text(backwardLength.shortLabel) + } + } + } + } label: { + HStack { + Image(systemName: "gobackward") + Text("Jump Backward Length") + } + } } label: { Image(systemName: "ellipsis.circle") diff --git a/Shared/Objects/VideoPlayerJumpLength.swift b/Shared/Objects/VideoPlayerJumpLength.swift index 2d660cd4..84b1ab73 100644 --- a/Shared/Objects/VideoPlayerJumpLength.swift +++ b/Shared/Objects/VideoPlayerJumpLength.swift @@ -20,6 +20,10 @@ enum VideoPlayerJumpLength: Int32, CaseIterable, Defaults.Serializable { return "\(self.rawValue) seconds" } + var shortLabel: String { + return "\(self.rawValue)s" + } + var forwardImageLabel: String { switch self { case .thirty: From 678fdd03bf08d851ba5bfd07fb93852c4b5e80f1 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 30 Dec 2021 14:25:52 -0700 Subject: [PATCH 22/62] add autoplay setting --- .../Overlays/VLCPlayerCompactOverlayView.swift | 16 +++++++++++++++- .../Overlays/VLCPlayerOverlayView.swift | 4 +++- .../VideoPlayer/VLCPlayerViewController.swift | 8 ++++++-- .../BaseItemDto+VideoPlayerViewModel.swift | 13 ++++++++++++- .../SwiftfinStore/SwiftfinStoreDefaults.swift | 2 ++ Shared/ViewModels/VideoPlayerViewModel.swift | 17 ++++++++++++++++- 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index f0fe74a0..ca73a0f7 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -117,6 +117,18 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) } + if viewModel.shouldShowAutoPlayNextItem { + Button { + viewModel.autoPlayNextItem.toggle() + } label: { + if viewModel.autoPlayNextItem { + Image(systemName: "play.circle.fill") + } else { + Image(systemName: "play.circle") + } + } + } + // MARK: Settings Menu Menu { @@ -342,7 +354,9 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { sliderPercentage: 0.432, selectedAudioStreamIndex: -1, selectedSubtitleStreamIndex: -1, - showAdjacentItems: true)) + showAdjacentItems: true, + shouldShowAutoPlayNextItem: true, + autoPlayNextItem: true)) } .previewInterfaceOrientation(.landscapeLeft) } diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index dd494e50..00971f26 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -240,7 +240,9 @@ struct VLCPlayerOverlayView_Previews: PreviewProvider { sliderPercentage: 0.0, selectedAudioStreamIndex: -1, selectedSubtitleStreamIndex: -1, - showAdjacentItems: true)) + showAdjacentItems: true, + shouldShowAutoPlayNextItem: true, + autoPlayNextItem: true)) } .previewInterfaceOrientation(.landscapeLeft) } diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 4f2cada4..b16d908e 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -382,10 +382,14 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { // MARK: mediaPlayerStateChanged func mediaPlayerStateChanged(_ aNotification: Notification!) { - self.viewModel.playerState = vlcMediaPlayer.state + viewModel.playerState = vlcMediaPlayer.state if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - didSelectClose() + if viewModel.autoPlayNextItem && viewModel.nextItemVideoPlayerViewModel != nil { + didSelectNextItem() + } else { + didSelectClose() + } } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index 7adbffb0..ff60582b 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -8,6 +8,7 @@ */ import Combine +import Defaults import JellyfinAPI import UIKit @@ -88,6 +89,14 @@ extension BaseItemDto { } } + + // MARK: VidoPlayerViewModel Creation + + // TODO: show adjacent items + + let shouldShowAutoPlayNextItem = Defaults[.shouldShowAutoPlayNextItem] + let autoPlayNextItem = Defaults[.autoPlayNextItem] + let videoPlayerViewModel = VideoPlayerViewModel(item: self, title: self.name!, subtitle: subtitle, @@ -105,7 +114,9 @@ extension BaseItemDto { sliderPercentage: (self.userData?.playedPercentage ?? 0) / 100, selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, - showAdjacentItems: true) + showAdjacentItems: true, + shouldShowAutoPlayNextItem: shouldShowAutoPlayNextItem, + autoPlayNextItem: autoPlayNextItem) return videoPlayerViewModel }) diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index d6c70b5e..118fa85f 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -33,4 +33,6 @@ extension Defaults.Keys { static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.suite) static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.suite) static let nativeVideoPlayer = Key("nativeVideoPlayer", default: false, suite: SwiftfinStore.Defaults.suite) + static let shouldShowAutoPlayNextItem = Key("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.suite) + static let autoPlayNextItem = Key("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.suite) } diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 45a1956d..a1eeab45 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -6,6 +6,7 @@ // import Combine +import Defaults import Foundation import JellyfinAPI import UIKit @@ -54,6 +55,16 @@ final class VideoPlayerViewModel: ViewModel { } } @Published var showAdjacentItems: Bool + @Published var shouldShowAutoPlayNextItem: Bool { + willSet { + Defaults[.shouldShowAutoPlayNextItem] = newValue + } + } + @Published var autoPlayNextItem: Bool { + willSet { + Defaults[.autoPlayNextItem] = newValue + } + } @Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel? @Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel? @@ -105,7 +116,9 @@ final class VideoPlayerViewModel: ViewModel { sliderPercentage: Double, selectedAudioStreamIndex: Int, selectedSubtitleStreamIndex: Int, - showAdjacentItems: Bool) { + showAdjacentItems: Bool, + shouldShowAutoPlayNextItem: Bool, + autoPlayNextItem: Bool) { self.item = item self.title = title self.subtitle = subtitle @@ -124,6 +137,8 @@ final class VideoPlayerViewModel: ViewModel { self.selectedAudioStreamIndex = selectedAudioStreamIndex self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex self.showAdjacentItems = showAdjacentItems + self.shouldShowAutoPlayNextItem = shouldShowAutoPlayNextItem + self.autoPlayNextItem = autoPlayNextItem super.init() From 45a93b9de531b0ce74a3d4da07398e7271cd984a Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 30 Dec 2021 14:28:19 -0700 Subject: [PATCH 23/62] rearrange items on compact overlay --- .../VLCPlayerCompactOverlayView.swift | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index ca73a0f7..b161cad4 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -69,6 +69,22 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { HStack(spacing: 20) { + if viewModel.shouldShowGoogleCast { + Button { + viewModel.playerOverlayDelegate?.didSelectGoogleCast() + } label: { + Image(systemName: "rectangle.badge.plus") + } + } + + if viewModel.shouldShowAirplay { + Button { + viewModel.playerOverlayDelegate?.didSelectAirplay() + } label: { + Image(systemName: "airplayvideo") + } + } + if viewModel.showAdjacentItems { Button { viewModel.playerOverlayDelegate?.didSelectPreviousItem() @@ -87,19 +103,15 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) } - if viewModel.shouldShowGoogleCast { + if viewModel.shouldShowAutoPlayNextItem { Button { - viewModel.playerOverlayDelegate?.didSelectGoogleCast() + viewModel.autoPlayNextItem.toggle() } label: { - Image(systemName: "rectangle.badge.plus") - } - } - - if viewModel.shouldShowAirplay { - Button { - viewModel.playerOverlayDelegate?.didSelectAirplay() - } label: { - Image(systemName: "airplayvideo") + if viewModel.autoPlayNextItem { + Image(systemName: "play.circle.fill") + } else { + Image(systemName: "play.circle") + } } } @@ -117,18 +129,6 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) } - if viewModel.shouldShowAutoPlayNextItem { - Button { - viewModel.autoPlayNextItem.toggle() - } label: { - if viewModel.autoPlayNextItem { - Image(systemName: "play.circle.fill") - } else { - Image(systemName: "play.circle") - } - } - } - // MARK: Settings Menu Menu { From 4e2af9ec7f6a8df8dcdb51060b6b5ada8a5ca607 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 30 Dec 2021 15:20:47 -0700 Subject: [PATCH 24/62] some marks on sessionmanager --- Shared/Singleton/SessionManager.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Shared/Singleton/SessionManager.swift b/Shared/Singleton/SessionManager.swift index cff2222b..d15ad30f 100644 --- a/Shared/Singleton/SessionManager.swift +++ b/Shared/Singleton/SessionManager.swift @@ -20,12 +20,16 @@ typealias CurrentLogin = (server: SwiftfinStore.State.Server, user: SwiftfinStor // MARK: NewSessionManager final class SessionManager { + // MARK: currentLogin + private(set) var currentLogin: CurrentLogin! // MARK: main + static let main = SessionManager() + // MARK: init private init() { if let lastUserID = SwiftfinStore.Defaults.suite[.lastServerUserID], let user = try? SwiftfinStore.dataStack.fetchOne(From(), @@ -40,11 +44,13 @@ final class SessionManager { } } + // MARK: fetchServers func fetchServers() -> [SwiftfinStore.State.Server] { let servers = try! SwiftfinStore.dataStack.fetchAll(From()) return servers.map({ $0.state }) } + // MARK: fetchUsers func fetchUsers(for server: SwiftfinStore.State.Server) -> [SwiftfinStore.State.User] { guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From(), Where("id == %@", server.id)) @@ -52,6 +58,7 @@ final class SessionManager { return storedServer.users.map({ $0.state }).sorted(by: { $0.username < $1.username }) } + // MARK: connectToServer publisher // Connects to a server at the given uri, storing if successful func connectToServer(with uri: String) -> AnyPublisher { var uriComponents = URLComponents(string: uri) ?? URLComponents() @@ -104,6 +111,7 @@ final class SessionManager { .eraseToAnyPublisher() } + // MARK: addURIToServer publisher func addURIToServer(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher { return Just(server) .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in @@ -129,6 +137,7 @@ final class SessionManager { .eraseToAnyPublisher() } + // MARK: setServerCurrentURI publisher func setServerCurrentURI(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher { return Just(server) .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in @@ -158,6 +167,7 @@ final class SessionManager { .eraseToAnyPublisher() } + // MARK: loginUser publisher // Logs in a user with an associated server, storing if successful func loginUser(server: SwiftfinStore.State.Server, username: String, password: String) -> AnyPublisher { setAuthHeader(with: "") @@ -174,7 +184,7 @@ final class SessionManager { guard let username = response.user?.name, let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") } - + newUser.username = username newUser.id = id newUser.appleTVID = "" @@ -217,6 +227,7 @@ final class SessionManager { .eraseToAnyPublisher() } + // MARK: loginUser func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { JellyfinAPI.basePath = server.currentURI SwiftfinStore.Defaults.suite[.lastServerUserID] = user.id @@ -225,6 +236,7 @@ final class SessionManager { SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) } + // MARK: logout func logout() { currentLogin = nil JellyfinAPI.basePath = "" @@ -233,6 +245,7 @@ final class SessionManager { SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) } + // MARK: purge func purge() { // Delete all servers let servers = fetchServers() @@ -247,12 +260,14 @@ final class SessionManager { SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didPurge, object: nil) } + // MARK: delete user func delete(user: SwiftfinStore.State.User) { guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(From(), [Where("id == %@", user.id)]) else { fatalError("No stored user for state user?")} _delete(user: storedUser, transaction: nil) } + // MARK: delete server func delete(server: SwiftfinStore.State.Server) { guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From(), [Where("id == %@", server.id)]) else { fatalError("No stored server for state server?")} From 9963c9af3bfd47e040d13b44d53c4cf237b62325 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Fri, 31 Dec 2021 00:21:07 -0700 Subject: [PATCH 25/62] some compact overlay polish --- .../VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index b161cad4..9769a734 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -39,7 +39,7 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { // MARK: Top Bar ZStack { - LinearGradient(gradient: Gradient(colors: [.black, .clear]), + LinearGradient(gradient: Gradient(colors: [.black.opacity(0.7), .clear]), startPoint: .top, endPoint: .bottom) .ignoresSafeArea() @@ -252,7 +252,7 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { // MARK: Bottom Bar ZStack { - LinearGradient(gradient: Gradient(colors: [.clear, .black]), + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]), startPoint: .top, endPoint: .bottom) .ignoresSafeArea() @@ -313,7 +313,7 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { .frame(minWidth: 70, maxWidth: 70) } .padding(.horizontal) -// .frame(maxWidth: 800, maxHeight: 50) + .frame(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 800 : nil) } .frame(maxHeight: 50) } From 5eeea800fc05be6b0348302c8ced5ccce6f29f97 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 00:12:03 -0700 Subject: [PATCH 26/62] refine auto play --- JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift | 2 +- .../BaseItemDto+VideoPlayerViewModel.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index b16d908e..35d872f5 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -385,7 +385,7 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { viewModel.playerState = vlcMediaPlayer.state if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoPlayNextItem && viewModel.nextItemVideoPlayerViewModel != nil { + if viewModel.autoPlayNextItem && viewModel.shouldShowAutoPlayNextItem && viewModel.nextItemVideoPlayerViewModel != nil { didSelectNextItem() } else { didSelectClose() diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index ff60582b..959a91bf 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -94,7 +94,7 @@ extension BaseItemDto { // TODO: show adjacent items - let shouldShowAutoPlayNextItem = Defaults[.shouldShowAutoPlayNextItem] + let shouldShowAutoPlayNextItem = Defaults[.shouldShowAutoPlayNextItem] && itemType == .episode let autoPlayNextItem = Defaults[.autoPlayNextItem] let videoPlayerViewModel = VideoPlayerViewModel(item: self, From 4c7490b5fa8e85a237500a44b71e8e3ba5761419 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 00:33:19 -0700 Subject: [PATCH 27/62] initial port of VLC player to tvOS --- .../NativePlayerViewController.swift | 9 +- .../VideoPlayer/VLCPlayerViewController.swift | 612 ++++++++++++++++++ .../Views/VideoPlayer/VideoPlayerView.swift | 16 + JellyfinPlayer.xcodeproj/project.pbxproj | 14 + .../VideoPlayer/VLCPlayerViewController.swift | 2 + .../tvOSVideoPlayerCoordinator.swift | 5 +- 6 files changed, 654 insertions(+), 4 deletions(-) create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift index f5fcbbc3..695f548f 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift @@ -145,15 +145,18 @@ class NativePlayerViewController: AVPlayerViewController { private func play() { player?.play() - viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0) +// viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0) + viewModel.sendPlayReport() } private func sendProgressReport(seconds: Double) { - viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000) +// viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000) + viewModel.sendProgressReport() } private func stop() { self.player?.pause() - viewModel.sendStopReport(ticks: 10_000_000) + viewModel.sendStopReport() +// viewModel.sendStopReport(ticks: 10_000_000) } } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift new file mode 100644 index 00000000..0cbc102a --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -0,0 +1,612 @@ +// +// PlayerViewController.swift +// JellyfinVideoPlayerDev +// +// Created by Ethan Pippin on 11/12/21. +// + +import AVKit +import AVFoundation +import Combine +import Defaults +import JellyfinAPI +import MediaPlayer +import TVVLCKit +import SwiftUI +import UIKit + +// TODO: Make the VLC player layer a view +// This will allow changing media and putting the view somewhere else +// in a compact state, like a small viewer while navigating the app + +// TODO: Look at making overlays handle timer and all gesture events + +class VLCPlayerViewController: UIViewController { + + // MARK: variables + + private var viewModel: VideoPlayerViewModel + private var vlcMediaPlayer = VLCMediaPlayer() + private var lastPlayerTicks: Int64 = 0 + private var lastProgressReportTicks: Int64 = 0 + private var viewModelReactCancellables = Set() + private var overlayDismissTimer: Timer? + + private var currentPlayerTicks: Int64 { + return Int64(vlcMediaPlayer.time.intValue) * 100_000 + } + + private var displayingOverlay: Bool { +// return currentOverlayHostingController?.view.alpha ?? 0 > 0 + return false + } + + private var jumpForwardLength: VideoPlayerJumpLength { + return Defaults[.videoPlayerJumpForward] + } + + private var jumpBackwardLength: VideoPlayerJumpLength { + return Defaults[.videoPlayerJumpBackward] + } + + private lazy var videoContentView = makeVideoContentView() + private lazy var tapGestureView = makeTapGestureView() +// private var currentOverlayHostingController: UIHostingController? + + // MARK: init + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + + viewModel.playerOverlayDelegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupSubviews() { + view.addSubview(videoContentView) + view.addSubview(tapGestureView) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + videoContentView.topAnchor.constraint(equalTo: view.topAnchor), + videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), + videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor) + ]) + NSLayoutConstraint.activate([ + tapGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), + tapGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + tapGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) + ]) + } + + // MARK: viewWillDisappear + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + didSelectClose() + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + setupSubviews() + setupConstraints() + + view.backgroundColor = .black + + // These are kept outside of 'setupMediaPlayer' such that + // they aren't unnecessarily set more than once + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView + vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) + + setupMediaPlayer(newViewModel: viewModel) + + setupRightSwipedGestureRecognizer() + setupLeftSwipedGestureRecognizer() + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil) + defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + @objc private func appWillTerminate() { + viewModel.sendStopReport() + } + + @objc private func appWillResignActive() { + showOverlay() + + stopOverlayDismissTimer() + + vlcMediaPlayer.pause() + + viewModel.sendPauseReport(paused: true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + } + + // MARK: subviews + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + private func makeTapGestureView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + + let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) + rightSwipeGesture.direction = .right + let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) + leftSwipeGesture.direction = .left + + view.addGestureRecognizer(singleTapGesture) + view.addGestureRecognizer(rightSwipeGesture) + view.addGestureRecognizer(leftSwipeGesture) + + return view + } + + @objc private func didTap() { + self.didGenerallyTap() + } + + @objc private func didRightSwipe() { + self.didSelectForward() + } + + @objc private func didLeftSwipe() { + self.didSelectBackward() + } + + // MARK: pressesBegan + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let buttonPress = presses.first?.type else { return } + + switch(buttonPress) { + case .menu: + print("Menu") + case .playPause: + didSelectMain() + print("Play/Pause") + case .select: + print("select") + case .upArrow: + print("Up arrow") + case .downArrow: + print("Down arrow") + case .leftArrow: + print("Left arrow") + case .rightArrow: + print("right arrow") + case .pageUp: + print("page up") + case .pageDown: + print("page down") + @unknown default: () + } + } + + func setupRightSwipedGestureRecognizer() { + let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedRight)) + swipeRecognizer.direction = .right + view.addGestureRecognizer(swipeRecognizer) + } + + @objc func swipedRight() { + didSelectForward() + } + + func setupLeftSwipedGestureRecognizer() { + let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedLeft)) + swipeRecognizer.direction = .left + view.addGestureRecognizer(swipeRecognizer) + } + + @objc func swipedLeft() { + didSelectBackward() + } + + // MARK: setupOverlayHostingController + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + +// // TODO: Look at injecting viewModel into the environment so it updates the current overlay +// if let currentOverlayHostingController = currentOverlayHostingController { +// // UX fade-out +// UIView.animate(withDuration: 0.5) { +// currentOverlayHostingController.view.alpha = 0 +// } completion: { _ in +// currentOverlayHostingController.view.isHidden = true +// +// currentOverlayHostingController.view.removeFromSuperview() +// currentOverlayHostingController.removeFromParent() +//// self.currentOverlayHostingController = nil +// } +// } +// +// let newOverlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) +// let newOverlayHostingController = UIHostingController(rootView: newOverlayView) +// +// newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false +// newOverlayHostingController.view.backgroundColor = UIColor.clear +// +// // UX fade-in +// newOverlayHostingController.view.alpha = 0 +// +// addChild(newOverlayHostingController) +// view.addSubview(newOverlayHostingController.view) +// newOverlayHostingController.didMove(toParent: self) +// +// NSLayoutConstraint.activate([ +// newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), +// newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), +// newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), +// newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) +// ]) +// +// // UX fade-in +// UIView.animate(withDuration: 0.5) { +// newOverlayHostingController.view.alpha = 1 +// } +// +// self.currentOverlayHostingController = newOverlayHostingController +// +// // There is a behavior when setting this that the navigation bar +// // on the current navigation controller pops up, re-hide it +// self.navigationController?.isNavigationBarHidden = true + } +} + +// MARK: setupMediaPlayer +extension VLCPlayerViewController { + + /// Main function that handles setting up the media player with the current VideoPlayerViewModel + /// and also takes the role of setting the 'viewModel' property with the given viewModel + /// + /// Use case for this is setting new media within the same VLCPlayerViewController + func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { + + stopOverlayDismissTimer() + + // Stop current media if there is one + if vlcMediaPlayer.media != nil { + viewModelReactCancellables.forEach({ $0.cancel() }) + + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } + + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + + let media = VLCMedia(url: newViewModel.streamURL) + media.addOption("--prefetch-buffer-size=1048576") + media.addOption("--network-caching=5000") + + vlcMediaPlayer.media = media + + setupOverlayHostingController(viewModel: newViewModel) + setupViewModelListeners(viewModel: newViewModel) + + newViewModel.getAdjacentEpisodes() + newViewModel.playerOverlayDelegate = self + + let startPercentage = viewModel.item.userData?.playedPercentage ?? 0 + + if startPercentage > 0 { + newViewModel.sliderPercentage = startPercentage / 100 + } + + viewModel = newViewModel + } + + // MARK: startPlayback + func startPlayback() { + vlcMediaPlayer.play() + + setMediaPlayerTimeAtCurrentSlider() + + viewModel.sendPlayReport() + + restartOverlayDismissTimer() + } + + // MARK: setupViewModelListeners + + private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &viewModelReactCancellables) + + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { + self.didEndScrubbing() + } + }.store(in: &viewModelReactCancellables) + + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &viewModelReactCancellables) + + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &viewModelReactCancellables) + } + + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000) + let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) + let newPositionOffset = secondsScrubbedTo - videoPosition + + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + } +} + +// MARK: Show/Hide Overlay +extension VLCPlayerViewController { + + private func showOverlay() { +// guard let overlayHostingController = currentOverlayHostingController else { return } +// +// guard overlayHostingController.view.alpha != 1 else { return } +// +// UIView.animate(withDuration: 0.2) { +// overlayHostingController.view.alpha = 1 +// } + } + + private func hideOverlay() { +// guard let overlayHostingController = currentOverlayHostingController else { return } +// +// guard overlayHostingController.view.alpha != 0 else { return } +// +// UIView.animate(withDuration: 0.2) { +// overlayHostingController.view.alpha = 0 +// } + } + + private func toggleOverlay() { +// guard let overlayHostingController = currentOverlayHostingController else { return } +// +// if overlayHostingController.view.alpha < 1 { +// showOverlay() +// } else { +// hideOverlay() +// } + } +} + +// MARK: OverlayTimer +extension VLCPlayerViewController { + + private func restartOverlayDismissTimer(interval: Double = 3) { + self.overlayDismissTimer?.invalidate() + self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false) + } + + @objc private func dismissTimerFired() { + self.hideOverlay() + } + + private func stopOverlayDismissTimer() { + self.overlayDismissTimer?.invalidate() + } +} + +// MARK: VLCMediaPlayerDelegate +extension VLCPlayerViewController: VLCMediaPlayerDelegate { + + + // MARK: mediaPlayerStateChanged + func mediaPlayerStateChanged(_ aNotification: Notification!) { + + viewModel.playerState = vlcMediaPlayer.state + + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + if viewModel.autoPlayNextItem && viewModel.shouldShowAutoPlayNextItem && viewModel.nextItemVideoPlayerViewModel != nil { + didSelectNextItem() + } else { + didSelectClose() + } + } + } + + // MARK: mediaPlayerTimeChanged + func mediaPlayerTimeChanged(_ aNotification: Notification!) { + + guard !viewModel.sliderIsScrubbing else { + lastPlayerTicks = currentPlayerTicks + return + } + + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + + // Have to manually set playing because VLCMediaPlayer doesn't + // properly set it itself + if abs(currentPlayerTicks - lastPlayerTicks) >= 10_000 { + viewModel.playerState = VLCMediaPlayerState.playing + } + + // If needing to fix subtitle streams during playback + if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && viewModel.subtitlesEnabled { + didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) + } + + lastPlayerTicks = currentPlayerTicks + + // Send progress report every 5 seconds + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + } +} + +// MARK: PlayerOverlayDelegate +extension VLCPlayerViewController: PlayerOverlayDelegate { + + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectSubtitleStream(index: Int) { + if viewModel.subtitlesEnabled { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectClose() { + vlcMediaPlayer.stop() + + viewModel.sendStopReport() + + dismiss(animated: true, completion: nil) + } + + func didSelectGoogleCast() { + print("didSelectCast") + } + + func didSelectAirplay() { + print("didSelectAirplay") + } + + func didSelectCaptions() { + + viewModel.subtitlesEnabled = !viewModel.subtitlesEnabled + + if viewModel.subtitlesEnabled { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } + + // TODO: Implement properly in overlays + func didSelectMenu() { + stopOverlayDismissTimer() + } + + // TODO: Implement properly in overlays + func didDeselectMenu() { + restartOverlayDismissTimer() + } + + func didSelectBackward() { + vlcMediaPlayer.jumpBackward(jumpBackwardLength.rawValue) + + restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + self.lastProgressReportTicks = currentPlayerTicks + } + + func didSelectForward() { + vlcMediaPlayer.jumpForward(jumpForwardLength.rawValue) + + restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + self.lastProgressReportTicks = currentPlayerTicks + } + + func didSelectMain() { + + switch viewModel.playerState { + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .playing: + viewModel.sendPauseReport(paused: true) + vlcMediaPlayer.pause() + restartOverlayDismissTimer(interval: 5) + case .paused: + viewModel.sendPauseReport(paused: false) + vlcMediaPlayer.play() + restartOverlayDismissTimer() + default: () + } + } + + func didGenerallyTap() { + toggleOverlay() + + restartOverlayDismissTimer(interval: 5) + } + + func didBeginScrubbing() { + stopOverlayDismissTimer() + } + + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() + + restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + self.lastProgressReportTicks = currentPlayerTicks + } + + func didSelectPreviousItem() { + setupMediaPlayer(newViewModel: viewModel.previousItemVideoPlayerViewModel!) + startPlayback() + } + + func didSelectNextItem() { + setupMediaPlayer(newViewModel: viewModel.nextItemVideoPlayerViewModel!) + startPlayback() + } +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift index 0b2aee90..8f9bf3e9 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift @@ -23,3 +23,19 @@ struct NativePlayerView: UIViewControllerRepresentable { } } + +struct VLCPlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = VLCPlayerViewController + + func makeUIViewController(context: Context) -> VLCPlayerViewController { + + return VLCPlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) { + + } +} diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index ecab7ffb..6b6adc17 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -253,6 +253,8 @@ E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; E131691926C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; + E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1384943278036C70024FB48 /* VLCPlayerViewController.swift */; }; + E13849452780370B0024FB48 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */; }; E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C127164941009D4DAF /* SwiftfinStore.swift */; }; E13DD3C327164941009D4DAF /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C127164941009D4DAF /* SwiftfinStore.swift */; }; @@ -288,6 +290,7 @@ E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; + E178857D278037FD0094FBCF /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E178857C278037FD0094FBCF /* JellyfinAPI */; }; 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 */; }; @@ -570,6 +573,7 @@ E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailCoordinator.swift; sourceTree = ""; }; E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; E131691626C583BC0074BFEE /* LogConstructor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogConstructor.swift; sourceTree = ""; }; + E1384943278036C70024FB48 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = ""; }; E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; E13DD3C127164941009D4DAF /* SwiftfinStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftfinStore.swift; sourceTree = ""; }; E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDeviceExtensions.swift; sourceTree = ""; }; @@ -650,6 +654,7 @@ 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */, E1A9999B271A343C008E78C0 /* SwiftUICollection in Frameworks */, E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */, + E178857D278037FD0094FBCF /* JellyfinAPI in Frameworks */, E12186DE2718F1C50010884C /* Defaults in Frameworks */, 53ABFDED26799D7700886593 /* ActivityIndicator in Frameworks */, 363CADF08820D3B2055CF1D8 /* Pods_JellyfinPlayer_tvOS.framework in Frameworks */, @@ -710,6 +715,7 @@ E1C812C7277AE40900918266 /* NativePlayerViewController.swift */, E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */, E1C812C8277AE40900918266 /* VideoPlayerView.swift */, + E1384943278036C70024FB48 /* VLCPlayerViewController.swift */, ); path = VideoPlayer; sourceTree = ""; @@ -1419,6 +1425,7 @@ E1218C9D271A2CD600EA0737 /* CombineExt */, E1218C9F271A2CF200EA0737 /* Nuke */, E1A9999A271A343C008E78C0 /* SwiftUICollection */, + E178857C278037FD0094FBCF /* JellyfinAPI */, ); productName = "JellyfinPlayer tvOS"; productReference = 535870602669D21600D05A09 /* JellyfinPlayer tvOS.app */; @@ -1826,6 +1833,7 @@ 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */, 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */, + E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */, 091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */, E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, @@ -1883,6 +1891,7 @@ 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */, 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, E19169CF272514760085832A /* HTTPScheme.swift in Sources */, + E13849452780370B0024FB48 /* PlaybackSpeed.swift in Sources */, E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */, C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */, E1D4BF822719D22800A11E64 /* AppAppearance.swift in Sources */, @@ -2810,6 +2819,11 @@ package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; + E178857C278037FD0094FBCF /* JellyfinAPI */ = { + isa = XCSwiftPackageProductDependency; + package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; + productName = JellyfinAPI; + }; E1A99998271A3429008E78C0 /* SwiftUICollection */ = { isa = XCSwiftPackageProductDependency; package = C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 35d872f5..191a836d 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -19,6 +19,8 @@ import UIKit // This will allow changing media and putting the view somewhere else // in a compact state, like a small viewer while navigating the app +// TODO: Look at making overlays handle timer and all gesture events + class VLCPlayerViewController: UIViewController { // MARK: variables diff --git a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift index a8d70173..56c03fae 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift @@ -27,7 +27,10 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { } @ViewBuilder func makeStart() -> some View { - NativePlayerView(viewModel: viewModel) +// NativePlayerView(viewModel: viewModel) +// .navigationBarHidden(true) +// .ignoresSafeArea() + VLCPlayerView(viewModel: viewModel) .navigationBarHidden(true) .ignoresSafeArea() } From 86e41c4f814e14cd47a4d9b2501f568e4c811f41 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 19:14:57 -0700 Subject: [PATCH 28/62] general VLCPlayer implementation --- .../Components/MediaPlayButtonRowView.swift | 3 - .../Components/SFSymbolButton.swift | 56 ++ JellyfinPlayer tvOS/Views/HomeView.swift | 59 +- .../Views/ItemView/EpisodeItemView.swift | 38 +- JellyfinPlayer tvOS/Views/SettingsView.swift | 119 ++-- .../VideoPlayer/PlayerOverlayDelegate.swift | 2 + .../VideoPlayer/VLCPlayerViewController.swift | 272 +++++---- .../tvOSOverlay/tvOSOverlayContent.swift | 89 +++ .../tvOSOverlay/tvOSVLCOverlay.swift | 134 +++++ .../VideoPlayer/tvOSSLider/SliderView.swift | 36 ++ .../VideoPlayer/tvOSSLider/tvOSSlider.swift | 552 ++++++++++++++++++ JellyfinPlayer.xcodeproj/project.pbxproj | 36 ++ .../tvOSMainTabCoordinator.swift | 5 +- Shared/Extensions/ColorExtension.swift | 6 +- 14 files changed, 1168 insertions(+), 239 deletions(-) create mode 100644 JellyfinPlayer tvOS/Components/SFSymbolButton.swift create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift diff --git a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift index 4063152a..395fa7a8 100644 --- a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift +++ b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift @@ -18,9 +18,6 @@ struct MediaPlayButtonRowView: View { var body: some View { HStack { VStack { -// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) { -// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) -// } Button { itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) } label: { diff --git a/JellyfinPlayer tvOS/Components/SFSymbolButton.swift b/JellyfinPlayer tvOS/Components/SFSymbolButton.swift new file mode 100644 index 00000000..e2683b3b --- /dev/null +++ b/JellyfinPlayer tvOS/Components/SFSymbolButton.swift @@ -0,0 +1,56 @@ +// + /* + * 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 SwiftUI +import UIKit + +struct SFSymbolButton: UIViewRepresentable { + + let systemName: String + let action: () -> Void + private let pointSize: CGFloat + + init(systemName: String, pointSize: CGFloat = 24, action: @escaping () -> Void) { + self.systemName = systemName + self.action = action + self.pointSize = pointSize + } + + func makeUIView(context: Context) -> some UIButton { + var configuration = UIButton.Configuration.plain() + configuration.cornerStyle = .capsule + + let buttonAction = UIAction(title: "") { action in + self.action() + } + + let button = UIButton(configuration: configuration, primaryAction: buttonAction) + + let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize) + let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig) + + button.setImage(symbolImage, for: .normal) + + return button + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + + } +} + +extension SFSymbolButton: Hashable { + static func == (lhs: SFSymbolButton, rhs: SFSymbolButton) -> Bool { + return lhs.systemName == rhs.systemName + } + + func hash(into hasher: inout Hasher) { + hasher.combine(systemName) + } +} diff --git a/JellyfinPlayer tvOS/Views/HomeView.swift b/JellyfinPlayer tvOS/Views/HomeView.swift index 46956a2e..a340d57a 100644 --- a/JellyfinPlayer tvOS/Views/HomeView.swift +++ b/JellyfinPlayer tvOS/Views/HomeView.swift @@ -17,38 +17,43 @@ struct HomeView: View { @State var showingSettings = false var body: some View { - ScrollView { - if viewModel.isLoading { - ProgressView() - } else { - LazyVStack(alignment: .leading) { - if !viewModel.resumeItems.isEmpty { - ContinueWatchingView(items: viewModel.resumeItems) - } - if !viewModel.nextUpItems.isEmpty { - NextUpView(items: viewModel.nextUpItems) - } + ZStack { + Color.black + .ignoresSafeArea() + + ScrollView { + if viewModel.isLoading { + ProgressView() + } else { + LazyVStack(alignment: .leading) { + if !viewModel.resumeItems.isEmpty { + ContinueWatchingView(items: viewModel.resumeItems) + } + if !viewModel.nextUpItems.isEmpty { + NextUpView(items: viewModel.nextUpItems) + } - if !viewModel.librariesShowRecentlyAddedIDs.isEmpty { - ForEach(viewModel.librariesShowRecentlyAddedIDs, id: \.self) { libraryID in - VStack(alignment: .leading) { - let library = viewModel.libraries.first(where: { $0.id == libraryID }) + if !viewModel.librariesShowRecentlyAddedIDs.isEmpty { + ForEach(viewModel.librariesShowRecentlyAddedIDs, id: \.self) { libraryID in + VStack(alignment: .leading) { + let library = viewModel.libraries.first(where: { $0.id == libraryID }) - Button { - self.homeRouter.route(to: \.modalLibrary, (.init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? "")) - } label: { - HStack { - Text(L10n.latestWithString(library?.name ?? "")) - .font(.headline) - .fontWeight(.semibold) - Image(systemName: "chevron.forward.circle.fill") - } - }.padding(EdgeInsets(top: 0, leading: 90, bottom: 0, trailing: 0)) - LatestMediaView(usingParentID: libraryID) + Button { + self.homeRouter.route(to: \.modalLibrary, (.init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? "")) + } label: { + HStack { + Text(L10n.latestWithString(library?.name ?? "")) + .font(.headline) + .fontWeight(.semibold) + Image(systemName: "chevron.forward.circle.fill") + } + }.padding(EdgeInsets(top: 0, leading: 90, bottom: 0, trailing: 0)) + LatestMediaView(usingParentID: libraryID) + } } } + Spacer().frame(height: 30) } - Spacer().frame(height: 30) } } } diff --git a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift index 8e3c3d51..7ba8750e 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift @@ -120,42 +120,8 @@ struct EpisodeItemView: View { .font(.body) .fontWeight(.medium) .foregroundColor(.primary) - - HStack { - VStack { - Button { - viewModel.updateFavoriteState() - } label: { - MediaViewActionButton(icon: "heart.fill", iconColor: viewModel.isFavorited ? .red : .white) - } - Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") - .font(.caption) - } - VStack { -// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) { -// MediaViewActionButton(icon: "play.fill") -// } - Button { - itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) - } label: { -// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) - MediaViewActionButton(icon: "play.fill") - } - Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play) - .font(.caption) - } - VStack { - Button { - viewModel.updateWatchState() - } label: { - MediaViewActionButton(icon: "eye.fill", iconColor: viewModel.isWatched ? .red : .white) - } - Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") - .font(.caption) - } - Spacer() - } - .padding(.top, 15) + + MediaPlayButtonRowView(viewModel: viewModel) } }.padding(.top, 50) diff --git a/JellyfinPlayer tvOS/Views/SettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView.swift index dcc93767..f1ed6c9a 100644 --- a/JellyfinPlayer tvOS/Views/SettingsView.swift +++ b/JellyfinPlayer tvOS/Views/SettingsView.swift @@ -21,59 +21,78 @@ struct SettingsView: View { @Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode var body: some View { - 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) + 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) + } } } - - 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) } } - .padding(.leading, 90) - .padding(.trailing, 90) + } + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView(viewModel: SettingsViewModel()) } } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index 2284fbd1..ebd02beb 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -27,4 +27,6 @@ protocol PlayerOverlayDelegate { func didSelectAudioStream(index: Int) func didSelectSubtitleStream(index: Int) + + func didFocusOnButton() } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 0cbc102a..3a3a0a55 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -37,8 +37,11 @@ class VLCPlayerViewController: UIViewController { } private var displayingOverlay: Bool { -// return currentOverlayHostingController?.view.alpha ?? 0 > 0 - return false + return currentOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private var displayingContentOverlay: Bool { + return currentOverlayContentHostingController?.view.alpha ?? 0 > 0 } private var jumpForwardLength: VideoPlayerJumpLength { @@ -50,8 +53,8 @@ class VLCPlayerViewController: UIViewController { } private lazy var videoContentView = makeVideoContentView() - private lazy var tapGestureView = makeTapGestureView() -// private var currentOverlayHostingController: UIHostingController? + private var currentOverlayHostingController: UIHostingController? + private var currentOverlayContentHostingController: UIHostingController? // MARK: init @@ -70,7 +73,6 @@ class VLCPlayerViewController: UIViewController { private func setupSubviews() { view.addSubview(videoContentView) - view.addSubview(tapGestureView) } private func setupConstraints() { @@ -80,12 +82,6 @@ class VLCPlayerViewController: UIViewController { videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor) ]) - NSLayoutConstraint.activate([ - tapGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), - tapGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - tapGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) - ]) } // MARK: viewWillDisappear @@ -115,12 +111,13 @@ class VLCPlayerViewController: UIViewController { // they aren't unnecessarily set more than once vlcMediaPlayer.delegate = self vlcMediaPlayer.drawable = videoContentView - vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) + vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16) setupMediaPlayer(newViewModel: viewModel) setupRightSwipedGestureRecognizer() setupLeftSwipedGestureRecognizer() + setupPanGestureRecognizer() let defaultNotificationCenter = NotificationCenter.default defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil) @@ -158,35 +155,6 @@ class VLCPlayerViewController: UIViewController { return view } - private func makeTapGestureView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - - let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) - rightSwipeGesture.direction = .right - let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) - leftSwipeGesture.direction = .left - - view.addGestureRecognizer(singleTapGesture) - view.addGestureRecognizer(rightSwipeGesture) - view.addGestureRecognizer(leftSwipeGesture) - - return view - } - - @objc private func didTap() { - self.didGenerallyTap() - } - - @objc private func didRightSwipe() { - self.didSelectForward() - } - - @objc private func didLeftSwipe() { - self.didSelectBackward() - } - // MARK: pressesBegan override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { guard let buttonPress = presses.first?.type else { return } @@ -196,17 +164,17 @@ class VLCPlayerViewController: UIViewController { print("Menu") case .playPause: didSelectMain() - print("Play/Pause") case .select: - print("select") + didGenerallyTap() case .upArrow: print("Up arrow") case .downArrow: print("Down arrow") case .leftArrow: + didSelectBackward() print("Left arrow") case .rightArrow: - print("right arrow") + didSelectForward() case .pageUp: print("page up") case .pageDown: @@ -215,73 +183,115 @@ class VLCPlayerViewController: UIViewController { } } - func setupRightSwipedGestureRecognizer() { + private func setupRightSwipedGestureRecognizer() { let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedRight)) swipeRecognizer.direction = .right view.addGestureRecognizer(swipeRecognizer) } - @objc func swipedRight() { + @objc private func swipedRight() { didSelectForward() } - func setupLeftSwipedGestureRecognizer() { + private func setupLeftSwipedGestureRecognizer() { let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedLeft)) swipeRecognizer.direction = .left view.addGestureRecognizer(swipeRecognizer) } - @objc func swipedLeft() { + @objc private func swipedLeft() { didSelectBackward() } + private func setupPanGestureRecognizer() { + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) + view.addGestureRecognizer(panGestureRecognizer) + } + + @objc private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { + if displayingOverlay { + restartOverlayDismissTimer() + } + } + // MARK: setupOverlayHostingController private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { -// // TODO: Look at injecting viewModel into the environment so it updates the current overlay -// if let currentOverlayHostingController = currentOverlayHostingController { -// // UX fade-out -// UIView.animate(withDuration: 0.5) { -// currentOverlayHostingController.view.alpha = 0 -// } completion: { _ in -// currentOverlayHostingController.view.isHidden = true -// -// currentOverlayHostingController.view.removeFromSuperview() -// currentOverlayHostingController.removeFromParent() -//// self.currentOverlayHostingController = nil -// } -// } -// -// let newOverlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) -// let newOverlayHostingController = UIHostingController(rootView: newOverlayView) -// -// newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false -// newOverlayHostingController.view.backgroundColor = UIColor.clear -// -// // UX fade-in -// newOverlayHostingController.view.alpha = 0 -// -// addChild(newOverlayHostingController) -// view.addSubview(newOverlayHostingController.view) -// newOverlayHostingController.didMove(toParent: self) -// -// NSLayoutConstraint.activate([ -// newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), -// newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), -// newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), -// newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) -// ]) -// -// // UX fade-in -// UIView.animate(withDuration: 0.5) { -// newOverlayHostingController.view.alpha = 1 -// } -// -// self.currentOverlayHostingController = newOverlayHostingController -// -// // There is a behavior when setting this that the navigation bar -// // on the current navigation controller pops up, re-hide it -// self.navigationController?.isNavigationBarHidden = true + // TODO: Look at injecting viewModel into the environment so it updates the current overlay + + // Overlay + if let currentOverlayHostingController = currentOverlayHostingController { + // UX fade-out + UIView.animate(withDuration: 0.5) { + currentOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() +// self.currentOverlayHostingController = nil + } + } + + let newOverlayView = tvOSVLCOverlay(viewModel: viewModel) + let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + + newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayHostingController.view.backgroundColor = UIColor.clear + + // UX fade-in + newOverlayHostingController.view.alpha = 0 + + addChild(newOverlayHostingController) + view.addSubview(newOverlayHostingController.view) + newOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) + ]) + + // UX fade-in + UIView.animate(withDuration: 0.5) { + newOverlayHostingController.view.alpha = 1 + } + + self.currentOverlayHostingController = newOverlayHostingController + + // OverlayContent + if let currentOverlayContentHostingController = currentOverlayContentHostingController { + currentOverlayContentHostingController.view.isHidden = true + + currentOverlayContentHostingController.view.removeFromSuperview() + currentOverlayContentHostingController.removeFromParent() + } + + let newOverlayContentView = tvOSOverlayContentView(viewModel: viewModel) + let newOverlayContentHostingController = UIHostingController(rootView: newOverlayContentView) + + newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayContentHostingController.view.backgroundColor = UIColor.clear + + newOverlayContentHostingController.view.alpha = 0 + + addChild(newOverlayContentHostingController) + view.addSubview(newOverlayContentHostingController.view) + newOverlayContentHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) + ]) + + self.currentOverlayContentHostingController = newOverlayContentHostingController + + // There is a behavior when setting this that the navigation bar + // on the current navigation controller pops up, re-hide it + self.navigationController?.isNavigationBarHidden = true } } @@ -337,7 +347,7 @@ extension VLCPlayerViewController { viewModel.sendPlayReport() - restartOverlayDismissTimer() + restartOverlayDismissTimer(interval: 5) } // MARK: setupViewModelListeners @@ -384,40 +394,58 @@ extension VLCPlayerViewController { extension VLCPlayerViewController { private func showOverlay() { -// guard let overlayHostingController = currentOverlayHostingController else { return } -// -// guard overlayHostingController.view.alpha != 1 else { return } -// -// UIView.animate(withDuration: 0.2) { -// overlayHostingController.view.alpha = 1 -// } + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } } private func hideOverlay() { -// guard let overlayHostingController = currentOverlayHostingController else { return } -// -// guard overlayHostingController.view.alpha != 0 else { return } -// -// UIView.animate(withDuration: 0.2) { -// overlayHostingController.view.alpha = 0 -// } + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } } private func toggleOverlay() { -// guard let overlayHostingController = currentOverlayHostingController else { return } -// -// if overlayHostingController.view.alpha < 1 { -// showOverlay() -// } else { -// hideOverlay() -// } + if displayingOverlay { + hideOverlay() + } else { + showOverlay() + } + } + + private func showOverlayContent() { + guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } + + guard currentOverlayContentHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + currentOverlayContentHostingController.view.alpha = 1 + } + } + + private func hideOverlayContent() { + guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } + + guard currentOverlayContentHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + currentOverlayContentHostingController.view.alpha = 0 + } } } // MARK: OverlayTimer extension VLCPlayerViewController { - private func restartOverlayDismissTimer(interval: Double = 3) { + private func restartOverlayDismissTimer(interval: Double = 5) { self.overlayDismissTimer?.invalidate() self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false) } @@ -534,12 +562,15 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { // TODO: Implement properly in overlays func didSelectMenu() { - stopOverlayDismissTimer() +// stopOverlayDismissTimer() +// +// hideOverlay() +// showOverlayContent() } // TODO: Implement properly in overlays func didDeselectMenu() { - restartOverlayDismissTimer() + } func didSelectBackward() { @@ -571,7 +602,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { case .playing: viewModel.sendPauseReport(paused: true) vlcMediaPlayer.pause() - restartOverlayDismissTimer(interval: 5) + showOverlay() + restartOverlayDismissTimer(interval: 10) case .paused: viewModel.sendPauseReport(paused: false) vlcMediaPlayer.play() @@ -609,4 +641,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { setupMediaPlayer(newViewModel: viewModel.nextItemVideoPlayerViewModel!) startPlayback() } + + func didFocusOnButton() { + restartOverlayDismissTimer(interval: 8) + } } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift new file mode 100644 index 00000000..1417b98f --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift @@ -0,0 +1,89 @@ +// + /* + * 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") + } + + Button { + print("here") + } label: { + Text("Chapters") + } + + Button { + print("here") + } label: { + Text("Subtitles") + } + + Button { + print("here") + } label: { + Text("Audio") + } + } + .frame(height: 50) + + Spacer() + } + .padding(.bottom) + + Color.gray + .frame(height: 300) + } + } +} + +struct tvOSOverlayContentView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.red + .ignoresSafeArea() + + tvOSOverlayContentView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 720 * 10_000_000), + 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)], + defaultAudioStreamIndex: -1, + defaultSubtitleStreamIndex: -1, + playerState: .error, + shouldShowGoogleCast: false, + shouldShowAirplay: false, + subtitlesEnabled: true, + sliderPercentage: 0.432, + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1, + showAdjacentItems: true, + shouldShowAutoPlayNextItem: true, + autoPlayNextItem: true)) + } + } +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift new file mode 100644 index 00000000..30b01da9 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift @@ -0,0 +1,134 @@ +// + /* + * 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 tvOSVLCOverlay: View { + + @ObservedObject var viewModel: VideoPlayerViewModel + + @ViewBuilder + private var mainButtonView: some View { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.circle") + case .playing: + Image(systemName: "pause.circle") + default: + ProgressView() + } + } + + var body: some View { + ZStack(alignment: .bottom) { + + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7), .black]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: viewModel.subtitle == nil ? 180 : 210) + + VStack { + + Spacer() + + HStack(alignment: .bottom) { + + VStack(alignment: .leading) { + if let subtitle = viewModel.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.secondarySystemFill) + } + + Text(viewModel.title) + .font(.title3) + .fontWeight(.bold) + } + + Spacer() + + if viewModel.subtitlesEnabled { + SFSymbolButton(systemName: "captions.bubble.fill") { + viewModel.playerOverlayDelegate?.didSelectCaptions() + } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "captions.bubble") { + viewModel.playerOverlayDelegate?.didSelectCaptions() + } + .frame(maxWidth: 30, maxHeight: 30) + } + + SFSymbolButton(systemName: "ellipsis.circle") { + viewModel.playerOverlayDelegate?.didSelectMenu() + } + .frame(maxWidth: 30, maxHeight: 30) + .contextMenu { + SFSymbolButton(systemName: "speedometer") { + print("here") + } + } + } + .offset(x: 0, y: 10) + + SliderView(viewModel: viewModel) + .frame(maxHeight: 40) + + HStack { + + HStack(spacing: 10) { + mainButtonView + .frame(maxWidth: 40, maxHeight: 40) + + Text(viewModel.leftLabelText) + } + + Spacer() + + Text(viewModel.rightLabelText) + } + .offset(x: 0, y: -10) + } + } + .foregroundColor(.white) + } +} + +struct tvOSVLCOverlay_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.red + .ignoresSafeArea() + + tvOSVLCOverlay(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 720 * 10_000_000), + 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)], + defaultAudioStreamIndex: -1, + defaultSubtitleStreamIndex: -1, + playerState: .error, + shouldShowGoogleCast: false, + shouldShowAirplay: false, + subtitlesEnabled: true, + sliderPercentage: 0.432, + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1, + showAdjacentItems: true, + shouldShowAutoPlayNextItem: true, + autoPlayNextItem: true)) + } + .previewInterfaceOrientation(.landscapeLeft) + } +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift new file mode 100644 index 00000000..662a4102 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift @@ -0,0 +1,36 @@ +// + /* + * 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 SwiftUI + +struct SliderView: UIViewRepresentable { + + @ObservedObject var viewModel: VideoPlayerViewModel + + private let maxValue: Double = 1000 + + func updateUIView(_ uiView: TvOSSlider, context: Context) { + uiView.value = Float(maxValue * viewModel.sliderPercentage) + } + + func makeUIView(context: Context) -> TvOSSlider { + let slider = TvOSSlider(viewModel: viewModel) + + slider.minimumValue = 0 + slider.maximumValue = Float(maxValue) + slider.value = Float(maxValue * viewModel.sliderPercentage) + slider.thumbSize = 25 + slider.thumbTintColor = .white + slider.minimumTrackTintColor = .white + slider.focusScaleFactor = 1.4 + slider.panDampingValue = 50 + + return slider + } +} diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift new file mode 100644 index 00000000..f7f69126 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift @@ -0,0 +1,552 @@ +// + /* + * 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 + */ + +// Modification of https://github.com/zattoo/TvOSSlider + +import UIKit +import GameController + +enum DPadState { + case select + case right + case left + case up + case down +} + +private let trackViewHeight: CGFloat = 5 +private let animationDuration: TimeInterval = 0.3 +private let defaultValue: Float = 0 +private let defaultMinimumValue: Float = 0 +private let defaultMaximumValue: Float = 1 +private let defaultIsContinuous: Bool = true +private let defaultThumbTintColor: UIColor = .white +private let defaultTrackColor: UIColor = .gray +private let defaultMininumTrackTintColor: UIColor = .blue +private let defaultFocusScaleFactor: CGFloat = 1.05 +private let defaultStepValue: Float = 0.1 +private let decelerationRate: Float = 0.92 +private let decelerationMaxVelocity: Float = 1000 +private let fineTunningVelocityThreshold: Float = 600 + +/// A control used to select a single value from a continuous range of values. +public final class TvOSSlider: UIControl { + + // MARK: - Public + + /// The slider’s current value. + @IBInspectable + public var value: Float { + get { + return storedValue + } + set { + storedValue = min(maximumValue, newValue) + storedValue = max(minimumValue, storedValue) + + var offset = trackView.bounds.width * CGFloat((storedValue - minimumValue) / (maximumValue - minimumValue)) + offset = min(trackView.bounds.width, offset) + thumbViewCenterXConstraint.constant = offset + } + } + + /// The minimum value of the slider. + @IBInspectable + public var minimumValue: Float = defaultMinimumValue { + didSet { + value = max(value, minimumValue) + } + } + + /// The maximum value of the slider. + @IBInspectable + public var maximumValue: Float = defaultMaximumValue { + didSet { + value = min(value, maximumValue) + } + } + + /// A Boolean value indicating whether changes in the slider’s value generate continuous update events. + @IBInspectable + public var isContinuous: Bool = defaultIsContinuous + + /// The color used to tint the default minimum track images. + @IBInspectable + public var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor { + didSet { + minimumTrackView.backgroundColor = minimumTrackTintColor + } + } + + /// The color used to tint the default maximum track images. + @IBInspectable + public var maximumTrackTintColor: UIColor? { + didSet { + maximumTrackView.backgroundColor = maximumTrackTintColor + } + } + + /// The color used to tint the default thumb images. + @IBInspectable + public var thumbTintColor: UIColor = defaultThumbTintColor { + didSet { + thumbView.backgroundColor = thumbTintColor + } + } + + /// Scale factor applied to the slider when receiving the focus + @IBInspectable + public var focusScaleFactor: CGFloat = defaultFocusScaleFactor { + didSet { + updateStateDependantViews() + } + } + + /// Value added or subtracted from the current value on steps left or right updates + public var stepValue: Float = defaultStepValue + + /// Damping value for panning gestures + public var panDampingValue: Float = 5 + + // Size for thumb view + public var thumbSize: CGFloat = 30 + + /** + Sets the slider’s current value, allowing you to animate the change visually. + + - Parameters: + - value: The new value to assign to the value property + - animated: Specify true to animate the change in value; otherwise, specify false to update the slider’s appearance immediately. Animations are performed asynchronously and do not block the calling thread. + */ + public func setValue(_ value: Float, animated: Bool) { + self.value = value + stopDeceleratingTimer() + + if animated { + UIView.animate(withDuration: animationDuration) { + self.setNeedsLayout() + self.layoutIfNeeded() + } + } + } + + /** + Assigns a minimum track image to the specified control states. + + - Parameters: + - image: The minimum track image to associate with the specified states. + - state: The control state with which to associate the image. + */ + public func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) { + minimumTrackViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /** + Assigns a maximum track image to the specified control states. + + - Parameters: + - image: The maximum track image to associate with the specified states. + - state: The control state with which to associate the image. + */ + public func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) { + maximumTrackViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /** + Assigns a thumb image to the specified control states. + + - Parameters: + - image: The thumb image to associate with the specified states. + - state: The control state with which to associate the image. + */ + public func setThumbImage(_ image: UIImage?, for state: UIControl.State) { + thumbViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /// The minimum track image currently being used to render the slider. + public var currentMinimumTrackImage: UIImage? { + return minimumTrackView.image + } + + /// Contains the maximum track image currently being used to render the slider. + public var currentMaximumTrackImage: UIImage? { + return maximumTrackView.image + } + + /// The thumb image currently being used to render the slider. + public var currentThumbImage: UIImage? { + return thumbView.image + } + + /** + Returns the minimum track image associated with the specified control state. + + - Parameters: + - state: The control state whose minimum track image you want to use. Specify a single control state value for this parameter. + + - Returns: The minimum track image associated with the specified state, or nil if no image has been set. This method might also return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. + */ + public func minimumTrackImage(for state: UIControl.State) -> UIImage? { + return minimumTrackViewImages[state.rawValue] + } + + /** + Returns the maximum track image associated with the specified control state. + + - Parameters: + - state: The control state whose maximum track image you want to use. Specify a single control state value for this parameter. + + - Returns: The maximum track image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. + */ + public func maximumTrackImage(for state: UIControl.State) -> UIImage? { + return maximumTrackViewImages[state.rawValue] + } + + /** + Returns the thumb image associated with the specified control state. + + - Parameters: + - state: The control state whose thumb image you want to use. Specify a single control state value for this parameter. + + - Returns: The thumb image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track and thumb images, see Customizing the Slider’s Appearance. + */ + public func thumbImage(for state: UIControl.State) -> UIImage? { + return thumbViewImages[state.rawValue] + } + + // MARK: - Initializers + + /// :nodoc: +// public override init(frame: CGRect) { +// super.init(frame: frame) +// setUpView() +// } + + /// :nodoc: +// public required init?(coder aDecoder: NSCoder) { +// super.init(coder: aDecoder) +// setUpView() +// } + + // MARK: VideoPlayerVieModel init + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + super.init(frame: .zero) + setUpView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - UIControlStates + + /// :nodoc: + public override var isEnabled: Bool { + didSet { + panGestureRecognizer.isEnabled = isEnabled + updateStateDependantViews() + } + } + + /// :nodoc: + public override var isSelected: Bool { + didSet { + updateStateDependantViews() + } + } + + /// :nodoc: + public override var isHighlighted: Bool { + didSet { + updateStateDependantViews() + } + } + + /// :nodoc: + public override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + coordinator.addCoordinatedAnimations({ + self.updateStateDependantViews() + }, completion: nil) + } + + // MARK: - Private + + private let viewModel: VideoPlayerViewModel! + + private typealias ControlState = UInt + + public var storedValue: Float = defaultValue + + private var thumbViewImages: [ControlState: UIImage] = [:] + private var thumbView: UIImageView! + + private var trackViewImages: [ControlState: UIImage] = [:] + private var trackView: UIImageView! + + private var minimumTrackViewImages: [ControlState: UIImage] = [:] + private var minimumTrackView: UIImageView! + + private var maximumTrackViewImages: [ControlState: UIImage] = [:] + private var maximumTrackView: UIImageView! + + private var panGestureRecognizer: UIPanGestureRecognizer! + private var leftTapGestureRecognizer: UITapGestureRecognizer! + private var rightTapGestureRecognizer: UITapGestureRecognizer! + + private var thumbViewCenterXConstraint: NSLayoutConstraint! + + private var dPadState: DPadState = .select + + private weak var deceleratingTimer: Timer? + private var deceleratingVelocity: Float = 0 + + private var thumbViewCenterXConstraintConstant: Float = 0 + + private func setUpView() { + setUpTrackView() + setUpMinimumTrackView() + setUpMaximumTrackView() + setUpThumbView() + + setUpTrackViewConstraints() + setUpMinimumTrackViewConstraints() + setUpMaximumTrackViewConstraints() + setUpThumbViewConstraints() + + setUpGestures() + + NotificationCenter.default.addObserver(self, selector: #selector(controllerConnected(note:)), name: .GCControllerDidConnect, object: nil) + updateStateDependantViews() + } + + private func setUpThumbView() { + thumbView = UIImageView() + thumbView.layer.cornerRadius = thumbSize / 6 + thumbView.backgroundColor = thumbTintColor + addSubview(thumbView) + } + + private func setUpTrackView() { + trackView = UIImageView() + trackView.layer.cornerRadius = trackViewHeight/2 + trackView.backgroundColor = defaultTrackColor.withAlphaComponent(0.3) + addSubview(trackView) + } + + private func setUpMinimumTrackView() { + minimumTrackView = UIImageView() + minimumTrackView.layer.cornerRadius = trackViewHeight / 2 + minimumTrackView.backgroundColor = minimumTrackTintColor + addSubview(minimumTrackView) + } + + private func setUpMaximumTrackView() { + maximumTrackView = UIImageView() + maximumTrackView.layer.cornerRadius = trackViewHeight / 2 + maximumTrackView.backgroundColor = maximumTrackTintColor + addSubview(maximumTrackView) + } + + private func setUpTrackViewConstraints() { + trackView.translatesAutoresizingMaskIntoConstraints = false + trackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true + trackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + trackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + trackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpMinimumTrackViewConstraints() { + minimumTrackView.translatesAutoresizingMaskIntoConstraints = false + minimumTrackView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor).isActive = true + minimumTrackView.trailingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true + minimumTrackView.centerYAnchor.constraint(equalTo:trackView.centerYAnchor).isActive = true + minimumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpMaximumTrackViewConstraints() { + maximumTrackView.translatesAutoresizingMaskIntoConstraints = false + maximumTrackView.leadingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true + maximumTrackView.trailingAnchor.constraint(equalTo: trackView.trailingAnchor).isActive = true + maximumTrackView.centerYAnchor.constraint(equalTo:trackView.centerYAnchor).isActive = true + maximumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpThumbViewConstraints() { + thumbView.translatesAutoresizingMaskIntoConstraints = false + thumbView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + thumbView.widthAnchor.constraint(equalToConstant: thumbSize / 3).isActive = true + thumbView.heightAnchor.constraint(equalToConstant: thumbSize).isActive = true + thumbViewCenterXConstraint = thumbView.centerXAnchor.constraint(equalTo: trackView.leadingAnchor, constant: CGFloat(value)) + thumbViewCenterXConstraint.isActive = true + } + + private func setUpGestures() { + panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureWasTriggered(panGestureRecognizer:))) + addGestureRecognizer(panGestureRecognizer) + + leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(leftTapWasTriggered)) + leftTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)] + leftTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] + addGestureRecognizer(leftTapGestureRecognizer) + + rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(rightTapWasTriggered)) + rightTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)] + rightTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] + addGestureRecognizer(rightTapGestureRecognizer) + } + + private func updateStateDependantViews() { + thumbView.image = thumbViewImages[state.rawValue] ?? thumbViewImages[UIControl.State.normal.rawValue] + + if isFocused { + thumbView.transform = CGAffineTransform(scaleX: focusScaleFactor, y: focusScaleFactor) + } + else { + thumbView.transform = CGAffineTransform.identity + } + } + + @objc private func controllerConnected(note: NSNotification) { + guard let controller = note.object as? GCController else { return } + guard let micro = controller.microGamepad else { return } + + let threshold: Float = 0.7 + micro.reportsAbsoluteDpadValues = true + micro.dpad.valueChangedHandler = { + [weak self] (pad, x, y) in + + if x < -threshold { + self?.dPadState = .left + } + else if x > threshold { + self?.dPadState = .right + } + else { + self?.dPadState = .select + } + } + } + + @objc + private func handleDeceleratingTimer(timer: Timer) { + let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01 + let percent = centerX / Float(trackView.frame.width) + value = minimumValue + ((maximumValue - minimumValue) * percent) + + if isContinuous { + sendActions(for: .valueChanged) + } + + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + + deceleratingVelocity *= decelerationRate + if !isFocused || abs(deceleratingVelocity) < 1 { + stopDeceleratingTimer() + } + } + + private func stopDeceleratingTimer() { + deceleratingTimer?.invalidate() + deceleratingTimer = nil + deceleratingVelocity = 0 + sendActions(for: .valueChanged) + } + + private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool { + let translation = recognizer.translation(in: self) + if abs(translation.y) > abs(translation.x) { + return true + } + return false + } + + // MARK: - Actions + + @objc + private func panGestureWasTriggered(panGestureRecognizer: UIPanGestureRecognizer) { + + if self.isVerticalGesture(panGestureRecognizer) { + return + } + + let translation = Float(panGestureRecognizer.translation(in: self).x) + let velocity = Float(panGestureRecognizer.velocity(in: self).x) + + switch panGestureRecognizer.state { + case .began: + viewModel.sliderIsScrubbing = true + + stopDeceleratingTimer() + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + case .changed: + viewModel.sliderIsScrubbing = true + + let centerX = thumbViewCenterXConstraintConstant + translation / panDampingValue + let percent = centerX / Float(trackView.frame.width) + value = minimumValue + ((maximumValue - minimumValue) * percent) + if isContinuous { + sendActions(for: .valueChanged) + } + + viewModel.sliderPercentage = Double(percent) + case .ended, .cancelled: + viewModel.sliderIsScrubbing = false + + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + + if abs(velocity) > fineTunningVelocityThreshold { + let direction: Float = velocity > 0 ? 1 : -1 + deceleratingVelocity = abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity + deceleratingTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(handleDeceleratingTimer(timer:)), userInfo: nil, repeats: true) + } + else { + stopDeceleratingTimer() + } + default: + break + } + } + + @objc + private func leftTapWasTriggered() { + setValue(value-stepValue, animated: true) + } + + @objc + private func rightTapWasTriggered() { + setValue(value+stepValue, animated: true) + } + + public override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + for press in presses { + switch press.type { + case .select where dPadState == .left: + panGestureRecognizer.isEnabled = false + leftTapWasTriggered() + case .select where dPadState == .right: + panGestureRecognizer.isEnabled = false + rightTapWasTriggered() + case .select: + panGestureRecognizer.isEnabled = false + default: + break + } + } + panGestureRecognizer.isEnabled = true + super.pressesBegan(presses, with: event) + } +} diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 6b6adc17..008c5a65 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -291,6 +291,11 @@ E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; E178857D278037FD0094FBCF /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E178857C278037FD0094FBCF /* JellyfinAPI */; }; + E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859A2780F1F40094FBCF /* tvOSSlider.swift */; }; + 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 */; }; @@ -593,6 +598,11 @@ E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; + E178859A2780F1F40094FBCF /* tvOSSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSSlider.swift; sourceTree = ""; }; + 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 = ""; }; @@ -714,6 +724,8 @@ children = ( E1C812C7277AE40900918266 /* NativePlayerViewController.swift */, E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */, + E178859C2780F5300094FBCF /* tvOSSLider */, + E17885A7278130690094FBCF /* tvOSOverlay */, E1C812C8277AE40900918266 /* VideoPlayerView.swift */, E1384943278036C70024FB48 /* VLCPlayerViewController.swift */, ); @@ -864,6 +876,7 @@ 53116A18268B947A003024C9 /* PlainLinkButton.swift */, 536D3D80267BDFC60004248C /* PortraitItemElement.swift */, 536D3D87267C17350004248C /* PublicUserButton.swift */, + E17885A3278105170094FBCF /* SFSymbolButton.swift */, ); path = Components; sourceTree = ""; @@ -1287,6 +1300,24 @@ path = ItemView; sourceTree = ""; }; + E178859C2780F5300094FBCF /* tvOSSLider */ = { + isa = PBXGroup; + children = ( + E178859D2780F53B0094FBCF /* SliderView.swift */, + E178859A2780F1F40094FBCF /* tvOSSlider.swift */, + ); + path = tvOSSLider; + sourceTree = ""; + }; + E17885A7278130690094FBCF /* tvOSOverlay */ = { + isa = PBXGroup; + children = ( + E17885A5278130610094FBCF /* tvOSOverlayContent.swift */, + E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */, + ); + path = tvOSOverlay; + sourceTree = ""; + }; E18845FA26DEACBE00B0C5B7 /* Portrait */ = { isa = PBXGroup; children = ( @@ -1832,18 +1863,22 @@ E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */, 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */, + E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */, E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */, 091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */, E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, + E17885A6278130610094FBCF /* tvOSOverlayContent.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, + E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, 62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */, E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */, E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */, + E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */, 531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */, 62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */, 535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */, @@ -1859,6 +1894,7 @@ C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */, E193D53727193F8700900D82 /* LibraryListCoordinator.swift in Sources */, E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */, + E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */, E193D54D2719426600900D82 /* LibraryFilterView.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, E193D4D927193CAC00900D82 /* PortraitImageStackable.swift in Sources */, diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift index ea0a5be7..6b7256d1 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift @@ -75,9 +75,6 @@ final class MainTabCoordinator: TabCoordinatable { } @ViewBuilder func makeSettingsTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "gearshape.fill") - Text("Settings") - } + Image(systemName: "gearshape.fill") } } diff --git a/Shared/Extensions/ColorExtension.swift b/Shared/Extensions/ColorExtension.swift index 1ac6cdcd..304fa1f2 100644 --- a/Shared/Extensions/ColorExtension.swift +++ b/Shared/Extensions/ColorExtension.swift @@ -11,7 +11,7 @@ import SwiftUI extension Color { - static let jellyfinPurple = Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255) + static let jellyfinPurple = Color(uiColor: .jellyfinPurple) #if os(tvOS) // tvOS doesn't have these public static let systemFill = Color(UIColor.white) @@ -23,3 +23,7 @@ extension Color { public static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground) #endif } + +extension UIColor { + static let jellyfinPurple = UIColor(red: 172 / 255, green: 92 / 255, blue: 195 / 255, alpha: 1) +} From 21389249e68682800a58ee0fe113f2353a4f2489 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 19:16:24 -0700 Subject: [PATCH 29/62] sizing adjustment on compact overlay --- .../VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 9769a734..363b764c 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -303,7 +303,7 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { .onLongPressGesture(perform: { print("got it here") }), - thumbSize: CGSize.Circle(radius: viewModel.sliderIsScrubbing ? 25 : 20), + thumbSize: CGSize.Circle(radius: viewModel.sliderIsScrubbing ? 20 : 15), thumbInteractiveSize: CGSize.Circle(radius: 40), options: .defaultOptions) ) From 1d2ed589c134268582579ba37c75c232a856ddd1 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 19:19:51 -0700 Subject: [PATCH 30/62] don't show buffering while paused --- .../Views/VideoPlayer/VLCPlayerViewController.swift | 5 +++++ .../Views/VideoPlayer/VLCPlayerViewController.swift | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 3a3a0a55..d7437182 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -466,6 +466,11 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { // MARK: mediaPlayerStateChanged func mediaPlayerStateChanged(_ aNotification: Notification!) { + // Don't show buffering if paused, usually here while scrubbing + if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { + return + } + viewModel.playerState = vlcMediaPlayer.state if vlcMediaPlayer.state == VLCMediaPlayerState.ended { diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 191a836d..76957778 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -384,6 +384,11 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { // MARK: mediaPlayerStateChanged func mediaPlayerStateChanged(_ aNotification: Notification!) { + // Don't show buffering if paused, usually here while scrubbing + if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { + return + } + viewModel.playerState = vlcMediaPlayer.state if vlcMediaPlayer.state == VLCMediaPlayerState.ended { From 29e824a0357d0fae7420519a0967c1338a27d840 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 21:17:22 -0700 Subject: [PATCH 31/62] add jump forward/backward indicators tvos --- .../VideoPlayer/PlayerOverlayDelegate.swift | 3 + .../VideoPlayer/VLCPlayerViewController.swift | 88 +++++++++++++++++++ .../tvOSOverlay/tvOSVLCOverlay.swift | 36 ++++++-- Shared/Extensions/ColorExtension.swift | 1 + 4 files changed, 119 insertions(+), 9 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index ebd02beb..bd95a0ec 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -28,5 +28,8 @@ protocol PlayerOverlayDelegate { func didSelectAudioStream(index: Int) func didSelectSubtitleStream(index: Int) + func didSelectPreviousItem() + func didSelectNextItem() + func didFocusOnButton() } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index d7437182..79a98a05 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -53,6 +53,8 @@ class VLCPlayerViewController: UIViewController { } private lazy var videoContentView = makeVideoContentView() + private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() + private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() private var currentOverlayHostingController: UIHostingController? private var currentOverlayContentHostingController: UIHostingController? @@ -73,6 +75,11 @@ class VLCPlayerViewController: UIViewController { private func setupSubviews() { view.addSubview(videoContentView) + view.addSubview(jumpForwardOverlayView) + view.addSubview(jumpBackwardOverlayView) + + jumpBackwardOverlayView.alpha = 0 + jumpForwardOverlayView.alpha = 0 } private func setupConstraints() { @@ -82,6 +89,14 @@ class VLCPlayerViewController: UIViewController { videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor) ]) + NSLayoutConstraint.activate([ + jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300), + jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + NSLayoutConstraint.activate([ + jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300), + jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) } // MARK: viewWillDisappear @@ -119,12 +134,27 @@ class VLCPlayerViewController: UIViewController { setupLeftSwipedGestureRecognizer() setupPanGestureRecognizer() + let menuPressRecognizer = UITapGestureRecognizer() + menuPressRecognizer.addTarget(self, action: #selector(menuButtonAction)) + menuPressRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.menu.rawValue)] + view.addGestureRecognizer(menuPressRecognizer) + let defaultNotificationCenter = NotificationCenter.default defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil) defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.didEnterBackgroundNotification, object: nil) } + @objc private func menuButtonAction() { + if displayingOverlay { + hideOverlay() + } else { + vlcMediaPlayer.pause() + + dismiss(animated: true, completion: nil) + } + } + @objc private func appWillTerminate() { viewModel.sendStopReport() } @@ -155,6 +185,24 @@ class VLCPlayerViewController: UIViewController { return view } + private func makeJumpBackwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 56) + let forwardSymbolImage = UIImage(systemName: jumpBackwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + return imageView + } + + private func makeJumpForwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 56) + let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + return imageView + } + // MARK: pressesBegan override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { guard let buttonPress = presses.first?.type else { return } @@ -442,6 +490,42 @@ extension VLCPlayerViewController { } } +// MARK: Show/Hide Jump +extension VLCPlayerViewController { + + private func flashJumpBackwardOverlay() { + jumpBackwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + self.jumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } + + private func hideJumpBackwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpBackwardOverlayView.alpha = 0 + } + } + + private func flashJumpFowardOverlay() { + jumpForwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + self.jumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } + + private func hideJumpForwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpForwardOverlayView.alpha = 0 + } + } +} + // MARK: OverlayTimer extension VLCPlayerViewController { @@ -579,6 +663,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } func didSelectBackward() { + flashJumpBackwardOverlay() + vlcMediaPlayer.jumpBackward(jumpBackwardLength.rawValue) restartOverlayDismissTimer() @@ -589,6 +675,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } func didSelectForward() { + flashJumpFowardOverlay() + vlcMediaPlayer.jumpForward(jumpForwardLength.rawValue) restartOverlayDismissTimer() diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift index 30b01da9..b6de77f9 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift @@ -45,7 +45,7 @@ struct tvOSVLCOverlay: View { if let subtitle = viewModel.subtitle { Text(subtitle) .font(.subheadline) - .foregroundColor(.secondarySystemFill) + .foregroundColor(.lightGray) } Text(viewModel.title) @@ -55,16 +55,34 @@ struct tvOSVLCOverlay: View { Spacer() - if viewModel.subtitlesEnabled { - SFSymbolButton(systemName: "captions.bubble.fill") { - viewModel.playerOverlayDelegate?.didSelectCaptions() - } + if viewModel.showAdjacentItems { + SFSymbolButton(systemName: "chevron.left.circle", action: { + viewModel.playerOverlayDelegate?.didSelectPreviousItem() + }) .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "captions.bubble") { - viewModel.playerOverlayDelegate?.didSelectCaptions() - } + .disabled(viewModel.previousItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + + SFSymbolButton(systemName: "chevron.right.circle", action: { + viewModel.playerOverlayDelegate?.didSelectNextItem() + }) .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } + + if !viewModel.subtitleStreams.isEmpty { + if viewModel.subtitlesEnabled { + SFSymbolButton(systemName: "captions.bubble.fill") { + viewModel.playerOverlayDelegate?.didSelectCaptions() + } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "captions.bubble") { + viewModel.playerOverlayDelegate?.didSelectCaptions() + } + .frame(maxWidth: 30, maxHeight: 30) + } } SFSymbolButton(systemName: "ellipsis.circle") { diff --git a/Shared/Extensions/ColorExtension.swift b/Shared/Extensions/ColorExtension.swift index 304fa1f2..edc56d4c 100644 --- a/Shared/Extensions/ColorExtension.swift +++ b/Shared/Extensions/ColorExtension.swift @@ -17,6 +17,7 @@ extension Color { public static let systemFill = Color(UIColor.white) public static let secondarySystemFill = Color(UIColor.gray) public static let tertiarySystemFill = Color(UIColor.black) + public static let lightGray = Color(UIColor.lightGray) #else public static let systemFill = Color(UIColor.systemFill) public static let secondarySystemFill = Color(UIColor.secondarySystemBackground) From 59adfdc9986b8e1de99951276ffac7a85245c36d Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 21:19:28 -0700 Subject: [PATCH 32/62] remove unnecessary swipe gestures --- .../VideoPlayer/VLCPlayerViewController.swift | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 79a98a05..77e92a4b 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -130,8 +130,6 @@ class VLCPlayerViewController: UIViewController { setupMediaPlayer(newViewModel: viewModel) - setupRightSwipedGestureRecognizer() - setupLeftSwipedGestureRecognizer() setupPanGestureRecognizer() let menuPressRecognizer = UITapGestureRecognizer() @@ -231,26 +229,6 @@ class VLCPlayerViewController: UIViewController { } } - private func setupRightSwipedGestureRecognizer() { - let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedRight)) - swipeRecognizer.direction = .right - view.addGestureRecognizer(swipeRecognizer) - } - - @objc private func swipedRight() { - didSelectForward() - } - - private func setupLeftSwipedGestureRecognizer() { - let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedLeft)) - swipeRecognizer.direction = .left - view.addGestureRecognizer(swipeRecognizer) - } - - @objc private func swipedLeft() { - didSelectBackward() - } - private func setupPanGestureRecognizer() { let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) view.addGestureRecognizer(panGestureRecognizer) From e4a27209c2a3381d6836a55dd5ab9718cd807ae9 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 21:33:49 -0700 Subject: [PATCH 33/62] move button presses to explicit tap gestures --- .../VideoPlayer/VLCPlayerViewController.swift | 87 +++++++++---------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 77e92a4b..cb129424 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -132,10 +132,7 @@ class VLCPlayerViewController: UIViewController { setupPanGestureRecognizer() - let menuPressRecognizer = UITapGestureRecognizer() - menuPressRecognizer.addTarget(self, action: #selector(menuButtonAction)) - menuPressRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.menu.rawValue)] - view.addGestureRecognizer(menuPressRecognizer) + setupButtonPressRecognizers() let defaultNotificationCenter = NotificationCenter.default defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil) @@ -143,16 +140,6 @@ class VLCPlayerViewController: UIViewController { defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.didEnterBackgroundNotification, object: nil) } - @objc private func menuButtonAction() { - if displayingOverlay { - hideOverlay() - } else { - vlcMediaPlayer.pause() - - dismiss(animated: true, completion: nil) - } - } - @objc private func appWillTerminate() { viewModel.sendStopReport() } @@ -184,7 +171,7 @@ class VLCPlayerViewController: UIViewController { } private func makeJumpBackwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 56) + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) let forwardSymbolImage = UIImage(systemName: jumpBackwardLength.forwardImageLabel, withConfiguration: symbolConfig) let imageView = UIImageView(image: forwardSymbolImage) imageView.translatesAutoresizingMaskIntoConstraints = false @@ -193,7 +180,7 @@ class VLCPlayerViewController: UIViewController { } private func makeJumpForwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 56) + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) let imageView = UIImageView(image: forwardSymbolImage) imageView.translatesAutoresizingMaskIntoConstraints = false @@ -201,39 +188,51 @@ class VLCPlayerViewController: UIViewController { return imageView } - // MARK: pressesBegan - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let buttonPress = presses.first?.type else { return } - - switch(buttonPress) { - case .menu: - print("Menu") - case .playPause: - didSelectMain() - case .select: - didGenerallyTap() - case .upArrow: - print("Up arrow") - case .downArrow: - print("Down arrow") - case .leftArrow: - didSelectBackward() - print("Left arrow") - case .rightArrow: - didSelectForward() - case .pageUp: - print("page up") - case .pageDown: - print("page down") - @unknown default: () - } - } - private func setupPanGestureRecognizer() { let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) view.addGestureRecognizer(panGestureRecognizer) } + private func setupButtonPressRecognizers() { + addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) + addButtonPressRecognizer(pressType: .playPause, action: #selector(didPressPlayPause)) + addButtonPressRecognizer(pressType: .leftArrow, action: #selector(didPressLeftArrow)) + addButtonPressRecognizer(pressType: .rightArrow, action: #selector(didPressRightArrow)) + } + + private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { + let pressRecognizer = UITapGestureRecognizer() + pressRecognizer.addTarget(self, action: action) + pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] + view.addGestureRecognizer(pressRecognizer) + } + + @objc private func didPressMenu() { + if displayingOverlay { + hideOverlay() + } else { + vlcMediaPlayer.pause() + + dismiss(animated: true, completion: nil) + } + } + + @objc private func didPressPlayPause() { + didSelectMain() + } + + @objc private func didPressSelect() { + didGenerallyTap() + } + + @objc private func didPressLeftArrow() { + didSelectBackward() + } + + @objc private func didPressRightArrow() { + didSelectForward() + } + @objc private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { if displayingOverlay { restartOverlayDismissTimer() From 8c0d39b94d3c9eb34767c035e890204c9373a920 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 21:35:13 -0700 Subject: [PATCH 34/62] adjust todo comments --- .../Views/VideoPlayer/VLCPlayerViewController.swift | 6 +----- .../Views/VideoPlayer/VLCPlayerViewController.swift | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index cb129424..9c56e0c7 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -15,11 +15,7 @@ import TVVLCKit import SwiftUI import UIKit -// TODO: Make the VLC player layer a view -// This will allow changing media and putting the view somewhere else -// in a compact state, like a small viewer while navigating the app - -// TODO: Look at making overlays handle timer and all gesture events +// TODO: Look at making the VLC player layer a view class VLCPlayerViewController: UIViewController { diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 76957778..5d83e568 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -15,11 +15,7 @@ import MobileVLCKit import SwiftUI import UIKit -// TODO: Make the VLC player layer a view -// This will allow changing media and putting the view somewhere else -// in a compact state, like a small viewer while navigating the app - -// TODO: Look at making overlays handle timer and all gesture events +// TODO: Look at making the VLC player layer a view class VLCPlayerViewController: UIViewController { From 2e5eb48cc651d8663799923c08c15e0b5a61ffe5 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 21:53:14 -0700 Subject: [PATCH 35/62] move back to pressesBegan for button presses and renamings --- .../VideoPlayer/PlayerOverlayDelegate.swift | 2 +- .../VideoPlayer/VLCPlayerViewController.swift | 51 ++++++++++--------- .../tvOSOverlay/tvOSVLCOverlay.swift | 4 +- .../VLCPlayerCompactOverlayView.swift | 2 +- .../Overlays/VLCPlayerOverlayView.swift | 2 +- .../VideoPlayer/PlayerOverlayDelegate.swift | 2 +- .../VideoPlayer/VLCPlayerViewController.swift | 2 +- 7 files changed, 35 insertions(+), 30 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index bd95a0ec..456b8823 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -12,7 +12,7 @@ protocol PlayerOverlayDelegate { func didSelectClose() func didSelectGoogleCast() func didSelectAirplay() - func didSelectCaptions() + func didSelectSubtitles() func didSelectMenu() func didDeselectMenu() diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 9c56e0c7..3cdbb48c 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -128,7 +128,7 @@ class VLCPlayerViewController: UIViewController { setupPanGestureRecognizer() - setupButtonPressRecognizers() + addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) let defaultNotificationCenter = NotificationCenter.default defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil) @@ -189,11 +189,32 @@ class VLCPlayerViewController: UIViewController { view.addGestureRecognizer(panGestureRecognizer) } - private func setupButtonPressRecognizers() { - addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) - addButtonPressRecognizer(pressType: .playPause, action: #selector(didPressPlayPause)) - addButtonPressRecognizer(pressType: .leftArrow, action: #selector(didPressLeftArrow)) - addButtonPressRecognizer(pressType: .rightArrow, action: #selector(didPressRightArrow)) + // MARK: pressesBegan + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let buttonPress = presses.first?.type else { return } + + switch(buttonPress) { + case .menu: + print("Menu") + case .playPause: + didSelectMain() + case .select: + didGenerallyTap() + case .upArrow: + print("Up arrow") + case .downArrow: + print("Down arrow") + case .leftArrow: + didSelectBackward() + print("Left arrow") + case .rightArrow: + didSelectForward() + case .pageUp: + print("page up") + case .pageDown: + print("page down") + @unknown default: () + } } private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { @@ -213,22 +234,6 @@ class VLCPlayerViewController: UIViewController { } } - @objc private func didPressPlayPause() { - didSelectMain() - } - - @objc private func didPressSelect() { - didGenerallyTap() - } - - @objc private func didPressLeftArrow() { - didSelectBackward() - } - - @objc private func didPressRightArrow() { - didSelectForward() - } - @objc private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { if displayingOverlay { restartOverlayDismissTimer() @@ -611,7 +616,7 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { print("didSelectAirplay") } - func didSelectCaptions() { + func didSelectSubtitles() { viewModel.subtitlesEnabled = !viewModel.subtitlesEnabled diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift index b6de77f9..ab3b8b76 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift @@ -74,12 +74,12 @@ struct tvOSVLCOverlay: View { if !viewModel.subtitleStreams.isEmpty { if viewModel.subtitlesEnabled { SFSymbolButton(systemName: "captions.bubble.fill") { - viewModel.playerOverlayDelegate?.didSelectCaptions() + viewModel.playerOverlayDelegate?.didSelectSubtitles() } .frame(maxWidth: 30, maxHeight: 30) } else { SFSymbolButton(systemName: "captions.bubble") { - viewModel.playerOverlayDelegate?.didSelectCaptions() + viewModel.playerOverlayDelegate?.didSelectSubtitles() } .frame(maxWidth: 30, maxHeight: 30) } diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift index 363b764c..9a9ceda0 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift @@ -117,7 +117,7 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { if !viewModel.subtitleStreams.isEmpty { Button { - viewModel.playerOverlayDelegate?.didSelectCaptions() + viewModel.playerOverlayDelegate?.didSelectSubtitles() } label: { if viewModel.subtitlesEnabled { Image(systemName: "captions.bubble.fill") diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index 00971f26..a57f3bf6 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -76,7 +76,7 @@ struct VLCPlayerOverlayView: View { } Button { - viewModel.playerOverlayDelegate?.didSelectCaptions() + viewModel.playerOverlayDelegate?.didSelectSubtitles() } label: { if viewModel.subtitlesEnabled { Image(systemName: "captions.bubble.fill") diff --git a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift index e30045ab..f5affe9d 100644 --- a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -12,7 +12,7 @@ protocol PlayerOverlayDelegate { func didSelectClose() func didSelectGoogleCast() func didSelectAirplay() - func didSelectCaptions() + func didSelectSubtitles() func didSelectMenu() func didDeselectMenu() diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 5d83e568..1a9359f8 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -468,7 +468,7 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { print("didSelectAirplay") } - func didSelectCaptions() { + func didSelectSubtitles() { viewModel.subtitlesEnabled = !viewModel.subtitlesEnabled From 1ae886606bda6974d326ac7b562ef7b687358b1e Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 21:59:44 -0700 Subject: [PATCH 36/62] initial black background and comment changes --- .../Views/VideoPlayer/VLCPlayerViewController.swift | 4 ++-- .../MainCoordinator/tvOSMainCoordinator.swift | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 3cdbb48c..dff0b5ef 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -118,8 +118,8 @@ class VLCPlayerViewController: UIViewController { view.backgroundColor = .black - // These are kept outside of 'setupMediaPlayer' such that - // they aren't unnecessarily set more than once + // Outside of 'setupMediaPlayer' such that they + // aren't unnecessarily set more than once vlcMediaPlayer.delegate = self vlcMediaPlayer.drawable = videoContentView vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16) diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift index 122a870f..24eeb27f 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift @@ -17,6 +17,14 @@ final class MainCoordinator: NavigationCoordinatable { @Root var mainTab = makeMainTab @Root var serverList = makeServerList + + @ViewBuilder + func customize(_ view: AnyView) -> some View { + view.background { + Color.black + .ignoresSafeArea() + } + } init() { if SessionManager.main.currentLogin != nil { From 39d9158aebb6be8caf54e7639ce0953ffd66d4f7 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 1 Jan 2022 22:17:32 -0700 Subject: [PATCH 37/62] implement jump/backward flashing on iOS --- .../VideoPlayer/VLCPlayerViewController.swift | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 1a9359f8..30f15d63 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -45,6 +45,8 @@ class VLCPlayerViewController: UIViewController { } private lazy var videoContentView = makeVideoContentView() + private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() + private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() private lazy var tapGestureView = makeTapGestureView() private var currentOverlayHostingController: UIHostingController? @@ -65,7 +67,12 @@ class VLCPlayerViewController: UIViewController { private func setupSubviews() { view.addSubview(videoContentView) + view.addSubview(jumpForwardOverlayView) + view.addSubview(jumpBackwardOverlayView) view.addSubview(tapGestureView) + + jumpBackwardOverlayView.alpha = 0 + jumpForwardOverlayView.alpha = 0 } private func setupConstraints() { @@ -75,6 +82,14 @@ class VLCPlayerViewController: UIViewController { videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor) ]) + NSLayoutConstraint.activate([ + jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), + jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + NSLayoutConstraint.activate([ + jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), + jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) NSLayoutConstraint.activate([ tapGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), tapGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), @@ -177,6 +192,26 @@ class VLCPlayerViewController: UIViewController { self.didSelectBackward() } + private func makeJumpBackwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let forwardSymbolImage = UIImage(systemName: jumpBackwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .white + + return imageView + } + + private func makeJumpForwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .white + + return imageView + } + // MARK: setupOverlayHostingController private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { @@ -356,6 +391,42 @@ extension VLCPlayerViewController { } } +// MARK: Show/Hide Jump +extension VLCPlayerViewController { + + private func flashJumpBackwardOverlay() { + jumpBackwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + self.jumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } + + private func hideJumpBackwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpBackwardOverlayView.alpha = 0 + } + } + + private func flashJumpFowardOverlay() { + jumpForwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + self.jumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } + + private func hideJumpForwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpForwardOverlayView.alpha = 0 + } + } +} + // MARK: OverlayTimer extension VLCPlayerViewController { @@ -490,6 +561,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } func didSelectBackward() { + flashJumpBackwardOverlay() + vlcMediaPlayer.jumpBackward(jumpBackwardLength.rawValue) restartOverlayDismissTimer() @@ -500,6 +573,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } func didSelectForward() { + flashJumpFowardOverlay() + vlcMediaPlayer.jumpForward(jumpForwardLength.rawValue) restartOverlayDismissTimer() From cd3a244f1717c242521027f0133c1adaf83c4214 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sun, 2 Jan 2022 00:04:45 -0700 Subject: [PATCH 38/62] fix backward label image --- .../Views/VideoPlayer/VLCPlayerViewController.swift | 2 +- JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index dff0b5ef..ce6e2a29 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -168,7 +168,7 @@ class VLCPlayerViewController: UIViewController { private func makeJumpBackwardOverlayView() -> UIImageView { let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: jumpBackwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let forwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) let imageView = UIImageView(image: forwardSymbolImage) imageView.translatesAutoresizingMaskIntoConstraints = false diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 30f15d63..9b38fa2f 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -194,7 +194,7 @@ class VLCPlayerViewController: UIViewController { private func makeJumpBackwardOverlayView() -> UIImageView { let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let forwardSymbolImage = UIImage(systemName: jumpBackwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let forwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) let imageView = UIImageView(image: forwardSymbolImage) imageView.translatesAutoresizingMaskIntoConstraints = false imageView.tintColor = .white From 5b451ceaaa710ce12edd5c75bee9947ac5e9262e Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sun, 2 Jan 2022 21:20:20 -0700 Subject: [PATCH 39/62] begin final work --- .../Views/ItemView/EpisodeItemView.swift | 1 + .../Views/ItemView/SeasonItemView.swift | 11 +- .../Views/ItemView/SeriesItemView.swift | 12 +- .../NativePlayerViewController.swift | 12 +- .../VideoPlayer/PlayerOverlayDelegate.swift | 12 +- .../VideoPlayer/VLCPlayerViewController.swift | 38 ++- .../Views/VideoPlayer/VideoPlayerView.swift | 12 +- .../tvOSOverlay/SmallMenuOverlay.swift | 62 +++++ JellyfinPlayer.xcodeproj/project.pbxproj | 70 ++--- .../Components/PrimaryButtonView.swift | 41 +++ JellyfinPlayer/Views/HomeView.swift | 27 +- JellyfinPlayer/Views/ServerListView.swift | 16 +- JellyfinPlayer/Views/SettingsView.swift | 54 ++-- .../NativePlayerViewController.swift | 112 -------- .../Overlays/VLCPlayerOverlayView.swift | 251 ----------------- .../Overlays/VideoPlayerOverlay.swift | 26 -- .../Views/VideoPlayer/PlaybackSpeed.swift | 12 +- .../VideoPlayer/PlayerOverlayDelegate.swift | 19 +- ...yView.swift => VLCPlayerOverlayView.swift} | 118 ++++---- .../Views/VideoPlayer/VLCPlayerView.swift | 27 ++ .../VideoPlayer/VLCPlayerViewController.swift | 253 ++++++++++-------- .../Views/VideoPlayer/VideoPlayerView.swift | 41 --- .../iOSVideoPlayerCoordinator.swift | 28 +- .../BaseItemDto+VideoPlayerViewModel.swift | 30 +-- Shared/Objects/OverlaySliderColor.swift | 25 ++ Shared/Objects/OverlayType.swift | 17 ++ Shared/Singleton/SessionManager.swift | 14 +- .../SwiftfinStore/SwiftfinStoreDefaults.swift | 51 ++-- Shared/ViewModels/HomeViewModel.swift | 3 +- Shared/ViewModels/SettingsViewModel.swift | 11 +- Shared/ViewModels/VideoPlayerViewModel.swift | 123 ++++----- Shared/ViewModels/ViewModel.swift | 2 +- 32 files changed, 675 insertions(+), 856 deletions(-) create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift create mode 100644 JellyfinPlayer/Components/PrimaryButtonView.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift rename JellyfinPlayer/Views/VideoPlayer/{Overlays/VLCPlayerCompactOverlayView.swift => VLCPlayerOverlayView.swift} (78%) create mode 100644 JellyfinPlayer/Views/VideoPlayer/VLCPlayerView.swift delete mode 100644 JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift create mode 100644 Shared/Objects/OverlaySliderColor.swift create mode 100644 Shared/Objects/OverlayType.swift diff --git a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift index 7ba8750e..b1caaee7 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift @@ -122,6 +122,7 @@ struct EpisodeItemView: View { .foregroundColor(.primary) MediaPlayButtonRowView(viewModel: viewModel) + .environmentObject(itemRouter) } }.padding(.top, 50) diff --git a/JellyfinPlayer tvOS/Views/ItemView/SeasonItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/SeasonItemView.swift index 475102fd..85631bd7 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/SeasonItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/SeasonItemView.swift @@ -11,6 +11,8 @@ import SwiftUI import JellyfinAPI struct SeasonItemView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: SeasonItemViewModel @State var wrappedScrollView: UIScrollView? @@ -101,10 +103,15 @@ struct SeasonItemView: View { ScrollView(.horizontal) { LazyHStack { Spacer().frame(width: 45) + ForEach(viewModel.episodes, id: \.id) { episode in - NavigationLink(destination: ItemView(item: episode)) { + + Button { + itemRouter.route(to: \.item, episode) + } label: { LandscapeItemElement(item: episode, inSeasonView: true) - }.buttonStyle(PlainNavigationLinkButtonStyle()) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) } Spacer().frame(width: 45) } diff --git a/JellyfinPlayer tvOS/Views/ItemView/SeriesItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/SeriesItemView.swift index 6a3a2ee3..21cb1d1b 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/SeriesItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/SeriesItemView.swift @@ -11,6 +11,8 @@ import SwiftUI import JellyfinAPI struct SeriesItemView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: SeriesItemViewModel @State var actors: [BaseItemPerson] = [] @@ -141,10 +143,16 @@ struct SeriesItemView: View { ScrollView(.horizontal) { LazyHStack { Spacer().frame(width: 45) + + + ForEach(viewModel.seasons, id: \.id) { season in - NavigationLink(destination: ItemView(item: season)) { + Button { + itemRouter.route(to: \.item, season) + } label: { PortraitItemElement(item: season) - }.buttonStyle(PlainNavigationLinkButtonStyle()) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) } Spacer().frame(width: 45) } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift index 695f548f..8c28964c 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/NativePlayerViewController.swift @@ -1,9 +1,11 @@ // -// NativePlayerViewController.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 11/20/21. -// + /* + * 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 AVKit import Combine diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index 456b8823..0a0f8cae 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -1,9 +1,11 @@ // -// PlayerOverlayDelegate.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 12/27/21. -// + /* + * 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 Foundation diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index ce6e2a29..fc46bdd0 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -1,9 +1,11 @@ // -// PlayerViewController.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 11/12/21. -// + /* + * 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 AVKit import AVFoundation @@ -203,7 +205,10 @@ class VLCPlayerViewController: UIViewController { case .upArrow: print("Up arrow") case .downArrow: - print("Down arrow") + stopOverlayDismissTimer() + + hideOverlay() + showOverlayContent() case .leftArrow: didSelectBackward() print("Left arrow") @@ -227,6 +232,8 @@ class VLCPlayerViewController: UIViewController { @objc private func didPressMenu() { if displayingOverlay { hideOverlay() + } else if displayingContentOverlay { + hideOverlayContent() } else { vlcMediaPlayer.pause() @@ -294,6 +301,11 @@ class VLCPlayerViewController: UIViewController { currentOverlayContentHostingController.removeFromParent() } +// let newSmallMenuOverlayView = SmallMediaStreamSelectionView(items: viewModel.subtitleStreams, +// selectedItem: viewModel.subtitleStreams.first(where: { $0.index == viewModel.selectedSubtitleStreamIndex })) { selectedMediaStream in +// self.didSelectSubtitleStream(index: selectedMediaStream.index ?? -1) +// } +// let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) let newOverlayContentView = tvOSOverlayContentView(viewModel: viewModel) let newOverlayContentHostingController = UIHostingController(rootView: newOverlayContentView) @@ -452,6 +464,10 @@ extension VLCPlayerViewController { guard currentOverlayContentHostingController.view.alpha != 1 else { return } + currentOverlayContentHostingController.view.setNeedsFocusUpdate() + currentOverlayContentHostingController.setNeedsFocusUpdate() + setNeedsFocusUpdate() + UIView.animate(withDuration: 0.2) { currentOverlayContentHostingController.view.alpha = 1 } @@ -462,6 +478,8 @@ extension VLCPlayerViewController { guard currentOverlayContentHostingController.view.alpha != 0 else { return } + setNeedsFocusUpdate() + UIView.animate(withDuration: 0.2) { currentOverlayContentHostingController.view.alpha = 0 } @@ -629,10 +647,10 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { // TODO: Implement properly in overlays func didSelectMenu() { -// stopOverlayDismissTimer() -// -// hideOverlay() -// showOverlayContent() + stopOverlayDismissTimer() + + hideOverlay() + showOverlayContent() } // TODO: Implement properly in overlays diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift index 8f9bf3e9..c1eb827e 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VideoPlayerView.swift @@ -1,9 +1,11 @@ // -// VideoPlayerView.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 11/12/21. -// + /* + * 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 UIKit import SwiftUI diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift new file mode 100644 index 00000000..c7f6d8e8 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift @@ -0,0 +1,62 @@ +// + /* + * 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 SmallMediaStreamSelectionView: View { + + @State var selectedItem: MediaStream? + private var items: [MediaStream] + private var selectedAction: (MediaStream) -> Void + + init(items: [MediaStream], selectedItem: MediaStream? = nil, selectedAction: @escaping (MediaStream) -> Void) { + self.items = items + self.selectedItem = selectedItem + self.selectedAction = selectedAction + } + + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 150) + + VStack { + + Spacer() + + HStack { + Text("Subtitles") + .font(.title3) + Spacer() + } + + ScrollView(.horizontal) { + HStack { + ForEach(items, id: \.self) { item in + Button { +// self.selectedItem = item + } label: { + if item == selectedItem { + Label(item.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(item.displayTitle ?? "No Title") + } + } + } + } + } + .frame(maxHeight: 100) + } + } + } +} diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 008c5a65..522bee82 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -232,7 +232,6 @@ E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */; }; E10EAA45277BB646000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA44277BB646000269ED /* JellyfinAPI */; }; E10EAA47277BB670000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA46277BB670000269ED /* JellyfinAPI */; }; - E10EAA4A277BB6F5000269ED /* VideoPlayerOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA49277BB6F5000269ED /* VideoPlayerOverlay.swift */; }; E10EAA4D277BB716000269ED /* Sliders in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA4C277BB716000269ED /* Sliders */; }; E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; @@ -329,6 +328,12 @@ E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D552271943D500900D82 /* tvOSMainTabCoordinator.swift */; }; E1A99999271A3429008E78C0 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = E1A99998271A3429008E78C0 /* SwiftUICollection */; }; E1A9999B271A343C008E78C0 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = E1A9999A271A343C008E78C0 /* SwiftUICollection */; }; + E1AA331D2782541500F6439C /* PrimaryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331C2782541500F6439C /* PrimaryButtonView.swift */; }; + E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; + E1AA33202782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; + E1AA33222782648000F6439C /* OverlaySliderColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA33212782648000F6439C /* OverlaySliderColor.swift */; }; + E1AA33232782648000F6439C /* OverlaySliderColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA33212782648000F6439C /* OverlaySliderColor.swift */; }; + E1AA332427829B5200F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104926D94822003E4A08 /* DetailItem.swift */; }; E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104926D94822003E4A08 /* DetailItem.swift */; }; E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; @@ -344,10 +349,8 @@ E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */; }; E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */; }; - E1C812BF277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */; }; - E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */; }; - E1C812C1277A8E5D00918266 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */; }; - E1C812C3277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */; }; + E1C812C0277A8E5D00918266 /* VLCPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */; }; + E1C812C3277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */; }; E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; }; E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */; }; E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C7277AE40900918266 /* NativePlayerViewController.swift */; }; @@ -373,6 +376,7 @@ E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E48CC8271E6D410021A2F9 /* RefreshHelper.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 */; }; E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; @@ -571,7 +575,6 @@ C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; - E10EAA49277BB6F5000269ED /* VideoPlayerOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerOverlay.swift; sourceTree = ""; }; E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = ""; }; E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; @@ -617,6 +620,9 @@ E193D54C2719426600900D82 /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = ""; }; E193D54F2719430400900D82 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E193D552271943D500900D82 /* tvOSMainTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSMainTabCoordinator.swift; sourceTree = ""; }; + E1AA331C2782541500F6439C /* PrimaryButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButtonView.swift; sourceTree = ""; }; + E1AA331E2782639D00F6439C /* OverlayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayType.swift; sourceTree = ""; }; + E1AA33212782648000F6439C /* OverlaySliderColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySliderColor.swift; sourceTree = ""; }; E1AD104926D94822003E4A08 /* DetailItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItem.swift; sourceTree = ""; }; E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = ""; }; E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHStackView.swift; sourceTree = ""; }; @@ -626,10 +632,8 @@ E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = ""; }; E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = ""; }; E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = ""; }; - E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerOverlayView.swift; sourceTree = ""; }; - E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; - E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; - E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerCompactOverlayView.swift; sourceTree = ""; }; + E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerView.swift; sourceTree = ""; }; + E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerOverlayView.swift; sourceTree = ""; }; E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsExtensions.swift; sourceTree = ""; }; E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = ""; }; E1C812C7277AE40900918266 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; @@ -645,6 +649,7 @@ E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshHelper.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 = ""; }; E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; FDEDADB92FA8523BC8432E45 /* Pods-WidgetExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WidgetExtension.release.xcconfig"; path = "Target Support Files/Pods-WidgetExtension/Pods-WidgetExtension.release.xcconfig"; sourceTree = ""; }; @@ -858,6 +863,8 @@ 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */, E19169CD272514760085832A /* HTTPScheme.swift */, + E1AA33212782648000F6439C /* OverlaySliderColor.swift */, + E1AA331E2782639D00F6439C /* OverlayType.swift */, E193D4DA27193CCA00900D82 /* PillStackable.swift */, E193D4D727193CAC00900D82 /* PortraitImageStackable.swift */, E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */, @@ -1081,11 +1088,12 @@ 53F866422687A45400DCD1D7 /* Components */ = { isa = PBXGroup; children = ( + E188460326DEF04800B0C5B7 /* EpisodeCardVStackView.swift */, E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */, E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */, - 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */, - E188460326DEF04800B0C5B7 /* EpisodeCardVStackView.swift */, C4BE076D2720FEA8003F4AD1 /* PortraitItemElement.swift */, + 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */, + E1AA331C2782541500F6439C /* PrimaryButtonView.swift */, ); path = Components; sourceTree = ""; @@ -1201,16 +1209,6 @@ path = Pods; sourceTree = ""; }; - E10EAA48277BB6D7000269ED /* Overlays */ = { - isa = PBXGroup; - children = ( - E10EAA49277BB6F5000269ED /* VideoPlayerOverlay.swift */, - E1C812BB277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift */, - E1C812B7277A8E5D00918266 /* VLCPlayerOverlayView.swift */, - ); - path = Overlays; - sourceTree = ""; - }; E12186DF2718F2030010884C /* App */ = { isa = PBXGroup; children = ( @@ -1312,6 +1310,7 @@ E17885A7278130690094FBCF /* tvOSOverlay */ = { isa = PBXGroup; children = ( + E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */, E17885A5278130610094FBCF /* tvOSOverlayContent.swift */, E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */, ); @@ -1350,11 +1349,10 @@ E193D5452719418B00900D82 /* VideoPlayer */ = { isa = PBXGroup; children = ( - E1C812B9277A8E5D00918266 /* NativePlayerViewController.swift */, - E10EAA48277BB6D7000269ED /* Overlays */, E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, - E1C812B8277A8E5D00918266 /* VideoPlayerView.swift */, + E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */, + E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */, E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */, ); path = VideoPlayer; @@ -1911,6 +1909,7 @@ E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, 62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, 536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */, + E1AA33232782648000F6439C /* OverlaySliderColor.swift in Sources */, 62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */, 5398514726B64E4100101B49 /* SearchBarView.swift in Sources */, E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, @@ -1935,6 +1934,7 @@ 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */, 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */, E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */, + E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */, E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */, E13DD3C927164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, 535870A62669D8AE00D05A09 /* LazyView.swift in Sources */, @@ -1942,6 +1942,7 @@ 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, 5321753E2671DE9C005491E6 /* Typings.swift in Sources */, E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */, + E1AA33202782639D00F6439C /* OverlayType.swift in Sources */, E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, 6264E88D273850380081A12A /* Strings.swift in Sources */, @@ -1992,7 +1993,7 @@ E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */, - E1C812C0277A8E5D00918266 /* VideoPlayerView.swift in Sources */, + E1C812C0277A8E5D00918266 /* VLCPlayerView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, C40CD925271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, @@ -2027,16 +2028,14 @@ 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, C4BE0763271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */, 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, - E1C812BF277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */, E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */, - E1C812C3277A8E5D00918266 /* VLCPlayerCompactOverlayView.swift in Sources */, + E1C812C3277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */, E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */, 091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */, 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */, - E1C812C1277A8E5D00918266 /* NativePlayerViewController.swift in Sources */, E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, @@ -2045,6 +2044,7 @@ 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */, + E1AA33222782648000F6439C /* OverlaySliderColor.swift in Sources */, E1D4BF842719D25A00A11E64 /* TrackLanguage.swift in Sources */, E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */, E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, @@ -2055,6 +2055,7 @@ 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */, + E1AA331D2782541500F6439C /* PrimaryButtonView.swift in Sources */, 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, @@ -2063,6 +2064,7 @@ E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */, 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, E193D4DB27193CCA00900D82 /* PillStackable.swift in Sources */, + E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, E193D4D827193CAC00900D82 /* PortraitImageStackable.swift in Sources */, 624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */, E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */, @@ -2101,7 +2103,6 @@ E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, - E10EAA4A277BB6F5000269ED /* VideoPlayerOverlay.swift in Sources */, 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */, 625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */, ); @@ -2135,6 +2136,7 @@ 62E1DCC5273CE19800C9AE76 /* URLExtensions.swift in Sources */, 62EC353226766849000E9F2D /* SessionManager.swift in Sources */, 536D3D79267BD5D00004248C /* ViewModel.swift in Sources */, + E1AA332427829B5200F6439C /* OverlayType.swift in Sources */, E1D4BF8C2719F39F00A11E64 /* AppAppearance.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2291,7 +2293,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer tvOS/Preview Content\""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "JellyfinPlayer tvOS/Info.plist"; @@ -2300,7 +2302,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -2321,7 +2323,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer tvOS/Preview Content\""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "JellyfinPlayer tvOS/Info.plist"; @@ -2330,7 +2332,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/JellyfinPlayer/Components/PrimaryButtonView.swift b/JellyfinPlayer/Components/PrimaryButtonView.swift new file mode 100644 index 00000000..7f33ff9a --- /dev/null +++ b/JellyfinPlayer/Components/PrimaryButtonView.swift @@ -0,0 +1,41 @@ +// + /* + * 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 SwiftUI + +struct PrimaryButtonView: View { + + private let title: String + private let action: () -> Void + + init(title: String, _ action: @escaping () -> Void) { + self.title = title + self.action = action + } + + var body: some View { + Button { + action() + } label: { + ZStack { + Rectangle() + .foregroundColor(Color(UIColor.systemPurple)) + .frame(maxWidth: 400, maxHeight: 50) + .frame(height: 50) + .cornerRadius(10) + .padding(.horizontal, 30) + .padding([.top, .bottom], 20) + + Text(title) + .foregroundColor(Color.white) + .bold() + } + } + } +} diff --git a/JellyfinPlayer/Views/HomeView.swift b/JellyfinPlayer/Views/HomeView.swift index 26132fb9..892d0ab7 100644 --- a/JellyfinPlayer/Views/HomeView.swift +++ b/JellyfinPlayer/Views/HomeView.swift @@ -20,8 +20,33 @@ struct HomeView: View { @ViewBuilder var innerBody: some View { - if viewModel.isLoading { + if let errorMessage = viewModel.errorMessage { + VStack(spacing: 5) { + if viewModel.isLoading { + ProgressView() + .frame(width: 100, height: 100) + .scaleEffect(2) + } else { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 72)) + .foregroundColor(Color.red) + .frame(width: 100, height: 100) + } + + Text("\(errorMessage.code)") + Text(errorMessage.displayMessage) + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) + + PrimaryButtonView(title: "Retry") { + viewModel.refresh() + } + } + .offset(y: -50) + } else if viewModel.isLoading { ProgressView() + .frame(width: 100, height: 100) + .scaleEffect(2) } else { ScrollView { VStack(alignment: .leading) { diff --git a/JellyfinPlayer/Views/ServerListView.swift b/JellyfinPlayer/Views/ServerListView.swift index a09f36ff..85833f3b 100644 --- a/JellyfinPlayer/Views/ServerListView.swift +++ b/JellyfinPlayer/Views/ServerListView.swift @@ -69,22 +69,8 @@ struct ServerListView: View { .frame(minWidth: 50, maxWidth: 240) .multilineTextAlignment(.center) - Button { + PrimaryButtonView(title: L10n.connect.stringValue) { serverListRouter.route(to: \.connectToServer) - } label: { - ZStack { - Rectangle() - .foregroundColor(Color.jellyfinPurple) - .frame(maxWidth: 400, maxHeight: 50) - .frame(height: 50) - .cornerRadius(10) - .padding(.horizontal, 30) - .padding([.top, .bottom], 20) - - L10n.connect.text - .foregroundColor(Color.white) - .bold() - } } } } diff --git a/JellyfinPlayer/Views/SettingsView.swift b/JellyfinPlayer/Views/SettingsView.swift index 63819e44..6f4ec697 100644 --- a/JellyfinPlayer/Views/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView.swift @@ -23,7 +23,6 @@ struct SettingsView: View { @Default(.appAppearance) var appAppearance @Default(.videoPlayerJumpForward) var jumpForwardLength @Default(.videoPlayerJumpBackward) var jumpBackwardLength - @Default(.nativeVideoPlayer) var nativeVideoPlayer var body: some View { Form { @@ -83,8 +82,7 @@ struct SettingsView: View { } } - Section(header: Text("Playback")) { - Toggle("Native Player", isOn: $nativeVideoPlayer) + Section(header: Text("Networking")) { Picker("Default local quality", selection: $inNetworkStreamBitrate) { ForEach(self.viewModel.bitrates, id: \.self) { bitrate in Text(bitrate.name).tag(bitrate.value) @@ -96,43 +94,45 @@ struct SettingsView: View { Text(bitrate.name).tag(bitrate.value) } } - + } + + Section(header: Text("Video Player")) { Picker("Jump Forward Length", selection: $jumpForwardLength) { - ForEach(self.viewModel.videoPlayerJumpLengths, id: \.self) { length in + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in Text(length.label).tag(length.rawValue) } } Picker("Jump Backward Length", selection: $jumpBackwardLength) { - ForEach(self.viewModel.videoPlayerJumpLengths, id: \.self) { length in + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in Text(length.label).tag(length.rawValue) } } } 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 })) +// 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 })) Picker(L10n.appearance, selection: $appAppearance) { - ForEach(self.viewModel.appearances, id: \.self) { appearance in + ForEach(AppAppearance.allCases, id: \.self) { appearance in Text(appearance.localizedName).tag(appearance.rawValue) } } diff --git a/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift deleted file mode 100644 index e6cb3f16..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/NativePlayerViewController.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// NativePlayerViewController.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 11/20/21. -// - -import AVKit -import Combine -import JellyfinAPI -import UIKit - -class NativePlayerViewController: AVPlayerViewController { - - let viewModel: VideoPlayerViewModel - - private var timeObserverToken: Any? - - private var lastProgressTicks: Int64 = 0 - - init(viewModel: VideoPlayerViewModel) { - - self.viewModel = viewModel - - super.init(nibName: nil, bundle: nil) - - let player = AVPlayer(url: viewModel.hlsURL) - - player.appliesMediaSelectionCriteriaAutomatically = false - player.currentItem?.externalMetadata = createMetadata() - - let timeScale = CMTimeScale(NSEC_PER_SEC) - let time = CMTime(seconds: 5, preferredTimescale: timeScale) - - timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in -// print("Timer timed: \(time)") - - if time.seconds != 0 { - self?.sendProgressReport(seconds: time.seconds) - } - } - - self.player = player - - self.allowsPictureInPicturePlayback = true - self.player?.allowsExternalPlayback = true - } - - private func createMetadata() -> [AVMetadataItem] { - let allMetadata: [AVMetadataIdentifier: Any] = [ - .commonIdentifierTitle: viewModel.title, - .iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "", - .commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any, - .commonIdentifierDescription: viewModel.item.overview ?? "" - ] - - return allMetadata.compactMap { createMetadataItem(for:$0, value:$1) } - } - - private func createMetadataItem(for identifier: AVMetadataIdentifier, - value: Any) -> AVMetadataItem { - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - // Specify "und" to indicate an undefined language. - item.extendedLanguageTag = "und" - return item.copy() as! AVMetadataItem - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - stop() - removePeriodicTimeObserver() - } - - func removePeriodicTimeObserver() { - if let timeObserverToken = timeObserverToken { - player?.removeTimeObserver(timeObserverToken) - self.timeObserverToken = nil - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - player?.seek(to: CMTimeMake(value: viewModel.item.userData?.playbackPositionTicks ?? 0, timescale: 10_000_000), toleranceBefore: CMTimeMake(value: 5, timescale: 1), toleranceAfter: CMTimeMake(value: 5, timescale: 1), completionHandler: { _ in - self.play() - }) - } - - private func play() { - player?.play() - viewModel.sendPlayReport() - } - - private func sendProgressReport(seconds: Double) { - viewModel.sendProgressReport() - } - - private func stop() { - viewModel.sendStopReport() - } -} diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift deleted file mode 100644 index a57f3bf6..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ /dev/null @@ -1,251 +0,0 @@ -// -// VLCPlayerOverlayView.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 11/24/21. -// - -import Combine -import MobileVLCKit -import SwiftUI -import JellyfinAPI - - - -struct VLCPlayerOverlayView: View { - - @ObservedObject var viewModel: VideoPlayerViewModel - - @ViewBuilder - private var mainButtonView: some View { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play") - .font(.system(size: 56)) - case .playing: - Image(systemName: "pause") - .font(.system(size: 56)) - default: - ProgressView() - } - } - - @ViewBuilder - private var mainBody: some View { - VStack { - - VStack(alignment: .EpisodeSeriesAlignmentGuide) { - - // MARK: Top Bar - HStack(alignment: .top) { - - VStack(alignment: .leading) { - HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectClose() - } label: { - Image(systemName: "chevron.backward") - } - - Text(viewModel.title) - .font(.system(size: 28, weight: .regular, design: .default)) - .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in - context[.leading] - } - } - } - - Spacer() - - HStack(spacing: 20) { - - if viewModel.shouldShowGoogleCast { - Button { - viewModel.playerOverlayDelegate?.didSelectGoogleCast() - } label: { - Image(systemName: "rectangle.badge.plus") - } - } - - if viewModel.shouldShowAirplay { - Button { - viewModel.playerOverlayDelegate?.didSelectAirplay() - } label: { - Image(systemName: "airplayvideo") - } - } - - Button { - viewModel.playerOverlayDelegate?.didSelectSubtitles() - } label: { - if viewModel.subtitlesEnabled { - Image(systemName: "captions.bubble.fill") - } else { - Image(systemName: "captions.bubble") - } - } - - // MARK: Settings Menu - Menu { - - Menu { - ForEach(viewModel.audioStreams, id: \.self) { audioStream in - Button { - viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 - } label: { - if audioStream.index == viewModel.selectedAudioStreamIndex { - Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") - } else { - Text(audioStream.displayTitle ?? "No Title") - } - } - } - } label: { - HStack { - Image(systemName: "speaker.wave.3") - Text("Audio") - } - } - - Menu { - ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in - Button { - viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 - } label: { - if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { - Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") - } else { - Text(subtitleStream.displayTitle ?? "No Title") - } - } - } - } label: { - HStack { - Image(systemName: "captions.bubble") - Text("Subtitles") - } - } - - Menu { - Button { - print("third pressed") - } label: { - Text("TODO") - } - } label: { - HStack { - Image(systemName: "speedometer") - Text("Playback Speed") - } - } - - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - .font(.system(size: 24)) - - if let seriesTitle = viewModel.subtitle { - Text(seriesTitle) - .font(.subheadline) - .foregroundColor(Color.gray) - .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in - context[.leading] - } - .offset(y: -10) - } - } - - Spacer() - - // MARK: Center Buttons - HStack(spacing: 80) { - Button { - viewModel.playerOverlayDelegate?.didSelectBackward() - } label: { - Image(systemName: "gobackward.10") - } - - Button { - viewModel.playerOverlayDelegate?.didSelectMain() - } label: { - mainButtonView - } - - Button { - viewModel.playerOverlayDelegate?.didSelectForward() - } label: { - Image(systemName: "goforward.10") - } - } - .font(.system(size: 48)) - - Spacer() - - // MARK: Bottom Bar - HStack { - Text(viewModel.leftLabelText) - .font(.system(size: 18, weight: .semibold, design: .default)) - - Slider(value: $viewModel.sliderPercentage) { editing in - viewModel.sliderIsScrubbing = editing - } - .foregroundColor(.purple) - .tint(.purple) - - Text(viewModel.rightLabelText) - .font(.system(size: 18, weight: .semibold, design: .default)) - } - .frame(height: 50) - } - .padding(.top) - .ignoresSafeArea(edges: .vertical) - .tint(Color.white) - .foregroundColor(Color.white) - } - - var body: some View { - mainBody - .background { - Color(uiColor: .black.withAlphaComponent(0.2)) - .ignoresSafeArea() - .onTapGesture { - viewModel.playerOverlayDelegate?.didGenerallyTap() - } - } - } -} - -struct VLCPlayerOverlayView_Previews: PreviewProvider { - static var previews: some View { - ZStack { - Color.gray - .ignoresSafeArea() - - VLCPlayerOverlayView(viewModel: 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)], - defaultAudioStreamIndex: -1, - defaultSubtitleStreamIndex: -1, - playerState: .playing, - shouldShowGoogleCast: false, - shouldShowAirplay: false, - subtitlesEnabled: true, - sliderPercentage: 0.0, - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - showAdjacentItems: true, - shouldShowAutoPlayNextItem: true, - autoPlayNextItem: true)) - } - .previewInterfaceOrientation(.landscapeLeft) - } -} - - diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift b/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift deleted file mode 100644 index 720266c7..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VideoPlayerOverlay.swift +++ /dev/null @@ -1,26 +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 SwiftUI - -protocol VideoPlayerOverlay: View { - var viewModel: VideoPlayerViewModel { get set } -} - -extension HorizontalAlignment { - - private struct EpisodeSeriesTitleAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[HorizontalAlignment.leading] - } - } - - static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(EpisodeSeriesTitleAlignment.self) - -} diff --git a/JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift b/JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift index 78b410f1..90806983 100644 --- a/JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift +++ b/JellyfinPlayer/Views/VideoPlayer/PlaybackSpeed.swift @@ -1,9 +1,11 @@ // -// PlaybackSpeed.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 12/27/21. -// + /* + * 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 Foundation diff --git a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift index f5affe9d..f80db501 100644 --- a/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -1,18 +1,17 @@ // -// PlayerOverlayDelegate.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 12/27/21. -// + /* + * 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 Foundation protocol PlayerOverlayDelegate { func didSelectClose() - func didSelectGoogleCast() - func didSelectAirplay() - func didSelectSubtitles() func didSelectMenu() func didDeselectMenu() @@ -28,6 +27,6 @@ protocol PlayerOverlayDelegate { func didSelectAudioStream(index: Int) func didSelectSubtitleStream(index: Int) - func didSelectPreviousItem() - func didSelectNextItem() + func didSelectPlayPreviousItem() + func didSelectPlayNextItem() } diff --git a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift similarity index 78% rename from JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift rename to JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift index 9a9ceda0..b578ecf1 100644 --- a/JellyfinPlayer/Views/VideoPlayer/Overlays/VLCPlayerCompactOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift @@ -1,9 +1,10 @@ -// -// VLCPlayerCompactOverlayView.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 12/26/21. -// +/* + * 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 Combine import Defaults @@ -12,11 +13,9 @@ import MobileVLCKit import Sliders import SwiftUI -struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { +struct VLCPlayerOverlayView: View { @ObservedObject var viewModel: VideoPlayerViewModel - @Default(.videoPlayerJumpForward) var jumpForwardLength - @Default(.videoPlayerJumpBackward) var jumpBackwardLength @ViewBuilder private var mainButtonView: some View { @@ -69,33 +68,19 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { HStack(spacing: 20) { - if viewModel.shouldShowGoogleCast { + if viewModel.shouldShowPlayPreviousItem { Button { - viewModel.playerOverlayDelegate?.didSelectGoogleCast() - } label: { - Image(systemName: "rectangle.badge.plus") - } - } - - if viewModel.shouldShowAirplay { - Button { - viewModel.playerOverlayDelegate?.didSelectAirplay() - } label: { - Image(systemName: "airplayvideo") - } - } - - if viewModel.showAdjacentItems { - Button { - viewModel.playerOverlayDelegate?.didSelectPreviousItem() + viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() } label: { Image(systemName: "chevron.left.circle") } .disabled(viewModel.previousItemVideoPlayerViewModel == nil) .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - + } + + if viewModel.shouldShowPlayNextItem { Button { - viewModel.playerOverlayDelegate?.didSelectNextItem() + viewModel.playerOverlayDelegate?.didSelectPlayNextItem() } label: { Image(systemName: "chevron.right.circle") } @@ -105,9 +90,9 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { if viewModel.shouldShowAutoPlayNextItem { Button { - viewModel.autoPlayNextItem.toggle() + viewModel.autoplayEnabled.toggle() } label: { - if viewModel.autoPlayNextItem { + if viewModel.autoplayEnabled { Image(systemName: "play.circle.fill") } else { Image(systemName: "play.circle") @@ -117,7 +102,7 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { if !viewModel.subtitleStreams.isEmpty { Button { - viewModel.playerOverlayDelegate?.didSelectSubtitles() + viewModel.subtitlesEnabled.toggle() } label: { if viewModel.subtitlesEnabled { Image(systemName: "captions.bubble.fill") @@ -192,9 +177,9 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { Menu { ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in Button { - jumpForwardLength = forwardLength + viewModel.jumpForwardLength = forwardLength } label: { - if forwardLength == jumpForwardLength { + if forwardLength == viewModel.jumpForwardLength { Label(forwardLength.shortLabel, systemImage: "checkmark") } else { Text(forwardLength.shortLabel) @@ -211,9 +196,9 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { Menu { ForEach(VideoPlayerJumpLength.allCases, id: \.self) { backwardLength in Button { - jumpBackwardLength = backwardLength + viewModel.jumpBackwardLength = backwardLength } label: { - if backwardLength == jumpBackwardLength { + if backwardLength == viewModel.jumpBackwardLength { Label(backwardLength.shortLabel, systemImage: "checkmark") } else { Text(backwardLength.shortLabel) @@ -247,6 +232,11 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { } } + // MARK: Center + + Spacer() + + Spacer() // MARK: Bottom Bar @@ -264,7 +254,7 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { Button { viewModel.playerOverlayDelegate?.didSelectBackward() } label: { - Image(systemName: jumpBackwardLength.backwardImageLabel) + Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) .padding(.horizontal, 5) } @@ -279,12 +269,11 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { Button { viewModel.playerOverlayDelegate?.didSelectForward() } label: { - Image(systemName: jumpForwardLength.forwardImageLabel) + Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) .padding(.horizontal, 5) } } .font(.system(size: 24, weight: .semibold, design: .default)) -// .padding(.trailing, 10) Text(viewModel.leftLabelText) .font(.system(size: 18, weight: .semibold, design: .default)) @@ -332,32 +321,43 @@ struct VLCPlayerCompactOverlayView: View, VideoPlayerOverlay { } struct VLCPlayerCompactOverlayView_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, + shouldShowAutoPlayNextItem: true) + static var previews: some View { ZStack { Color.red .ignoresSafeArea() - VLCPlayerCompactOverlayView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 720 * 10_000_000), - 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)], - defaultAudioStreamIndex: -1, - defaultSubtitleStreamIndex: -1, - playerState: .playing, - shouldShowGoogleCast: false, - shouldShowAirplay: false, - subtitlesEnabled: true, - sliderPercentage: 0.432, - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - showAdjacentItems: true, - shouldShowAutoPlayNextItem: true, - autoPlayNextItem: true)) + VLCPlayerOverlayView(viewModel: videoPlayerViewModel) } .previewInterfaceOrientation(.landscapeLeft) } } + +// MARK: TitleSubtitleAlignment +extension HorizontalAlignment { + + private struct TitleSubtitleAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[HorizontalAlignment.leading] + } + } + + static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(TitleSubtitleAlignment.self) +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerView.swift new file mode 100644 index 00000000..099d0838 --- /dev/null +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerView.swift @@ -0,0 +1,27 @@ +// + /* + * 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 UIKit +import SwiftUI + +struct VLCPlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = VLCPlayerViewController + + func makeUIViewController(context: Context) -> VLCPlayerViewController { + + return VLCPlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) { + + } +} diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 9b38fa2f..490e242e 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -1,9 +1,11 @@ // -// PlayerViewController.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 11/12/21. -// + /* + * 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 AVKit import AVFoundation @@ -25,7 +27,7 @@ class VLCPlayerViewController: UIViewController { private var vlcMediaPlayer = VLCMediaPlayer() private var lastPlayerTicks: Int64 = 0 private var lastProgressReportTicks: Int64 = 0 - private var viewModelReactCancellables = Set() + private var viewModelListeners = Set() private var overlayDismissTimer: Timer? private var currentPlayerTicks: Int64 { @@ -36,19 +38,11 @@ class VLCPlayerViewController: UIViewController { return currentOverlayHostingController?.view.alpha ?? 0 > 0 } - private var jumpForwardLength: VideoPlayerJumpLength { - return Defaults[.videoPlayerJumpForward] - } - - private var jumpBackwardLength: VideoPlayerJumpLength { - return Defaults[.videoPlayerJumpBackward] - } - private lazy var videoContentView = makeVideoContentView() - private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() - private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() - private lazy var tapGestureView = makeTapGestureView() - private var currentOverlayHostingController: UIHostingController? + private lazy var mainGestureView = makeTapGestureView() + private var currentOverlayHostingController: UIHostingController? + private var currentJumpBackwardOverlayView: UIImageView? + private var currentJumpForwardOverlayView: UIImageView? // MARK: init @@ -67,12 +61,7 @@ class VLCPlayerViewController: UIViewController { private func setupSubviews() { view.addSubview(videoContentView) - view.addSubview(jumpForwardOverlayView) - view.addSubview(jumpBackwardOverlayView) - view.addSubview(tapGestureView) - - jumpBackwardOverlayView.alpha = 0 - jumpForwardOverlayView.alpha = 0 + view.addSubview(mainGestureView) } private func setupConstraints() { @@ -83,18 +72,10 @@ class VLCPlayerViewController: UIViewController { videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor) ]) NSLayoutConstraint.activate([ - jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), - jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - NSLayoutConstraint.activate([ - jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), - jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - NSLayoutConstraint.activate([ - tapGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), - tapGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - tapGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) + mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), + mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) ]) } @@ -127,6 +108,9 @@ class VLCPlayerViewController: UIViewController { setupMediaPlayer(newViewModel: viewModel) + refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) + refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) + let defaultNotificationCenter = NotificationCenter.default defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil) defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) @@ -192,26 +176,6 @@ class VLCPlayerViewController: UIViewController { self.didSelectBackward() } - private func makeJumpBackwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let forwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.tintColor = .white - - return imageView - } - - private func makeJumpForwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.tintColor = .white - - return imageView - } - // MARK: setupOverlayHostingController private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { @@ -225,11 +189,10 @@ class VLCPlayerViewController: UIViewController { currentOverlayHostingController.view.removeFromSuperview() currentOverlayHostingController.removeFromParent() -// self.currentOverlayHostingController = nil } } - let newOverlayView = VLCPlayerCompactOverlayView(viewModel: viewModel) + let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) let newOverlayHostingController = UIHostingController(rootView: newOverlayView) newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false @@ -256,10 +219,59 @@ class VLCPlayerViewController: UIViewController { self.currentOverlayHostingController = newOverlayHostingController - // There is a behavior when setting this that the navigation bar - // on the current navigation controller pops up, re-hide it + // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it self.navigationController?.isNavigationBarHidden = true } + + private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { + + if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { + currentJumpBackwardOverlayView.removeFromSuperview() + } + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) + let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) + + newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false + newJumpBackwardImageView.tintColor = .white + + newJumpBackwardImageView.alpha = 0 + + view.addSubview(newJumpBackwardImageView) + + NSLayoutConstraint.activate([ + newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), + newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + currentJumpBackwardOverlayView = newJumpBackwardImageView + } + + private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) { + + if let currentJumpForwardOverlayView = currentJumpForwardOverlayView { + currentJumpForwardOverlayView.removeFromSuperview() + } + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let newJumpForwardImageView = UIImageView(image: forwardSymbolImage) + + newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false + newJumpForwardImageView.tintColor = .white + + newJumpForwardImageView.alpha = 0 + + view.addSubview(newJumpForwardImageView) + + NSLayoutConstraint.activate([ + newJumpForwardImageView.leftAnchor.constraint(equalTo: view.rightAnchor, constant: -150), + newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + currentJumpForwardOverlayView = newJumpForwardImageView + } } // MARK: setupMediaPlayer @@ -275,7 +287,7 @@ extension VLCPlayerViewController { // Stop current media if there is one if vlcMediaPlayer.media != nil { - viewModelReactCancellables.forEach({ $0.cancel() }) + viewModelListeners.forEach({ $0.cancel() }) vlcMediaPlayer.stop() viewModel.sendStopReport() @@ -297,7 +309,7 @@ extension VLCPlayerViewController { newViewModel.getAdjacentEpisodes() newViewModel.playerOverlayDelegate = self - let startPercentage = viewModel.item.userData?.playedPercentage ?? 0 + let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 if startPercentage > 0 { newViewModel.sliderPercentage = startPercentage / 100 @@ -320,9 +332,10 @@ extension VLCPlayerViewController { // MARK: setupViewModelListeners private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + viewModel.$playbackSpeed.sink { newSpeed in self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelReactCancellables) + }.store(in: &viewModelListeners) viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in if sliderIsScrubbing { @@ -330,15 +343,27 @@ extension VLCPlayerViewController { } else { self.didEndScrubbing() } - }.store(in: &viewModelReactCancellables) + }.store(in: &viewModelListeners) viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelReactCancellables) + }.store(in: &viewModelListeners) viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelReactCancellables) + }.store(in: &viewModelListeners) + + viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in + self.didToggleSubtitles(newValue: newSubtitlesEnabled) + }.store(in: &viewModelListeners) + + viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in + self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) + }.store(in: &viewModelListeners) + + viewModel.$jumpForwardLength.sink { newJumpForwardLength in + self.refreshJumpForwardOverlayView(with: newJumpForwardLength) + }.store(in: &viewModelListeners) } func setMediaPlayerTimeAtCurrentSlider() { @@ -395,34 +420,42 @@ extension VLCPlayerViewController { extension VLCPlayerViewController { private func flashJumpBackwardOverlay() { - jumpBackwardOverlayView.layer.removeAllAnimations() + guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + + currentJumpBackwardOverlayView.layer.removeAllAnimations() UIView.animate(withDuration: 0.1) { - self.jumpBackwardOverlayView.alpha = 1 + currentJumpBackwardOverlayView.alpha = 1 } completion: { _ in self.hideJumpBackwardOverlay() } } private func hideJumpBackwardOverlay() { + guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + UIView.animate(withDuration: 0.3) { - self.jumpBackwardOverlayView.alpha = 0 + currentJumpBackwardOverlayView.alpha = 0 } } private func flashJumpFowardOverlay() { - jumpForwardOverlayView.layer.removeAllAnimations() + guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + + currentJumpForwardOverlayView.layer.removeAllAnimations() UIView.animate(withDuration: 0.1) { - self.jumpForwardOverlayView.alpha = 1 + currentJumpForwardOverlayView.alpha = 1 } completion: { _ in self.hideJumpForwardOverlay() } } private func hideJumpForwardOverlay() { + guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + UIView.animate(withDuration: 0.3) { - self.jumpForwardOverlayView.alpha = 0 + currentJumpForwardOverlayView.alpha = 0 } } } @@ -459,8 +492,8 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { viewModel.playerState = vlcMediaPlayer.state if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoPlayNextItem && viewModel.shouldShowAutoPlayNextItem && viewModel.nextItemVideoPlayerViewModel != nil { - didSelectNextItem() + if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() } else { didSelectClose() } @@ -470,13 +503,10 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { // MARK: mediaPlayerTimeChanged func mediaPlayerTimeChanged(_ aNotification: Notification!) { - guard !viewModel.sliderIsScrubbing else { - lastPlayerTicks = currentPlayerTicks - return + if !viewModel.sliderIsScrubbing { + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) } - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - // Have to manually set playing because VLCMediaPlayer doesn't // properly set it itself if abs(currentPlayerTicks - lastPlayerTicks) >= 10_000 { @@ -486,6 +516,9 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { // If needing to fix subtitle streams during playback if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && viewModel.subtitlesEnabled { didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + } + + if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) } @@ -500,7 +533,7 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { } } -// MARK: PlayerOverlayDelegate +// MARK: PlayerOverlayDelegate and more extension VLCPlayerViewController: PlayerOverlayDelegate { func didSelectAudioStream(index: Int) { @@ -511,12 +544,11 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { lastProgressReportTicks = currentPlayerTicks } + /// Do not call when setting to index -1 func didSelectSubtitleStream(index: Int) { - if viewModel.subtitlesEnabled { - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } + + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) viewModel.sendProgressReport() @@ -531,19 +563,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { dismiss(animated: true, completion: nil) } - func didSelectGoogleCast() { - print("didSelectCast") - } - - func didSelectAirplay() { - print("didSelectAirplay") - } - - func didSelectSubtitles() { - - viewModel.subtitlesEnabled = !viewModel.subtitlesEnabled - - if viewModel.subtitlesEnabled { + func didToggleSubtitles(newValue: Bool) { + if newValue { vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) } else { vlcMediaPlayer.currentVideoSubTitleIndex = -1 @@ -561,27 +582,33 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } func didSelectBackward() { + flashJumpBackwardOverlay() - vlcMediaPlayer.jumpBackward(jumpBackwardLength.rawValue) + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - restartOverlayDismissTimer() + if displayingOverlay { + restartOverlayDismissTimer() + } viewModel.sendProgressReport() - self.lastProgressReportTicks = currentPlayerTicks + lastProgressReportTicks = currentPlayerTicks } func didSelectForward() { + flashJumpFowardOverlay() - vlcMediaPlayer.jumpForward(jumpForwardLength.rawValue) + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - restartOverlayDismissTimer() + if displayingOverlay { + restartOverlayDismissTimer() + } viewModel.sendProgressReport() - self.lastProgressReportTicks = currentPlayerTicks + lastProgressReportTicks = currentPlayerTicks } func didSelectMain() { @@ -619,16 +646,20 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { viewModel.sendProgressReport() - self.lastProgressReportTicks = currentPlayerTicks + lastProgressReportTicks = currentPlayerTicks } - func didSelectPreviousItem() { - setupMediaPlayer(newViewModel: viewModel.previousItemVideoPlayerViewModel!) - startPlayback() + func didSelectPlayPreviousItem() { + if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) + startPlayback() + } } - func didSelectNextItem() { - setupMediaPlayer(newViewModel: viewModel.nextItemVideoPlayerViewModel!) - startPlayback() + func didSelectPlayNextItem() { + if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) + startPlayback() + } } } diff --git a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift b/JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift deleted file mode 100644 index 8f9bf3e9..00000000 --- a/JellyfinPlayer/Views/VideoPlayer/VideoPlayerView.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// VideoPlayerView.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 11/12/21. -// - -import UIKit -import SwiftUI - -struct NativePlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = NativePlayerViewController - - func makeUIViewController(context: Context) -> NativePlayerViewController { - - return NativePlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) { - - } -} - -struct VLCPlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = VLCPlayerViewController - - func makeUIViewController(context: Context) -> VLCPlayerViewController { - - return VLCPlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) { - - } -} diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift index f15a8b63..ea74d736 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift @@ -19,7 +19,6 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { @Root var start = makeStart - @Default(.nativeVideoPlayer) var nativeVideoPlayer let viewModel: VideoPlayerViewModel init(viewModel: VideoPlayerViewModel) { @@ -27,24 +26,13 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { } @ViewBuilder func makeStart() -> some View { - if nativeVideoPlayer { - PreferenceUIHostingControllerView { - NativePlayerView(viewModel: self.viewModel) - .navigationBarHidden(true) - .statusBar(hidden: true) - .ignoresSafeArea() - .prefersHomeIndicatorAutoHidden(true) - .supportedOrientations(.landscape) - }.ignoresSafeArea() - } else { - PreferenceUIHostingControllerView { - VLCPlayerView(viewModel: self.viewModel) - .navigationBarHidden(true) - .statusBar(hidden: true) - .ignoresSafeArea() - .prefersHomeIndicatorAutoHidden(true) - .supportedOrientations(.landscape) - }.ignoresSafeArea() - } + PreferenceUIHostingControllerView { + VLCPlayerView(viewModel: self.viewModel) + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + .prefersHomeIndicatorAutoHidden(true) + .supportedOrientations(.landscape) + }.ignoresSafeArea() } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index 959a91bf..bbed415e 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -80,6 +80,8 @@ extension BaseItemDto { hlsURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(defaultSubtitleStream!.index!)") } + // MARK: VidoPlayerViewModel Creation + var subtitle: String? = nil // TODO: other forms of media subtitle @@ -89,34 +91,32 @@ extension BaseItemDto { } } + let subtitlesEnabled = Defaults[.subtitlesEnabledIfDefault] && defaultSubtitleStream != nil - // MARK: VidoPlayerViewModel Creation + let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode + let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay - // TODO: show adjacent items + let overlayType = Defaults[.overlayType] - let shouldShowAutoPlayNextItem = Defaults[.shouldShowAutoPlayNextItem] && itemType == .episode - let autoPlayNextItem = Defaults[.autoPlayNextItem] + let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode + let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode let videoPlayerViewModel = VideoPlayerViewModel(item: self, - title: self.name!, + title: self.name ?? "", subtitle: subtitle, streamURL: streamURL.url!, hlsURL: hlsURL.url!, response: response, audioStreams: audioStreams, subtitleStreams: subtitleStreams, - defaultAudioStreamIndex: defaultAudioStream?.index ?? -1, - defaultSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, - playerState: .playing, - shouldShowGoogleCast: false, - shouldShowAirplay: false, - subtitlesEnabled: defaultSubtitleStream?.index != nil, - sliderPercentage: (self.userData?.playedPercentage ?? 0) / 100, selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, - showAdjacentItems: true, - shouldShowAutoPlayNextItem: shouldShowAutoPlayNextItem, - autoPlayNextItem: autoPlayNextItem) + subtitlesEnabled: subtitlesEnabled, + autoplayEnabled: autoplayEnabled, + overlayType: overlayType, + shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, + shouldShowPlayNextItem: shouldShowPlayNextItem, + shouldShowAutoPlayNextItem: shouldShowAutoPlay) return videoPlayerViewModel }) diff --git a/Shared/Objects/OverlaySliderColor.swift b/Shared/Objects/OverlaySliderColor.swift new file mode 100644 index 00000000..4844d851 --- /dev/null +++ b/Shared/Objects/OverlaySliderColor.swift @@ -0,0 +1,25 @@ +// + /* + * 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 UIKit + +enum OverlaySliderColor: String, CaseIterable, DefaultsSerializable { + case white + case jellyfinPurple + + var displayLabel: String { + switch self { + case .white: + return "White" + case .jellyfinPurple: + return "Jellyfin Purple" + } + } +} diff --git a/Shared/Objects/OverlayType.swift b/Shared/Objects/OverlayType.swift new file mode 100644 index 00000000..4e2cfe8f --- /dev/null +++ b/Shared/Objects/OverlayType.swift @@ -0,0 +1,17 @@ +// + /* + * 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 Foundation + +enum OverlayType: String, CaseIterable, Defaults.Serializable { + case normal + case compact + case bottom +} diff --git a/Shared/Singleton/SessionManager.swift b/Shared/Singleton/SessionManager.swift index d15ad30f..59c7cae2 100644 --- a/Shared/Singleton/SessionManager.swift +++ b/Shared/Singleton/SessionManager.swift @@ -31,7 +31,7 @@ final class SessionManager { // MARK: init private init() { - if let lastUserID = SwiftfinStore.Defaults.suite[.lastServerUserID], + if let lastUserID = Defaults[.lastServerUserID], let user = try? SwiftfinStore.dataStack.fetchOne(From(), [Where("id == %@", lastUserID)]) { @@ -64,7 +64,7 @@ final class SessionManager { var uriComponents = URLComponents(string: uri) ?? URLComponents() if uriComponents.scheme == nil { - uriComponents.scheme = SwiftfinStore.Defaults.suite[.defaultHTTPScheme].rawValue + uriComponents.scheme = Defaults[.defaultHTTPScheme].rawValue } var uri = uriComponents.string ?? "" @@ -216,7 +216,7 @@ final class SessionManager { let currentServer = SwiftfinStore.dataStack.fetchExisting(server)! let currentUser = SwiftfinStore.dataStack.fetchExisting(user)! - SwiftfinStore.Defaults.suite[.lastServerUserID] = user.id + Defaults[.lastServerUserID] = user.id currentLogin = (server: currentServer.state, user: currentUser.state) SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) @@ -230,7 +230,7 @@ final class SessionManager { // MARK: loginUser func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { JellyfinAPI.basePath = server.currentURI - SwiftfinStore.Defaults.suite[.lastServerUserID] = user.id + Defaults[.lastServerUserID] = user.id setAuthHeader(with: user.accessToken) currentLogin = (server: server, user: user) SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) @@ -241,7 +241,7 @@ final class SessionManager { currentLogin = nil JellyfinAPI.basePath = "" setAuthHeader(with: "") - SwiftfinStore.Defaults.suite[.lastServerUserID] = nil + Defaults[.lastServerUserID] = nil SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) } @@ -254,8 +254,8 @@ final class SessionManager { delete(server: server) } - // Delete UserDefaults - SwiftfinStore.Defaults.suite.removeAll() + // Delete general UserDefaults + SwiftfinStore.Defaults.generalSuite.removeAll() SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didPurge, object: nil) } diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 118fa85f..25aefd4f 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -14,25 +14,44 @@ extension SwiftfinStore { enum Defaults { - static let suite: UserDefaults = { - return UserDefaults(suiteName: "swiftfinstore-defaults")! + static let generalSuite: UserDefaults = { + return UserDefaults(suiteName: "swiftfinstore-general-defaults")! + }() + + static let universalSuite: UserDefaults = { + return UserDefaults(suiteName: "swiftfinstore-universal-defaults")! }() } } extension Defaults.Keys { - static let lastServerUserID = Defaults.Key("lastServerUserID", suite: SwiftfinStore.Defaults.suite) - - static let defaultHTTPScheme = Key("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.suite) - static let inNetworkBandwidth = Key("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.suite) - static let outOfNetworkBandwidth = Key("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.suite) - static let isAutoSelectSubtitles = Key("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.suite) - static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", default: "Auto", suite: SwiftfinStore.Defaults.suite) - static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.suite) - static let appAppearance = Key("appAppearance", default: .system, suite: SwiftfinStore.Defaults.suite) - static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.suite) - static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.suite) - static let nativeVideoPlayer = Key("nativeVideoPlayer", default: false, suite: SwiftfinStore.Defaults.suite) - static let shouldShowAutoPlayNextItem = Key("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.suite) - static let autoPlayNextItem = Key("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.suite) + + // Universal settings + static let defaultHTTPScheme = Key("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite) + static let appAppearance = Key("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite) + + // General settings + static let lastServerUserID = Defaults.Key("lastServerUserID", suite: SwiftfinStore.Defaults.generalSuite) + static let inNetworkBandwidth = Key("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) + static let outOfNetworkBandwidth = Key("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) + static let isAutoSelectSubtitles = Key("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) + static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) + + static let overlayType = Key("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) + static let gesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) + static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) + static let subtitlesEnabledIfDefault = Key("subtitlesEnabledIfDefault", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let autoplayEnabled = Key("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + + // Should show video player items + static let shouldShowPlayPreviousItem = Key("shouldShowPreviousItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowPlayNextItem = Key("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowAutoPlay = Key("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + + // Experimental settings + struct Experimental { + static let syncSubtitleStateWithAdjacent = Key("experimental.syncSubtitleState", default: false, suite: SwiftfinStore.Defaults.generalSuite) + } } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 716dafea..6143c459 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -64,8 +64,9 @@ final class HomeViewModel: ViewModel { case .finished: () case .failure: self.libraries = [] - self.handleAPIRequestError(completion: completion) } + + self.handleAPIRequestError(completion: completion) }, receiveValue: { response in var newLibraries: [BaseItemDto] = [] diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index 5cedb82f..eef24a8d 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -12,13 +12,13 @@ import SwiftUI import Defaults final class SettingsViewModel: ObservableObject { - let currentLocale = Locale.current + var bitrates: [Bitrates] = [] - var langs = [TrackLanguage]() - let appearances = AppAppearance.allCases - let videoPlayerJumpLengths = VideoPlayerJumpLength.allCases + var langs: [TrackLanguage] = [] init() { + + // Bitrates let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")! do { @@ -32,8 +32,9 @@ final class SettingsViewModel: ObservableObject { LogManager.shared.log.error("Error processing JSON file `bitrates.json`") } + // Track languages self.langs = Locale.isoLanguageCodes.compactMap { - guard let name = currentLocale.localizedString(forLanguageCode: $0) else { return nil } + guard let name = Locale.current.localizedString(forLanguageCode: $0) else { return nil } return TrackLanguage(name: name, isoCode: $0) }.sorted(by: { $0.name < $1.name }) self.langs.insert(.auto, at: 0) diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index a1eeab45..79ec3195 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -1,9 +1,11 @@ // -// VideoPlayerViewModel.swift -// JellyfinVideoPlayerDev -// -// Created by Ethan Pippin on 11/12/21. -// + /* + * 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 Combine import Defaults @@ -19,71 +21,59 @@ import MobileVLCKit final class VideoPlayerViewModel: ViewModel { + // MARK: Published + // Manually kept state because VLCKit doesn't properly set "played" // on the VLCMediaPlayer object - @Published var playerState: VLCMediaPlayerState - @Published var shouldShowGoogleCast: Bool - @Published var shouldShowAirplay: Bool - @Published var subtitlesEnabled: Bool { - didSet { - if subtitlesEnabled != oldValue { - previousItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) - nextItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) - } - } - } + @Published var playerState: VLCMediaPlayerState = .buffering @Published var leftLabelText: String = "--:--" @Published var rightLabelText: String = "--:--" @Published var playbackSpeed: PlaybackSpeed = .one - @Published var sliderPercentage: Double { + @Published var subtitlesEnabled: Bool + @Published var selectedAudioStreamIndex: Int + @Published var selectedSubtitleStreamIndex: Int + @Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel? + @Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel? + @Published var jumpBackwardLength: VideoPlayerJumpLength + @Published var jumpForwardLength: VideoPlayerJumpLength + @Published var sliderIsScrubbing: Bool = false + @Published var sliderPercentage: Double = 0 { willSet { sliderScrubbingSubject.send(self) sliderPercentageChanged(newValue: newValue) } } - @Published var sliderIsScrubbing: Bool = false - @Published var selectedAudioStreamIndex: Int { - didSet { - previousItemVideoPlayerViewModel?.matchAudioStream(with: self) - nextItemVideoPlayerViewModel?.matchAudioStream(with: self) - } - } - @Published var selectedSubtitleStreamIndex: Int { - didSet { - previousItemVideoPlayerViewModel?.matchSubtitleStream(with: self) - nextItemVideoPlayerViewModel?.matchSubtitleStream(with: self) - } - } - @Published var showAdjacentItems: Bool - @Published var shouldShowAutoPlayNextItem: Bool { + @Published var autoplayEnabled: Bool { willSet { - Defaults[.shouldShowAutoPlayNextItem] = newValue + Defaults[.autoplayEnabled] = newValue } } - @Published var autoPlayNextItem: Bool { - willSet { - Defaults[.autoPlayNextItem] = newValue - } - } - @Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel? - @Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel? + // MARK: ShouldShowItems + + let shouldShowPlayPreviousItem: Bool + let shouldShowPlayNextItem: Bool + let shouldShowAutoPlayNextItem: Bool + + // MARK: General let item: BaseItemDto let title: String let subtitle: String? let streamURL: URL let hlsURL: URL - // Full response kept for convenience - let response: PlaybackInfoResponse let audioStreams: [MediaStream] let subtitleStreams: [MediaStream] - let defaultAudioStreamIndex: Int - let defaultSubtitleStreamIndex: Int + let overlayType: OverlayType + + // Full response kept for convenience + let response: PlaybackInfoResponse var playerOverlayDelegate: PlayerOverlayDelegate? - // Ticks of the time the media has begun - var startTimeTicks: Int64? + // Ticks of the time the media began playing + private var startTimeTicks: Int64 = 0 + + // MARK: Current Time var currentSeconds: Double { let videoDuration = Double(item.runTimeTicks! / 10_000_000) @@ -107,18 +97,16 @@ final class VideoPlayerViewModel: ViewModel { response: PlaybackInfoResponse, audioStreams: [MediaStream], subtitleStreams: [MediaStream], - defaultAudioStreamIndex: Int, - defaultSubtitleStreamIndex: Int, - playerState: VLCMediaPlayerState, - shouldShowGoogleCast: Bool, - shouldShowAirplay: Bool, - subtitlesEnabled: Bool, - sliderPercentage: Double, selectedAudioStreamIndex: Int, selectedSubtitleStreamIndex: Int, - showAdjacentItems: Bool, - shouldShowAutoPlayNextItem: Bool, - autoPlayNextItem: Bool) { + subtitlesEnabled: Bool, + autoplayEnabled: Bool, + overlayType: OverlayType, + shouldShowPlayPreviousItem: Bool, + shouldShowPlayNextItem: Bool, + shouldShowAutoPlayNextItem: Bool + + ) { self.item = item self.title = title self.subtitle = subtitle @@ -127,26 +115,21 @@ final class VideoPlayerViewModel: ViewModel { self.response = response self.audioStreams = audioStreams self.subtitleStreams = subtitleStreams - self.defaultAudioStreamIndex = defaultAudioStreamIndex - self.defaultSubtitleStreamIndex = defaultSubtitleStreamIndex - self.playerState = playerState - self.shouldShowGoogleCast = shouldShowGoogleCast - self.shouldShowAirplay = shouldShowAirplay - self.subtitlesEnabled = subtitlesEnabled - self.sliderPercentage = sliderPercentage self.selectedAudioStreamIndex = selectedAudioStreamIndex self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex - self.showAdjacentItems = showAdjacentItems + self.subtitlesEnabled = subtitlesEnabled + self.autoplayEnabled = autoplayEnabled + self.overlayType = overlayType + self.shouldShowPlayPreviousItem = shouldShowPlayPreviousItem + self.shouldShowPlayNextItem = shouldShowPlayNextItem self.shouldShowAutoPlayNextItem = shouldShowAutoPlayNextItem - self.autoPlayNextItem = autoPlayNextItem + + self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward] + self.jumpForwardLength = Defaults[.videoPlayerJumpForward] super.init() - self.sliderPercentageChanged(newValue: (item.userData?.playedPercentage ?? 0) / 100) - - if item.itemType != .episode { - self.showAdjacentItems = false - } + self.sliderPercentage = (item.userData?.playedPercentage ?? 0) / 100 } private func sliderPercentageChanged(newValue: Double) { diff --git a/Shared/ViewModels/ViewModel.swift b/Shared/ViewModels/ViewModel.swift index 18f023cc..1795c8b6 100644 --- a/Shared/ViewModels/ViewModel.swift +++ b/Shared/ViewModels/ViewModel.swift @@ -27,7 +27,7 @@ class ViewModel: ObservableObject { func handleAPIRequestError(displayMessage: String? = nil, logLevel: LogLevel = .error, tag: String = "", function: String = #function, file: String = #file, line: UInt = #line, completion: Subscribers.Completion) { switch completion { case .finished: - break + self.errorMessage = nil case .failure(let error): let logConstructor = LogConstructor(message: "__NOTHING__", tag: tag, level: logLevel, function: function, file: file, line: line) From 47249c2edd3cafd57263daba9fd56a94e2c5442f Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sun, 2 Jan 2022 21:43:00 -0700 Subject: [PATCH 40/62] tvos begin final work --- .../VideoPlayer/PlayerOverlayDelegate.swift | 9 +- .../VideoPlayer/VLCPlayerViewController.swift | 115 ++++++++---------- .../tvOSOverlay/SmallMenuOverlay.swift | 17 +-- .../tvOSOverlay/tvOSOverlayContent.swift | 39 +++--- .../tvOSOverlay/tvOSVLCOverlay.swift | 55 +++++---- .../VideoPlayer/VLCPlayerOverlayView.swift | 2 +- .../VideoPlayer/VLCPlayerViewController.swift | 2 +- .../tvOSVideoPlayerCoordinator.swift | 4 - 8 files changed, 111 insertions(+), 132 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index 0a0f8cae..f80db501 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -12,9 +12,6 @@ import Foundation protocol PlayerOverlayDelegate { func didSelectClose() - func didSelectGoogleCast() - func didSelectAirplay() - func didSelectSubtitles() func didSelectMenu() func didDeselectMenu() @@ -30,8 +27,6 @@ protocol PlayerOverlayDelegate { func didSelectAudioStream(index: Int) func didSelectSubtitleStream(index: Int) - func didSelectPreviousItem() - func didSelectNextItem() - - func didFocusOnButton() + func didSelectPlayPreviousItem() + func didSelectPlayNextItem() } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index fc46bdd0..85d6ffa7 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -27,7 +27,7 @@ class VLCPlayerViewController: UIViewController { private var vlcMediaPlayer = VLCMediaPlayer() private var lastPlayerTicks: Int64 = 0 private var lastProgressReportTicks: Int64 = 0 - private var viewModelReactCancellables = Set() + private var viewModelListeners = Set() private var overlayDismissTimer: Timer? private var currentPlayerTicks: Int64 { @@ -42,14 +42,6 @@ class VLCPlayerViewController: UIViewController { return currentOverlayContentHostingController?.view.alpha ?? 0 > 0 } - private var jumpForwardLength: VideoPlayerJumpLength { - return Defaults[.videoPlayerJumpForward] - } - - private var jumpBackwardLength: VideoPlayerJumpLength { - return Defaults[.videoPlayerJumpBackward] - } - private lazy var videoContentView = makeVideoContentView() private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() @@ -170,7 +162,7 @@ class VLCPlayerViewController: UIViewController { private func makeJumpBackwardOverlayView() -> UIImageView { let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) + let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) let imageView = UIImageView(image: forwardSymbolImage) imageView.translatesAutoresizingMaskIntoConstraints = false @@ -179,7 +171,7 @@ class VLCPlayerViewController: UIViewController { private func makeJumpForwardOverlayView() -> UIImageView { let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) let imageView = UIImageView(image: forwardSymbolImage) imageView.translatesAutoresizingMaskIntoConstraints = false @@ -262,7 +254,6 @@ class VLCPlayerViewController: UIViewController { currentOverlayHostingController.view.removeFromSuperview() currentOverlayHostingController.removeFromParent() -// self.currentOverlayHostingController = nil } } @@ -346,7 +337,7 @@ extension VLCPlayerViewController { // Stop current media if there is one if vlcMediaPlayer.media != nil { - viewModelReactCancellables.forEach({ $0.cancel() }) + viewModelListeners.forEach({ $0.cancel() }) vlcMediaPlayer.stop() viewModel.sendStopReport() @@ -368,7 +359,7 @@ extension VLCPlayerViewController { newViewModel.getAdjacentEpisodes() newViewModel.playerOverlayDelegate = self - let startPercentage = viewModel.item.userData?.playedPercentage ?? 0 + let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 if startPercentage > 0 { newViewModel.sliderPercentage = startPercentage / 100 @@ -393,7 +384,7 @@ extension VLCPlayerViewController { private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { viewModel.$playbackSpeed.sink { newSpeed in self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelReactCancellables) + }.store(in: &viewModelListeners) viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in if sliderIsScrubbing { @@ -401,15 +392,19 @@ extension VLCPlayerViewController { } else { self.didEndScrubbing() } - }.store(in: &viewModelReactCancellables) + }.store(in: &viewModelListeners) viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelReactCancellables) + }.store(in: &viewModelListeners) viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelReactCancellables) + }.store(in: &viewModelListeners) + + viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in + self.didToggleSubtitles(newValue: newSubtitlesEnabled) + }.store(in: &viewModelListeners) } func setMediaPlayerTimeAtCurrentSlider() { @@ -554,8 +549,8 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { viewModel.playerState = vlcMediaPlayer.state if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoPlayNextItem && viewModel.shouldShowAutoPlayNextItem && viewModel.nextItemVideoPlayerViewModel != nil { - didSelectNextItem() + if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() } else { didSelectClose() } @@ -565,9 +560,8 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { // MARK: mediaPlayerTimeChanged func mediaPlayerTimeChanged(_ aNotification: Notification!) { - guard !viewModel.sliderIsScrubbing else { - lastPlayerTicks = currentPlayerTicks - return + if !viewModel.sliderIsScrubbing { + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) } viewModel.sliderPercentage = Double(vlcMediaPlayer.position) @@ -581,6 +575,9 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { // If needing to fix subtitle streams during playback if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && viewModel.subtitlesEnabled { didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + } + + if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) } @@ -606,12 +603,11 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { lastProgressReportTicks = currentPlayerTicks } + /// Do not call when setting to index -1 func didSelectSubtitleStream(index: Int) { - if viewModel.subtitlesEnabled { - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } + + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) viewModel.sendProgressReport() @@ -626,19 +622,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { dismiss(animated: true, completion: nil) } - func didSelectGoogleCast() { - print("didSelectCast") - } - - func didSelectAirplay() { - print("didSelectAirplay") - } - - func didSelectSubtitles() { - - viewModel.subtitlesEnabled = !viewModel.subtitlesEnabled - - if viewModel.subtitlesEnabled { + func didToggleSubtitles(newValue: Bool) { + if newValue { vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) } else { vlcMediaPlayer.currentVideoSubTitleIndex = -1 @@ -648,38 +633,41 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { // TODO: Implement properly in overlays func didSelectMenu() { stopOverlayDismissTimer() - - hideOverlay() - showOverlayContent() } // TODO: Implement properly in overlays func didDeselectMenu() { - + restartOverlayDismissTimer() } func didSelectBackward() { + flashJumpBackwardOverlay() - vlcMediaPlayer.jumpBackward(jumpBackwardLength.rawValue) + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - restartOverlayDismissTimer() + if displayingOverlay { + restartOverlayDismissTimer() + } viewModel.sendProgressReport() - self.lastProgressReportTicks = currentPlayerTicks + lastProgressReportTicks = currentPlayerTicks } func didSelectForward() { + flashJumpFowardOverlay() - vlcMediaPlayer.jumpForward(jumpForwardLength.rawValue) + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - restartOverlayDismissTimer() + if displayingOverlay { + restartOverlayDismissTimer() + } viewModel.sendProgressReport() - self.lastProgressReportTicks = currentPlayerTicks + lastProgressReportTicks = currentPlayerTicks } func didSelectMain() { @@ -691,8 +679,7 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { case .playing: viewModel.sendPauseReport(paused: true) vlcMediaPlayer.pause() - showOverlay() - restartOverlayDismissTimer(interval: 10) + restartOverlayDismissTimer(interval: 5) case .paused: viewModel.sendPauseReport(paused: false) vlcMediaPlayer.play() @@ -718,20 +705,20 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { viewModel.sendProgressReport() - self.lastProgressReportTicks = currentPlayerTicks + lastProgressReportTicks = currentPlayerTicks } - func didSelectPreviousItem() { - setupMediaPlayer(newViewModel: viewModel.previousItemVideoPlayerViewModel!) - startPlayback() + func didSelectPlayPreviousItem() { + if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) + startPlayback() + } } - func didSelectNextItem() { - setupMediaPlayer(newViewModel: viewModel.nextItemVideoPlayerViewModel!) - startPlayback() - } - - func didFocusOnButton() { - restartOverlayDismissTimer(interval: 8) + func didSelectPlayNextItem() { + if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) + startPlayback() + } } } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift index c7f6d8e8..4dd2f855 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift @@ -12,15 +12,16 @@ import SwiftUI struct SmallMediaStreamSelectionView: View { - @State var selectedItem: MediaStream? + @Binding var selectedItem: MediaStream? + private let title: String private var items: [MediaStream] private var selectedAction: (MediaStream) -> Void - init(items: [MediaStream], selectedItem: MediaStream? = nil, selectedAction: @escaping (MediaStream) -> Void) { - self.items = items - self.selectedItem = selectedItem - self.selectedAction = selectedAction - } +// init(items: [MediaStream], selectedItem: MediaStream?, selectedAction: @escaping (MediaStream) -> Void) { +// self.items = items +// self.selectedItem = selectedItem +// self.selectedAction = selectedAction +// } var body: some View { ZStack(alignment: .bottom) { @@ -35,7 +36,7 @@ struct SmallMediaStreamSelectionView: View { Spacer() HStack { - Text("Subtitles") + Text(title) .font(.title3) Spacer() } @@ -44,7 +45,7 @@ struct SmallMediaStreamSelectionView: View { HStack { ForEach(items, id: \.self) { item in Button { -// self.selectedItem = item + self.selectedAction(item) } label: { if item == selectedItem { Label(item.displayTitle ?? "No Title", systemImage: "checkmark") diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift index 1417b98f..0558eb28 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift @@ -59,31 +59,30 @@ struct tvOSOverlayContentView: View { } 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, + shouldShowAutoPlayNextItem: true) + static var previews: some View { ZStack { Color.red .ignoresSafeArea() - tvOSOverlayContentView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 720 * 10_000_000), - 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)], - defaultAudioStreamIndex: -1, - defaultSubtitleStreamIndex: -1, - playerState: .error, - shouldShowGoogleCast: false, - shouldShowAirplay: false, - subtitlesEnabled: true, - sliderPercentage: 0.432, - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - showAdjacentItems: true, - shouldShowAutoPlayNextItem: true, - autoPlayNextItem: true)) + tvOSOverlayContentView(viewModel: videoPlayerViewModel) } } } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift index ab3b8b76..9fab7783 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift @@ -55,16 +55,19 @@ struct tvOSVLCOverlay: View { Spacer() - if viewModel.showAdjacentItems { + + if viewModel.shouldShowPlayPreviousItem { SFSymbolButton(systemName: "chevron.left.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPreviousItem() + viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() }) .frame(maxWidth: 30, maxHeight: 30) .disabled(viewModel.previousItemVideoPlayerViewModel == nil) .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - + } + + if viewModel.shouldShowPlayNextItem { SFSymbolButton(systemName: "chevron.right.circle", action: { - viewModel.playerOverlayDelegate?.didSelectNextItem() + viewModel.playerOverlayDelegate?.didSelectPlayNextItem() }) .frame(maxWidth: 30, maxHeight: 30) .disabled(viewModel.nextItemVideoPlayerViewModel == nil) @@ -74,12 +77,12 @@ struct tvOSVLCOverlay: View { if !viewModel.subtitleStreams.isEmpty { if viewModel.subtitlesEnabled { SFSymbolButton(systemName: "captions.bubble.fill") { - viewModel.playerOverlayDelegate?.didSelectSubtitles() + viewModel.subtitlesEnabled.toggle() } .frame(maxWidth: 30, maxHeight: 30) } else { SFSymbolButton(systemName: "captions.bubble") { - viewModel.playerOverlayDelegate?.didSelectSubtitles() + viewModel.subtitlesEnabled.toggle() } .frame(maxWidth: 30, maxHeight: 30) } @@ -121,32 +124,30 @@ struct tvOSVLCOverlay: View { } struct tvOSVLCOverlay_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, + shouldShowAutoPlayNextItem: true) + static var previews: some View { ZStack { Color.red .ignoresSafeArea() - tvOSVLCOverlay(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 720 * 10_000_000), - 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)], - defaultAudioStreamIndex: -1, - defaultSubtitleStreamIndex: -1, - playerState: .error, - shouldShowGoogleCast: false, - shouldShowAirplay: false, - subtitlesEnabled: true, - sliderPercentage: 0.432, - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - showAdjacentItems: true, - shouldShowAutoPlayNextItem: true, - autoPlayNextItem: true)) + tvOSVLCOverlay(viewModel: videoPlayerViewModel) } - .previewInterfaceOrientation(.landscapeLeft) } } diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift index b578ecf1..5972123d 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift @@ -95,7 +95,7 @@ struct VLCPlayerOverlayView: View { if viewModel.autoplayEnabled { Image(systemName: "play.circle.fill") } else { - Image(systemName: "play.circle") + Image(systemName: "stop.circle") } } } diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 490e242e..caabf09c 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -266,7 +266,7 @@ class VLCPlayerViewController: UIViewController { view.addSubview(newJumpForwardImageView) NSLayoutConstraint.activate([ - newJumpForwardImageView.leftAnchor.constraint(equalTo: view.rightAnchor, constant: -150), + newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor) ]) diff --git a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift index 56c03fae..cffcb52f 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift @@ -19,7 +19,6 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { @Root var start = makeStart - @Default(.nativeVideoPlayer) var nativeVideoPlayer let viewModel: VideoPlayerViewModel init(viewModel: VideoPlayerViewModel) { @@ -27,9 +26,6 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { } @ViewBuilder func makeStart() -> some View { -// NativePlayerView(viewModel: viewModel) -// .navigationBarHidden(true) -// .ignoresSafeArea() VLCPlayerView(viewModel: viewModel) .navigationBarHidden(true) .ignoresSafeArea() From a9d37033e6f06744b7bea5d1ef1aeee8ae765847 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sun, 2 Jan 2022 21:48:18 -0700 Subject: [PATCH 41/62] auto play on tvOS --- .../tvOSOverlay/tvOSOverlayContent.swift | 2 +- .../VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift | 15 +++++++++++++-- .../Views/VideoPlayer/VLCPlayerOverlayView.swift | 4 ++-- .../BaseItemDto+VideoPlayerViewModel.swift | 2 +- Shared/ViewModels/VideoPlayerViewModel.swift | 8 +++----- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift index 0558eb28..cda78ad1 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift @@ -75,7 +75,7 @@ struct tvOSOverlayContentView_Previews: PreviewProvider { overlayType: .compact, shouldShowPlayPreviousItem: true, shouldShowPlayNextItem: true, - shouldShowAutoPlayNextItem: true) + shouldShowAutoPlay: true) static var previews: some View { ZStack { diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift index 9fab7783..67d1cec5 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift @@ -55,7 +55,6 @@ struct tvOSVLCOverlay: View { Spacer() - if viewModel.shouldShowPlayPreviousItem { SFSymbolButton(systemName: "chevron.left.circle", action: { viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() @@ -74,6 +73,18 @@ struct tvOSVLCOverlay: View { .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) } + if viewModel.shouldShowAutoPlay { + Button { + viewModel.autoplayEnabled.toggle() + } label: { + if viewModel.autoplayEnabled { + Image(systemName: "play.circle.fill") + } else { + Image(systemName: "stop.circle") + } + } + } + if !viewModel.subtitleStreams.isEmpty { if viewModel.subtitlesEnabled { SFSymbolButton(systemName: "captions.bubble.fill") { @@ -140,7 +151,7 @@ struct tvOSVLCOverlay_Previews: PreviewProvider { overlayType: .compact, shouldShowPlayPreviousItem: true, shouldShowPlayNextItem: true, - shouldShowAutoPlayNextItem: true) + shouldShowAutoPlay: true) static var previews: some View { ZStack { diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift index 5972123d..c73e3ee2 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift @@ -88,7 +88,7 @@ struct VLCPlayerOverlayView: View { .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) } - if viewModel.shouldShowAutoPlayNextItem { + if viewModel.shouldShowAutoPlay { Button { viewModel.autoplayEnabled.toggle() } label: { @@ -337,7 +337,7 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { overlayType: .compact, shouldShowPlayPreviousItem: true, shouldShowPlayNextItem: true, - shouldShowAutoPlayNextItem: true) + shouldShowAutoPlay: true) static var previews: some View { ZStack { diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index bbed415e..c0214167 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -116,7 +116,7 @@ extension BaseItemDto { overlayType: overlayType, shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, shouldShowPlayNextItem: shouldShowPlayNextItem, - shouldShowAutoPlayNextItem: shouldShowAutoPlay) + shouldShowAutoPlay: shouldShowAutoPlay) return videoPlayerViewModel }) diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 79ec3195..ac86a054 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -53,7 +53,7 @@ final class VideoPlayerViewModel: ViewModel { let shouldShowPlayPreviousItem: Bool let shouldShowPlayNextItem: Bool - let shouldShowAutoPlayNextItem: Bool + let shouldShowAutoPlay: Bool // MARK: General let item: BaseItemDto @@ -104,9 +104,7 @@ final class VideoPlayerViewModel: ViewModel { overlayType: OverlayType, shouldShowPlayPreviousItem: Bool, shouldShowPlayNextItem: Bool, - shouldShowAutoPlayNextItem: Bool - - ) { + shouldShowAutoPlay: Bool) { self.item = item self.title = title self.subtitle = subtitle @@ -122,7 +120,7 @@ final class VideoPlayerViewModel: ViewModel { self.overlayType = overlayType self.shouldShowPlayPreviousItem = shouldShowPlayPreviousItem self.shouldShowPlayNextItem = shouldShowPlayNextItem - self.shouldShowAutoPlayNextItem = shouldShowAutoPlayNextItem + self.shouldShowAutoPlay = shouldShowAutoPlay self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward] self.jumpForwardLength = Defaults[.videoPlayerJumpForward] From 40b6e5c680a9eca28d1851af85abaa12e97d8251 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 3 Jan 2022 11:48:35 -0700 Subject: [PATCH 42/62] work on tvos subtitle overlay --- .../VideoPlayer/VLCPlayerViewController.swift | 32 ++++++++++++------- .../tvOSOverlay/SmallMenuOverlay.swift | 24 ++++++-------- .../tvOSOverlay/tvOSOverlayContent.swift | 8 +++++ .../tvOSOverlay/tvOSVLCOverlay.swift | 16 ++++++---- 4 files changed, 47 insertions(+), 33 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 85d6ffa7..50da72f6 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -46,7 +46,7 @@ class VLCPlayerViewController: UIViewController { private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() private var currentOverlayHostingController: UIHostingController? - private var currentOverlayContentHostingController: UIHostingController? + private var currentOverlayContentHostingController: UIHostingController? // MARK: init @@ -195,12 +195,21 @@ class VLCPlayerViewController: UIViewController { case .select: didGenerallyTap() case .upArrow: + if displayingContentOverlay { + hideOverlayContent() + + showOverlay() + restartOverlayDismissTimer() + } + print("Up arrow") case .downArrow: - stopOverlayDismissTimer() + if !displayingContentOverlay { + stopOverlayDismissTimer() - hideOverlay() - showOverlayContent() + hideOverlay() + showOverlayContent() + } case .leftArrow: didSelectBackward() print("Left arrow") @@ -292,13 +301,14 @@ class VLCPlayerViewController: UIViewController { currentOverlayContentHostingController.removeFromParent() } -// let newSmallMenuOverlayView = SmallMediaStreamSelectionView(items: viewModel.subtitleStreams, -// selectedItem: viewModel.subtitleStreams.first(where: { $0.index == viewModel.selectedSubtitleStreamIndex })) { selectedMediaStream in -// self.didSelectSubtitleStream(index: selectedMediaStream.index ?? -1) -// } -// let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) - let newOverlayContentView = tvOSOverlayContentView(viewModel: viewModel) - let newOverlayContentHostingController = UIHostingController(rootView: newOverlayContentView) + let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel, + title: "Subtitles", + items: viewModel.subtitleStreams) { selectedMediaStream in + self.didSelectSubtitleStream(index: selectedMediaStream.index ?? -1) + } + let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) +// let newOverlayContentView = tvOSOverlayContentView(viewModel: viewModel) +// let newOverlayContentHostingController = UIHostingController(rootView: newOverlayContentView) newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false newOverlayContentHostingController.view.backgroundColor = UIColor.clear diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift index 4dd2f855..31c676cc 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift @@ -12,20 +12,14 @@ import SwiftUI struct SmallMediaStreamSelectionView: View { - @Binding var selectedItem: MediaStream? - private let title: String - private var items: [MediaStream] - private var selectedAction: (MediaStream) -> Void - -// init(items: [MediaStream], selectedItem: MediaStream?, selectedAction: @escaping (MediaStream) -> Void) { -// self.items = items -// self.selectedItem = selectedItem -// self.selectedAction = selectedAction -// } + @ObservedObject var viewModel: VideoPlayerViewModel + let title: String + var items: [MediaStream] + var selectedAction: (MediaStream) -> Void var body: some View { ZStack(alignment: .bottom) { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]), + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.9)]), startPoint: .top, endPoint: .bottom) .ignoresSafeArea() @@ -36,8 +30,8 @@ struct SmallMediaStreamSelectionView: View { Spacer() HStack { - Text(title) - .font(.title3) + Text("Subtitles") + Spacer() } @@ -45,9 +39,9 @@ struct SmallMediaStreamSelectionView: View { HStack { ForEach(items, id: \.self) { item in Button { - self.selectedAction(item) + viewModel.playerOverlayDelegate?.didSelectSubtitleStream(index: item.index ?? -1) } label: { - if item == selectedItem { + if item.index ?? -1 == viewModel.selectedSubtitleStreamIndex { Label(item.displayTitle ?? "No Title", systemImage: "checkmark") } else { Text(item.displayTitle ?? "No Title") diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift index cda78ad1..851abf1b 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSOverlayContent.swift @@ -27,24 +27,32 @@ struct tvOSOverlayContentView: View { } 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) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift index 67d1cec5..d3e56743 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift @@ -74,14 +74,16 @@ struct tvOSVLCOverlay: View { } if viewModel.shouldShowAutoPlay { - Button { - viewModel.autoplayEnabled.toggle() - } label: { - if viewModel.autoplayEnabled { - Image(systemName: "play.circle.fill") - } else { - Image(systemName: "stop.circle") + if viewModel.autoplayEnabled { + SFSymbolButton(systemName: "play.circle.fill") { + viewModel.autoplayEnabled.toggle() } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "stop.circle") { + viewModel.autoplayEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) } } From 3eb92cd32548fc141bfa3389e404ea84f35c544d Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 3 Jan 2022 18:38:50 -0700 Subject: [PATCH 43/62] cinematic views for tvOS and more final work --- .../CinematicEpisodeItemView.swift | 81 ++++++++++ .../CinematicItemAboutView.swift | 42 +++++ .../CinematicItemViewTopRow.swift | 126 +++++++++++++++ .../CinematicItemViewTopRowButton.swift | 45 ++++++ .../CinematicMovieItemView.swift | 79 ++++++++++ .../Views/ItemView/EpisodeItemView.swift | 11 +- .../Views/ItemView/EpisodesRowView.swift | 73 +++++++++ .../Views/ItemView/ItemDetailsView.swift | 136 ++++++++++++++++ .../Views/ItemView/ItemView.swift | 4 +- .../Views/ItemView/MovieItemView.swift | 10 +- .../Views/ItemView/PortraitItemsRowView.swift | 53 +++++++ JellyfinPlayer.xcodeproj/project.pbxproj | 44 ++++++ .../Components/EpisodeCardVStackView.swift | 10 +- .../Landscape/ItemLandscapeTopBarView.swift | 12 +- .../ItemPortraitHeaderOverlayView.swift | 12 +- .../Views/OverlaySettingsView.swift | 35 +++++ JellyfinPlayer/Views/SettingsView.swift | 69 ++++---- .../VideoPlayer/VLCPlayerOverlayView.swift | 147 ++++++++++++------ .../VideoPlayer/VLCPlayerViewController.swift | 15 +- Shared/Coordinators/SettingsCoordinator.swift | 8 +- .../BaseItemDto+VideoPlayerViewModel.swift | 2 +- .../BaseItemDtoExtensions.swift | 4 +- Shared/Objects/OverlayType.swift | 10 +- .../SwiftfinStore/SwiftfinStoreDefaults.swift | 3 +- Shared/ViewModels/EpisodeItemViewModel.swift | 22 +++ Shared/ViewModels/SettingsViewModel.swift | 8 +- Shared/ViewModels/VideoPlayerViewModel.swift | 17 +- 27 files changed, 956 insertions(+), 122 deletions(-) create mode 100644 JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift create mode 100644 JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift create mode 100644 JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift create mode 100644 JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift create mode 100644 JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift create mode 100644 JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift create mode 100644 JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift create mode 100644 JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift create mode 100644 JellyfinPlayer/Views/OverlaySettingsView.swift diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift new file mode 100644 index 00000000..f304cdc6 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift @@ -0,0 +1,81 @@ +// + /* + * 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 Introspect +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() + + ScrollView { + VStack(spacing: 0) { + + Spacer(minLength: verticalScrollViewOffset) + + CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView) + .focusSection() + + ZStack(alignment: .topLeading) { + + Color.black.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 20) { + + CinematicItemAboutView(viewModel: viewModel) + + EpisodesRowView(viewModel: viewModel) + .focusSection() + + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView(rowTitle: "Recommended", items: viewModel.similarItems) + } + + ItemDetailsView(viewModel: viewModel) + +// HStack { +// SFSymbolButton(systemName: "heart.fill", pointSize: 48, action: {}) +// .frame(width: 60, height: 60) +// SFSymbolButton(systemName: "checkmark.circle", pointSize: 48, action: {}) +// .frame(width: 60, height: 60) +// } +// .padding(.horizontal, 50) + } + .padding(.top, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } +} diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift new file mode 100644 index 00000000..6e8cb1ed --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift @@ -0,0 +1,42 @@ +// + /* + * 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 SwiftUI + +struct CinematicItemAboutView: View { + + @ObservedObject var viewModel: ItemViewModel + @FocusState private var focused: Bool + + var body: some View { + HStack(alignment: .top, spacing: 10) { + ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 230)) + .frame(width: 230, height: 380) + .cornerRadius(10) + + ZStack(alignment: .topLeading) { + Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) + .cornerRadius(30) + .frame(height: 380) + + VStack(alignment: .leading) { + Text("About") + .font(.title3) + + Text(viewModel.item.overview ?? "No details available") + .padding(.top, 2) + } + .padding() + } + } + .focusable() + .focused($focused) + .padding(.horizontal, 50) + } +} diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift new file mode 100644 index 00000000..a34fb698 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift @@ -0,0 +1,126 @@ +// + /* + * 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 SwiftUI + +struct CinematicItemViewTopRow: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router + @ObservedObject var viewModel: ItemViewModel + @Environment(\.isFocused) var envFocused: Bool + @State var focused: Bool = false + @State var wrappedScrollView: UIScrollView? + + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 210) + + HStack(alignment: .bottom) { + VStack(alignment: .leading) { + HStack(alignment: .PlayInformationAlignmentGuide) { + CinematicItemViewTopRowButton(wrappedScrollView: wrappedScrollView) { + Button { + itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) + } label: { + 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)") + } + + 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() + } + .padding(.horizontal, 50) + .padding(.bottom, 50) + } + .onChange(of: envFocused) { envFocus in + if envFocus == true { + wrappedScrollView?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + wrappedScrollView?.scrollToTop() + } + } + + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + } +} + +extension HorizontalAlignment { + + private struct TitleSubtitleAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[HorizontalAlignment.leading] + } + } + + static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(TitleSubtitleAlignment.self) +} + +extension VerticalAlignment { + + private struct PlayInformationAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[VerticalAlignment.bottom] + } + } + + static let PlayInformationAlignmentGuide = VerticalAlignment(PlayInformationAlignment.self) +} diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift new file mode 100644 index 00000000..8549d1fa --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift @@ -0,0 +1,45 @@ +// + /* + * 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 SwiftUI + +struct CinematicItemViewTopRowButton: View { + @Environment(\.isFocused) var envFocused: Bool + @State var focused: Bool = false + @State var wrappedScrollView: UIScrollView? + var content: () -> Content + + @FocusState private var buttonFocused: Bool + + var body: some View { + content() + .focused($buttonFocused) + .onChange(of: envFocused) { envFocus in + if envFocus == true { + wrappedScrollView?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + wrappedScrollView?.scrollToTop() + } + } + + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + .onChange(of: buttonFocused) { newValue in + if newValue { + wrappedScrollView?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + wrappedScrollView?.scrollToTop() + } + print("Scroll to top") + } + } + } +} diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift new file mode 100644 index 00000000..415fa865 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift @@ -0,0 +1,79 @@ +// + /* + * 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 Introspect +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() + + ScrollView { + VStack(spacing: 0) { + + Spacer(minLength: verticalScrollViewOffset) + + CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView) + .focusSection() + + ZStack(alignment: .topLeading) { + + Color.black.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 20) { + + CinematicItemAboutView(viewModel: viewModel) + + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView(rowTitle: "Recommended", items: viewModel.similarItems) + } + + ItemDetailsView(viewModel: viewModel) + +// HStack { +// SFSymbolButton(systemName: "heart.fill", pointSize: 48, action: {}) +// .frame(width: 60, height: 60) +// SFSymbolButton(systemName: "checkmark.circle", pointSize: 48, action: {}) +// .frame(width: 60, height: 60) +// } +// .padding(.horizontal, 50) + } + .padding(.top, 50) + + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } +} diff --git a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift index b1caaee7..a1033cc7 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift @@ -59,10 +59,13 @@ struct EpisodeItemView: View { .foregroundColor(.secondary) .lineLimit(1) } - Text(viewModel.item.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + if viewModel.item.officialRating != nil { Text(viewModel.item.officialRating!).font(.subheadline) .fontWeight(.semibold) diff --git a/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift b/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift new file mode 100644 index 00000000..e0731189 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift @@ -0,0 +1,73 @@ +// + /* + * 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 EpisodesRowView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router + @ObservedObject var viewModel: EpisodeItemViewModel + + var body: some View { + VStack(alignment: .leading) { + + Text("Episodes") + .font(.title3) + .padding(.horizontal, 50) + + ScrollView(.horizontal) { + ScrollViewReader { reader in + HStack(alignment: .top) { + ForEach(viewModel.seasonEpisodes, id:\.self) { episode in + Button { + itemRouter.route(to: \.item, episode) + } label: { + HStack(alignment: .top) { + VStack(alignment: .leading) { + + ImageView(src: episode.getBackdropImage(maxWidth: 445), + bh: episode.getBackdropImageBlurHash()) + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) + + VStack(alignment: .leading) { + Text(episode.getEpisodeLocator() ?? "") + .font(.caption) + .foregroundColor(.secondary) + Text(episode.name ?? "") + .font(.footnote) + .padding(.bottom, 1) + Text(episode.overview ?? "") + .font(.caption) + .fontWeight(.light) + .lineLimit(4) + } + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + } + } + .buttonStyle(PlainButtonStyle()) + .id(episode.name) + } + } + .padding(.horizontal, 50) + .padding(.vertical) + .onAppear { + reader.scrollTo(viewModel.item.name) + } + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } +} diff --git a/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift b/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift new file mode 100644 index 00000000..6d3afa1a --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift @@ -0,0 +1,136 @@ +// + /* + * 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 SwiftUI + +struct ItemDetailsView: View { + + @ObservedObject var viewModel: ItemViewModel + private let detailItems: [(String, String)] + private let mediaItems: [(String, String)] + @FocusState private var focused: Bool + + init(viewModel: ItemViewModel) { + self.viewModel = viewModel + + var initialDetailItems: [(String, String)] = [] + + if let productionYear = viewModel.item.productionYear { + initialDetailItems.append(("Released", "\(productionYear)")) + } + + if let rating = viewModel.item.officialRating { + initialDetailItems.append(("Rated", "\(rating)")) + } + + if let runtime = viewModel.item.getItemRuntime() { + initialDetailItems.append(("Runtime", "\(runtime)")) + } + + var initialMediatems: [(String, String)] = [] + + if let container = viewModel.item.container { + let containerList = container.split(separator: ",") + if containerList.count > 1 { + initialMediatems.append(("Containers", containerList.joined(separator: ", "))) + } else { + initialMediatems.append(("Container", containerList.joined(separator: ", "))) + } + } + + if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel { + + if !itemVideoPlayerViewModel.audioStreams.isEmpty { + let audioList = itemVideoPlayerViewModel.audioStreams.compactMap({ $0.displayTitle }).joined(separator: ", ") + initialMediatems.append(("Audio", audioList)) + } + + if !itemVideoPlayerViewModel.subtitleStreams.isEmpty { + let subtitlesList = itemVideoPlayerViewModel.subtitleStreams.compactMap({ $0.displayTitle }).joined(separator: ", ") + initialMediatems.append(("Subtitles", subtitlesList)) + } + } + + detailItems = initialDetailItems + mediaItems = initialMediatems + } + + var body: some View { + + ZStack(alignment: .leading) { + + Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) + .cornerRadius(30, corners: [.topLeft, .topRight]) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 20) { + Text("Details") + .font(.title3) + .padding(.bottom, 5) + + ForEach(detailItems, id: \.self.0) { (title, content) in + ItemDetail(title: title, content: content) + } + } + + Spacer() + + VStack(alignment: .leading, spacing: 20) { + Text("Media") + .font(.title3) + .padding(.bottom, 5) + + ForEach(mediaItems, id: \.self.0) { (title, content) in + ItemDetail(title: title, content: content) + } + } + + Spacer() + } + .ignoresSafeArea() + .focusable() + .focused($focused) + .padding(.horizontal, 50) + .padding(.bottom, 50) + } + } +} + +fileprivate struct ItemDetail: View { + + let title: String + let content: String + + var body: some View { + VStack(alignment: .leading) { + Text(title) + .font(.body) + Text(content) + .font(.footnote) + .foregroundColor(.secondary) + } + } +} + +struct RoundedCorner: Shape { + + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape( RoundedCorner(radius: radius, corners: corners) ) + } +} diff --git a/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift index 32e707ac..be61887f 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift @@ -32,13 +32,13 @@ struct ItemView: View { var body: some View { Group { if item.type == "Movie" { - MovieItemView(viewModel: .init(item: item)) + CinematicMovieItemView(viewModel: MovieItemViewModel(item: item)) } else if item.type == "Series" { SeriesItemView(viewModel: .init(item: item)) } else if item.type == "Season" { SeasonItemView(viewModel: .init(item: item)) } else if item.type == "Episode" { - EpisodeItemView(viewModel: .init(item: item)) + CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) } else { Text(L10n.notImplementedYetWithType(item.type ?? "")) } diff --git a/JellyfinPlayer tvOS/Views/ItemView/MovieItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/MovieItemView.swift index 378c40da..32afd5e3 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/MovieItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/MovieItemView.swift @@ -59,10 +59,12 @@ struct MovieItemView: View { .foregroundColor(.secondary) .lineLimit(1) } - Text(viewModel.item.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } if viewModel.item.officialRating != nil { Text(viewModel.item.officialRating!).font(.subheadline) .fontWeight(.semibold) diff --git a/JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift b/JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift new file mode 100644 index 00000000..53a3b7e9 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift @@ -0,0 +1,53 @@ +// + /* + * 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 SwiftUI +import JellyfinAPI + +struct PortraitItemsRowView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router + + let rowTitle: String + let items: [BaseItemDto] + + var body: some View { + VStack(alignment: .leading) { + + Text(rowTitle) + .font(.title3) + .padding(.horizontal, 50) + + ScrollView(.horizontal) { + HStack(alignment: .top) { + ForEach(items, id: \.self) { item in + + VStack(spacing: 15) { + Button { + itemRouter.route(to: \.item, item) + } label: { + ImageView(src: item.portraitHeaderViewURL(maxWidth: 200)) + .frame(width: 200, height: 334) + } + .frame(height: 334) + .buttonStyle(PlainButtonStyle()) + + Text(item.title) + .lineLimit(2) + .frame(width: 200) + } + } + } + .padding(.horizontal, 50) + .padding(.vertical) + } + .edgesIgnoringSafeArea(.horizontal) + } + } +} diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 522bee82..de8e65bf 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -374,6 +374,15 @@ E1D4BF8D2719F3A300A11E64 /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */; }; E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */; }; + E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */; }; + E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */; }; + E1E5D53B2783A80900692DFE /* CinematicItemViewTopRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */; }; + E1E5D53E2783B05200692DFE /* CinematicMovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */; }; + E1E5D5402783B0C000692DFE /* CinematicItemViewTopRowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */; }; + E1E5D5422783B33900692DFE /* PortraitItemsRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */; }; + E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */; }; + E1E5D5462783C28100692DFE /* CinematicItemAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */; }; + E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5472783CCF900692DFE /* OverlaySettingsView.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 */; }; @@ -648,6 +657,15 @@ E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsCoordinator.swift; sourceTree = ""; }; E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshHelper.swift; sourceTree = ""; }; + E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicEpisodeItemView.swift; sourceTree = ""; }; + E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = ""; }; + E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemViewTopRow.swift; sourceTree = ""; }; + E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicMovieItemView.swift; sourceTree = ""; }; + E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemViewTopRowButton.swift; sourceTree = ""; }; + E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemsRowView.swift; sourceTree = ""; }; + E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsView.swift; sourceTree = ""; }; + E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemAboutView.swift; sourceTree = ""; }; + E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.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 = ""; }; @@ -1277,6 +1295,7 @@ 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, 53892771263C8C6F0035E14B /* LoadingView.swift */, 5389276F263C25230035E14B /* NextUpView.swift */, + E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, E13DD3E427177D15009D4DAF /* ServerListView.swift */, 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, @@ -1361,9 +1380,13 @@ E193D54E271942C000900D82 /* ItemView */ = { isa = PBXGroup; children = ( + E1E5D53C2783A85F00692DFE /* CinematicItemView */, 53272538268C20100035FBF1 /* EpisodeItemView.swift */, + E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */, + E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */, 53CD2A3F268A49C2002ABD4E /* ItemView.swift */, 53CD2A41268A4B38002ABD4E /* MovieItemView.swift */, + E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */, 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */, 53116A16268B919A003024C9 /* SeriesItemView.swift */, ); @@ -1414,6 +1437,18 @@ path = Objects; sourceTree = ""; }; + E1E5D53C2783A85F00692DFE /* CinematicItemView */ = { + isa = PBXGroup; + children = ( + E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */, + E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */, + E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */, + E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */, + E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */, + ); + path = CinematicItemView; + sourceTree = ""; + }; E1FCD08E26C466F3007C8DCF /* Errors */ = { isa = PBXGroup; children = ( @@ -1838,6 +1873,7 @@ files = ( E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, + E1E5D53E2783B05200692DFE /* CinematicMovieItemView.swift in Sources */, E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, 6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */, @@ -1849,10 +1885,12 @@ 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, + E1E5D53B2783A80900692DFE /* CinematicItemViewTopRow.swift in Sources */, E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */, E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, 62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */, + E1E5D5462783C28100692DFE /* CinematicItemAboutView.swift in Sources */, E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, 53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */, @@ -1866,6 +1904,7 @@ E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */, 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 */, @@ -1873,6 +1912,7 @@ E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, 62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */, + E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */, E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */, E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */, @@ -1917,6 +1957,7 @@ E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */, E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */, 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */, + E1E5D5402783B0C000692DFE /* CinematicItemViewTopRowButton.swift in Sources */, 5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */, 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, @@ -1950,6 +1991,8 @@ E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, + E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */, + E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */, 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, @@ -2015,6 +2058,7 @@ E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, 625CB56F2678C23300530A6E /* HomeView.swift in Sources */, E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, + E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */, 53892770263C25230035E14B /* NextUpView.swift in Sources */, E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, diff --git a/JellyfinPlayer/Components/EpisodeCardVStackView.swift b/JellyfinPlayer/Components/EpisodeCardVStackView.swift index aee29489..fafad2ab 100644 --- a/JellyfinPlayer/Components/EpisodeCardVStackView.swift +++ b/JellyfinPlayer/Components/EpisodeCardVStackView.swift @@ -81,10 +81,12 @@ struct EpisodeCardVStackView: View { .fontWeight(.medium) .foregroundColor(.secondary) - Text(item.getItemRuntime()) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) + if let runtime = item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + } Spacer() } diff --git a/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift b/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift index c0626b1c..f94aa923 100644 --- a/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift +++ b/JellyfinPlayer/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift @@ -28,11 +28,13 @@ struct ItemLandscapeTopBarView: View { if viewModel.item.itemType.showDetails { // MARK: Runtime - Text(viewModel.item.getItemRuntime()) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .padding(.leading, 16) + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .padding(.leading, 16) + } } // MARK: Details diff --git a/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift b/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift index 98053a5a..96c24e1f 100644 --- a/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift +++ b/JellyfinPlayer/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift @@ -38,11 +38,13 @@ struct PortraitHeaderOverlayView: View { if viewModel.item.itemType.showDetails { // MARK: Runtime if viewModel.shouldDisplayRuntime() { - Text(viewModel.item.getItemRuntime()) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } } } diff --git a/JellyfinPlayer/Views/OverlaySettingsView.swift b/JellyfinPlayer/Views/OverlaySettingsView.swift new file mode 100644 index 00000000..f9cc687f --- /dev/null +++ b/JellyfinPlayer/Views/OverlaySettingsView.swift @@ -0,0 +1,35 @@ +// + /* + * 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(.overlayType) var overlayType + @Default(.shouldShowPlayPreviousItem) var shouldShowPlayPreviousItem + @Default(.shouldShowPlayNextItem) var shouldShowPlayNextItem + @Default(.shouldShowAutoPlay) var shouldShowAutoPlay + + var body: some View { + Form { + Section(header: Text("Overlay")) { + Picker("Overlay Type", selection: $overlayType) { + ForEach(OverlayType.allCases, id: \.self) { overlay in + Text(overlay.label).tag(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/Views/SettingsView.swift b/JellyfinPlayer/Views/SettingsView.swift index 6f4ec697..501d2c0f 100644 --- a/JellyfinPlayer/Views/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView.swift @@ -21,54 +21,32 @@ struct SettingsView: View { @Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode @Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode @Default(.appAppearance) var appAppearance + @Default(.overlayType) var overlayType @Default(.videoPlayerJumpForward) var jumpForwardLength @Default(.videoPlayerJumpBackward) var jumpBackwardLength + @Default(.jumpGesturesEnabled) var jumpGesturesEnabled var body: some View { Form { Section(header: EmptyView()) { + HStack { + Text("User") + Spacer() + Text(viewModel.user.username) + .foregroundColor(.jellyfinPurple) + } - // There is a bug where the SettingsView attmempts to remake itself upon signing out - // so this check is made - if SessionManager.main.currentLogin == nil { + Button { + settingsRouter.route(to: \.serverDetail) + } label: { HStack { - Text("User") + Text("Server") + .foregroundColor(.white) Spacer() - Text("") + Text(viewModel.server.name) .foregroundColor(.jellyfinPurple) - } - Button { - settingsRouter.route(to: \.serverDetail) - } label: { - HStack { - Text("Server") - Spacer() - Text("") - .foregroundColor(.jellyfinPurple) - - Image(systemName: "chevron.right") - } - } - } else { - HStack { - Text("User") - Spacer() - Text(SessionManager.main.currentLogin.user.username) - .foregroundColor(.jellyfinPurple) - } - - Button { - settingsRouter.route(to: \.serverDetail) - } label: { - HStack { - Text("Server") - Spacer() - Text(SessionManager.main.currentLogin.server.name) - .foregroundColor(.jellyfinPurple) - - Image(systemName: "chevron.right") - } + Image(systemName: "chevron.right") } } @@ -77,7 +55,7 @@ struct SettingsView: View { SessionManager.main.logout() } } label: { - Text("Sign out") + Text("Switch User") .font(.callout) } } @@ -108,6 +86,21 @@ struct SettingsView: View { Text(length.label).tag(length.rawValue) } } + + Toggle("Jump Gestures Enabled", isOn: $jumpGesturesEnabled) + + Button { + settingsRouter.route(to: \.overlaySettings) + } label: { + HStack { + Text("Overlay") + .foregroundColor(.white) + Spacer() + Text(overlayType.label) + + Image(systemName: "chevron.right") + } + } } Section(header: L10n.accessibility.text) { diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift index c73e3ee2..eea32862 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift @@ -19,15 +19,29 @@ struct VLCPlayerOverlayView: View { @ViewBuilder private var mainButtonView: some View { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.fill") - .font(.system(size: 28, weight: .heavy, design: .default)) - case .playing: - Image(systemName: "pause") - .font(.system(size: 28, weight: .heavy, design: .default)) - default: - ProgressView() + if viewModel.overlayType == .normal { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.fill") + .font(.system(size: 56, weight: .semibold, design: .default)) + case .playing: + Image(systemName: "pause") + .font(.system(size: 56, weight: .semibold, design: .default)) + default: + ProgressView() + .scaleEffect(2) + } + } else if viewModel.overlayType == .compact { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.fill") + .font(.system(size: 28, weight: .heavy, design: .default)) + case .playing: + Image(systemName: "pause") + .font(.system(size: 28, weight: .heavy, design: .default)) + default: + ProgressView() + } } } @@ -38,11 +52,13 @@ struct VLCPlayerOverlayView: View { // MARK: Top Bar ZStack { - LinearGradient(gradient: Gradient(colors: [.black.opacity(0.7), .clear]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 80) + if viewModel.overlayType == .compact { + LinearGradient(gradient: Gradient(colors: [.black.opacity(0.7), .clear]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 80) + } VStack(alignment: .EpisodeSeriesAlignmentGuide) { @@ -236,44 +252,71 @@ struct VLCPlayerOverlayView: View { Spacer() + if viewModel.overlayType == .normal { + HStack(spacing: 80) { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) + } + + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + } + .frame(width: 200) + + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) + } + } + .font(.system(size: 48)) + } Spacer() // MARK: Bottom Bar ZStack { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 70) + if viewModel.overlayType == .compact { + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 70) + } HStack { - HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectBackward() - } label: { - Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) - .padding(.horizontal, 5) - } - - Button { - viewModel.playerOverlayDelegate?.didSelectMain() - } label: { - mainButtonView - .frame(minWidth: 30, maxWidth: 30) - .padding(.horizontal, 10) - } - - Button { - viewModel.playerOverlayDelegate?.didSelectForward() - } label: { - Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) - .padding(.horizontal, 5) + if viewModel.overlayType == .compact { + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) + .padding(.horizontal, 5) + } + + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + .frame(minWidth: 30, maxWidth: 30) + .padding(.horizontal, 10) + } + + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) + .padding(.horizontal, 5) + } } + .font(.system(size: 24, weight: .semibold, design: .default)) } - .font(.system(size: 24, weight: .semibold, design: .default)) Text(viewModel.leftLabelText) .font(.system(size: 18, weight: .semibold, design: .default)) @@ -296,6 +339,7 @@ struct VLCPlayerOverlayView: View { thumbInteractiveSize: CGSize.Circle(radius: 40), options: .defaultOptions) ) + .frame(maxHeight: 50) Text(viewModel.rightLabelText) .font(.system(size: 18, weight: .semibold, design: .default)) @@ -312,11 +356,22 @@ struct VLCPlayerOverlayView: View { } var body: some View { - mainBody - .contentShape(Rectangle()) - .onTapGesture { - viewModel.playerOverlayDelegate?.didGenerallyTap() - } + if viewModel.overlayType == .normal { + mainBody + .background { + Color(uiColor: .black.withAlphaComponent(0.5)) + .ignoresSafeArea() + .onTapGesture { + viewModel.playerOverlayDelegate?.didGenerallyTap() + } + } + } else { + mainBody + .contentShape(Rectangle()) + .onTapGesture { + viewModel.playerOverlayDelegate?.didGenerallyTap() + } + } } } diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index caabf09c..5a6a1d74 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -104,6 +104,8 @@ class VLCPlayerViewController: UIViewController { // they aren't unnecessarily set more than once vlcMediaPlayer.delegate = self vlcMediaPlayer.drawable = videoContentView + + // TODO: Custom subtitle sizes vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) setupMediaPlayer(newViewModel: viewModel) @@ -152,15 +154,20 @@ class VLCPlayerViewController: UIViewController { view.translatesAutoresizingMaskIntoConstraints = false let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) rightSwipeGesture.direction = .right + let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) leftSwipeGesture.direction = .left view.addGestureRecognizer(singleTapGesture) - view.addGestureRecognizer(rightSwipeGesture) - view.addGestureRecognizer(leftSwipeGesture) + if viewModel.jumpGesturesEnabled { + view.addGestureRecognizer(rightSwipeGesture) + view.addGestureRecognizer(leftSwipeGesture) + } + return view } @@ -420,7 +427,7 @@ extension VLCPlayerViewController { extension VLCPlayerViewController { private func flashJumpBackwardOverlay() { - guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } currentJumpBackwardOverlayView.layer.removeAllAnimations() @@ -440,7 +447,7 @@ extension VLCPlayerViewController { } private func flashJumpFowardOverlay() { - guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } currentJumpForwardOverlayView.layer.removeAllAnimations() diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index c0664d7e..0b7da98e 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -17,13 +17,19 @@ final class SettingsCoordinator: NavigationCoordinatable { @Root var start = makeStart @Route(.push) var serverDetail = makeServerDetail + @Route(.push) var overlaySettings = makeOverlaySettings @ViewBuilder func makeServerDetail() -> some View { let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server) ServerDetailView(viewModel: viewModel) } + + @ViewBuilder func makeOverlaySettings() -> some View { + OverlaySettingsView() + } @ViewBuilder func makeStart() -> some View { - SettingsView(viewModel: .init()) + let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user) + SettingsView(viewModel: viewModel) } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index c0214167..6068f539 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -91,7 +91,7 @@ extension BaseItemDto { } } - let subtitlesEnabled = Defaults[.subtitlesEnabledIfDefault] && defaultSubtitleStream != nil + let subtitlesEnabled = defaultSubtitleStream != nil let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 2f27801d..370635dc 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -142,7 +142,7 @@ public extension BaseItemDto { // MARK: Calculations - func getItemRuntime() -> String { + func getItemRuntime() -> String? { let timeHMSFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.unitsStyle = .abbreviated @@ -151,7 +151,7 @@ public extension BaseItemDto { }() guard let runTimeTicks = runTimeTicks, - let text = timeHMSFormatter.string(from: Double(runTimeTicks / 10_000_000)) else { return "" } + let text = timeHMSFormatter.string(from: Double(runTimeTicks / 10_000_000)) else { return nil } return text } diff --git a/Shared/Objects/OverlayType.swift b/Shared/Objects/OverlayType.swift index 4e2cfe8f..22737a15 100644 --- a/Shared/Objects/OverlayType.swift +++ b/Shared/Objects/OverlayType.swift @@ -13,5 +13,13 @@ import Foundation enum OverlayType: String, CaseIterable, Defaults.Serializable { case normal case compact - case bottom + + var label: String { + switch self { + case .normal: + return "Normal" + case .compact: + return "Compact" + } + } } diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 25aefd4f..fc5207ae 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -39,10 +39,9 @@ extension Defaults.Keys { static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) static let overlayType = Key("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) - static let gesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) - static let subtitlesEnabledIfDefault = Key("subtitlesEnabledIfDefault", default: false, suite: SwiftfinStore.Defaults.generalSuite) static let autoplayEnabled = Key("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) // Should show video player items diff --git a/Shared/ViewModels/EpisodeItemViewModel.swift b/Shared/ViewModels/EpisodeItemViewModel.swift index 89a88ca2..a51b92b2 100644 --- a/Shared/ViewModels/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/EpisodeItemViewModel.swift @@ -13,7 +13,15 @@ import JellyfinAPI import Stinsen final class EpisodeItemViewModel: ItemViewModel { + @RouterObject var itemRouter: ItemCoordinator.Router? + var seasonEpisodes: [BaseItemDto] = [] + + override init(item: BaseItemDto) { + super.init(item: item) + + getSeasonEpisodes() + } override func getItemDisplayName() -> String { guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" } @@ -47,4 +55,18 @@ final class EpisodeItemViewModel: ItemViewModel { }) .store(in: &cancellables) } + + private func getSeasonEpisodes() { + guard let seriesID = item.seriesId else { return } + TvShowsAPI.getEpisodes(seriesId: seriesID, + userId: SessionManager.main.currentLogin.user.id, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + seasonId: item.seasonId ?? "") + .sink { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + } receiveValue: { [weak self] item in + self?.seasonEpisodes = item.items ?? [] + } + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index eef24a8d..131f55a4 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -15,8 +15,14 @@ final class SettingsViewModel: ObservableObject { var bitrates: [Bitrates] = [] var langs: [TrackLanguage] = [] + + let server: SwiftfinStore.State.Server + let user: SwiftfinStore.State.User - init() { + init(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { + + self.server = server + self.user = user // Bitrates let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")! diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index ac86a054..2069185d 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -34,8 +34,16 @@ final class VideoPlayerViewModel: ViewModel { @Published var selectedSubtitleStreamIndex: Int @Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel? @Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel? - @Published var jumpBackwardLength: VideoPlayerJumpLength - @Published var jumpForwardLength: VideoPlayerJumpLength + @Published var jumpBackwardLength: VideoPlayerJumpLength { + willSet { + Defaults[.videoPlayerJumpBackward] = newValue + } + } + @Published var jumpForwardLength: VideoPlayerJumpLength { + willSet { + Defaults[.videoPlayerJumpForward] = newValue + } + } @Published var sliderIsScrubbing: Bool = false @Published var sliderPercentage: Double = 0 { willSet { @@ -64,6 +72,7 @@ final class VideoPlayerViewModel: ViewModel { let audioStreams: [MediaStream] let subtitleStreams: [MediaStream] let overlayType: OverlayType + let jumpGesturesEnabled: Bool // Full response kept for convenience let response: PlaybackInfoResponse @@ -124,6 +133,7 @@ final class VideoPlayerViewModel: ViewModel { self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward] self.jumpForwardLength = Defaults[.videoPlayerJumpForward] + self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled] super.init() @@ -242,6 +252,9 @@ extension VideoPlayerViewModel { .store(in: &cancellables) } + // Potential for experimental feature of syncing subtitle states among adjacent episodes + // when using previous & next item buttons and auto-play + private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) { if !masterViewModel.subtitlesEnabled { matchSubtitlesEnabled(with: masterViewModel) From 4f3f6d4c08203d6d42d5bf19ba85e230894f1747 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 3 Jan 2022 19:00:02 -0700 Subject: [PATCH 44/62] more iOS settings and fix appearance setting --- JellyfinPlayer/App/JellyfinPlayerApp.swift | 10 +-- .../Views/BasicAppSettingsView.swift | 39 +++++++++-- .../Views/OverlaySettingsView.swift | 2 + JellyfinPlayer/Views/SettingsView.swift | 4 +- .../VideoPlayer/VLCPlayerOverlayView.swift | 65 ++++++++++--------- .../MainCoordinator/iOSMainCoordinator.swift | 10 +++ Shared/Singleton/SessionManager.swift | 3 - .../SwiftfinStore/SwiftfinStoreDefaults.swift | 3 + .../BasicAppSettingsViewModel.swift | 10 ++- Shared/ViewModels/VideoPlayerViewModel.swift | 2 + 10 files changed, 97 insertions(+), 51 deletions(-) diff --git a/JellyfinPlayer/App/JellyfinPlayerApp.swift b/JellyfinPlayer/App/JellyfinPlayerApp.swift index 88d6880a..e87b3234 100644 --- a/JellyfinPlayer/App/JellyfinPlayerApp.swift +++ b/JellyfinPlayer/App/JellyfinPlayerApp.swift @@ -15,7 +15,6 @@ import SwiftUI struct JellyfinPlayerApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @Default(.appAppearance) var appAppearance var body: some Scene { WindowGroup { @@ -25,21 +24,18 @@ struct JellyfinPlayerApp: App { window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view()) }) .onAppear { - setupAppearance() + JellyfinPlayerApp.setupAppearance() } .onOpenURL { url in AppURLHandler.shared.processDeepLink(url: url) } - .onChange(of: appAppearance) { newValue in - setupAppearance() - } } } - private func setupAppearance() { + static func setupAppearance() { let scenes = UIApplication.shared.connectedScenes let windowScene = scenes.first as? UIWindowScene - windowScene?.windows.first?.overrideUserInterfaceStyle = appAppearance.style + windowScene?.windows.first?.overrideUserInterfaceStyle = Defaults[.appAppearance].style } } diff --git a/JellyfinPlayer/Views/BasicAppSettingsView.swift b/JellyfinPlayer/Views/BasicAppSettingsView.swift index 71fe5a0b..ef441842 100644 --- a/JellyfinPlayer/Views/BasicAppSettingsView.swift +++ b/JellyfinPlayer/Views/BasicAppSettingsView.swift @@ -15,7 +15,9 @@ struct BasicAppSettingsView: View { @EnvironmentObject var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router @ObservedObject var viewModel: BasicAppSettingsViewModel - @State var resetTapped: Bool = false + @State var resetUserSettingsTapped: Bool = false + @State var resetAppSettingsTapped: Bool = false + @State var removeAllUsersTapped: Bool = false @Default(.appAppearance) var appAppearance @Default(.defaultHTTPScheme) var defaultHTTPScheme @@ -43,15 +45,40 @@ struct BasicAppSettingsView: View { } Button { - resetTapped = true + resetUserSettingsTapped = true + } label: { + Text("Reset User Settings") + } + + Button { + resetAppSettingsTapped = true + } label: { + Text("Reset App Settings") + } + + Button { + removeAllUsersTapped = true + } label: { + Text("Remove All Users") + } + } + .alert("Reset User Settings", isPresented: $resetUserSettingsTapped, actions: { + Button(role: .destructive) { + viewModel.resetUserSettings() } label: { L10n.reset.text } - } - .alert(L10n.reset, isPresented: $resetTapped, actions: { + }) + .alert("Reset App Settings", isPresented: $resetAppSettingsTapped, actions: { Button(role: .destructive) { - viewModel.reset() - basicAppSettingsRouter.dismissCoordinator() + viewModel.resetAppSettings() + } label: { + L10n.reset.text + } + }) + .alert("Remove All Users", isPresented: $removeAllUsersTapped, actions: { + Button(role: .destructive) { + viewModel.removeAllUsers() } label: { L10n.reset.text } diff --git a/JellyfinPlayer/Views/OverlaySettingsView.swift b/JellyfinPlayer/Views/OverlaySettingsView.swift index f9cc687f..eb0e611a 100644 --- a/JellyfinPlayer/Views/OverlaySettingsView.swift +++ b/JellyfinPlayer/Views/OverlaySettingsView.swift @@ -16,6 +16,7 @@ struct OverlaySettingsView: View { @Default(.shouldShowPlayPreviousItem) var shouldShowPlayPreviousItem @Default(.shouldShowPlayNextItem) var shouldShowPlayNextItem @Default(.shouldShowAutoPlay) var shouldShowAutoPlay + @Default(.shouldShowJumpButtonsInOverlayMenu) var shouldShowJumpButtonsInOverlayMenu var body: some View { Form { @@ -29,6 +30,7 @@ struct OverlaySettingsView: View { 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) + Toggle("Allow Edit Jump Lengths", isOn: $shouldShowJumpButtonsInOverlayMenu) } } } diff --git a/JellyfinPlayer/Views/SettingsView.swift b/JellyfinPlayer/Views/SettingsView.swift index 501d2c0f..3bb528fd 100644 --- a/JellyfinPlayer/Views/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView.swift @@ -41,7 +41,7 @@ struct SettingsView: View { } label: { HStack { Text("Server") - .foregroundColor(.white) + .foregroundColor(.primary) Spacer() Text(viewModel.server.name) .foregroundColor(.jellyfinPurple) @@ -94,7 +94,7 @@ struct SettingsView: View { } label: { HStack { Text("Overlay") - .foregroundColor(.white) + .foregroundColor(.primary) Spacer() Text(overlayType.label) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift index eea32862..5e7dce0d 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift @@ -190,44 +190,45 @@ struct VLCPlayerOverlayView: View { } } - Menu { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in - Button { - viewModel.jumpForwardLength = forwardLength - } label: { - if forwardLength == viewModel.jumpForwardLength { - Label(forwardLength.shortLabel, systemImage: "checkmark") - } else { - Text(forwardLength.shortLabel) + if viewModel.shouldShowJumpButtonsInOverlayMenu { + Menu { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in + Button { + viewModel.jumpForwardLength = forwardLength + } label: { + if forwardLength == viewModel.jumpForwardLength { + Label(forwardLength.shortLabel, systemImage: "checkmark") + } else { + Text(forwardLength.shortLabel) + } } } - } - } label: { - HStack { - Image(systemName: "goforward") - Text("Jump Forward Length") - } - } - - Menu { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { backwardLength in - Button { - viewModel.jumpBackwardLength = backwardLength - } label: { - if backwardLength == viewModel.jumpBackwardLength { - Label(backwardLength.shortLabel, systemImage: "checkmark") - } else { - Text(backwardLength.shortLabel) - } + } label: { + HStack { + Image(systemName: "goforward") + Text("Jump Forward Length") } } - } label: { - HStack { - Image(systemName: "gobackward") - Text("Jump Backward Length") + + Menu { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { backwardLength in + Button { + viewModel.jumpBackwardLength = backwardLength + } label: { + if backwardLength == viewModel.jumpBackwardLength { + Label(backwardLength.shortLabel, systemImage: "checkmark") + } else { + Text(backwardLength.shortLabel) + } + } + } + } label: { + HStack { + Image(systemName: "gobackward") + Text("Jump Backward Length") + } } } - } label: { Image(systemName: "ellipsis.circle") } diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index 067d16f9..5a1e5eb7 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -7,6 +7,8 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Combine +import Defaults import Foundation import Nuke import Stinsen @@ -18,6 +20,8 @@ final class MainCoordinator: NavigationCoordinatable { @Root var mainTab = makeMainTab @Root var serverList = makeServerList + + private var cancellables = Set() init() { if SessionManager.main.currentLogin != nil { @@ -45,6 +49,12 @@ final class MainCoordinator: NavigationCoordinatable { nc.addObserver(self, selector: #selector(didLogOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) nc.addObserver(self, selector: #selector(processDeepLink), name: SwiftfinNotificationCenter.Keys.processDeepLink, object: nil) nc.addObserver(self, selector: #selector(didChangeServerCurrentURI), name: SwiftfinNotificationCenter.Keys.didChangeServerCurrentURI, object: nil) + + Defaults.publisher(.appAppearance) + .sink { _ in + JellyfinPlayerApp.setupAppearance() + } + .store(in: &cancellables) } @objc func didLogIn() { diff --git a/Shared/Singleton/SessionManager.swift b/Shared/Singleton/SessionManager.swift index 59c7cae2..bdf96aae 100644 --- a/Shared/Singleton/SessionManager.swift +++ b/Shared/Singleton/SessionManager.swift @@ -254,9 +254,6 @@ final class SessionManager { delete(server: server) } - // Delete general UserDefaults - SwiftfinStore.Defaults.generalSuite.removeAll() - SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didPurge, object: nil) } diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index fc5207ae..921a3ba9 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -49,6 +49,9 @@ extension Defaults.Keys { static let shouldShowPlayNextItem = Key("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let shouldShowAutoPlay = Key("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + // Should show video player items in overlay menu + static let shouldShowJumpButtonsInOverlayMenu = Key("shouldShowJumpButtonsInMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) + // Experimental settings struct Experimental { static let syncSubtitleStateWithAdjacent = Key("experimental.syncSubtitleState", default: false, suite: SwiftfinStore.Defaults.generalSuite) diff --git a/Shared/ViewModels/BasicAppSettingsViewModel.swift b/Shared/ViewModels/BasicAppSettingsViewModel.swift index 9cc1fa43..fb5c8887 100644 --- a/Shared/ViewModels/BasicAppSettingsViewModel.swift +++ b/Shared/ViewModels/BasicAppSettingsViewModel.swift @@ -13,7 +13,15 @@ final class BasicAppSettingsViewModel: ViewModel { let appearances = AppAppearance.allCases - func reset() { + func resetUserSettings() { + SwiftfinStore.Defaults.generalSuite.removeAll() + } + + func resetAppSettings() { + SwiftfinStore.Defaults.universalSuite.removeAll() + } + + func removeAllUsers() { SessionManager.main.purge() } } diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 2069185d..f810ce62 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -62,6 +62,7 @@ final class VideoPlayerViewModel: ViewModel { let shouldShowPlayPreviousItem: Bool let shouldShowPlayNextItem: Bool let shouldShowAutoPlay: Bool + let shouldShowJumpButtonsInOverlayMenu: Bool // MARK: General let item: BaseItemDto @@ -134,6 +135,7 @@ final class VideoPlayerViewModel: ViewModel { self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward] self.jumpForwardLength = Defaults[.videoPlayerJumpForward] self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled] + self.shouldShowJumpButtonsInOverlayMenu = Defaults[.shouldShowJumpButtonsInOverlayMenu] super.init() From 7ab85e453dbe9fe98f8f3d9de84a5a9c4c65ff38 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 3 Jan 2022 19:15:14 -0700 Subject: [PATCH 45/62] experimental flag for sync subtitles with adjacent items --- JellyfinPlayer.xcodeproj/project.pbxproj | 16 +++++++++-- .../ExperimentalSettingsView.swift | 28 +++++++++++++++++++ .../OverlaySettingsView.swift | 0 .../{ => SettingsView}/SettingsView.swift | 12 +++++++- Shared/Coordinators/SettingsCoordinator.swift | 5 ++++ Shared/ViewModels/VideoPlayerViewModel.swift | 23 +++++++++++++-- 6 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 JellyfinPlayer/Views/SettingsView/ExperimentalSettingsView.swift rename JellyfinPlayer/Views/{ => SettingsView}/OverlaySettingsView.swift (100%) rename JellyfinPlayer/Views/{ => SettingsView}/SettingsView.swift (94%) diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index de8e65bf..87aeb1c6 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -383,6 +383,7 @@ E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */; }; 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 */; }; 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 */; }; @@ -666,6 +667,7 @@ E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsView.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1295,10 +1297,9 @@ 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, 53892771263C8C6F0035E14B /* LoadingView.swift */, 5389276F263C25230035E14B /* NextUpView.swift */, - E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */, + E1E5D54A2783E26100692DFE /* SettingsView */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, E13DD3E427177D15009D4DAF /* ServerListView.swift */, - 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, E13DD3FB2717EAE8009D4DAF /* UserListView.swift */, E13DD3F4271793BB009D4DAF /* UserSignInView.swift */, E193D5452719418B00900D82 /* VideoPlayer */, @@ -1449,6 +1450,16 @@ path = CinematicItemView; sourceTree = ""; }; + E1E5D54A2783E26100692DFE /* SettingsView */ = { + isa = PBXGroup; + children = ( + E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */, + E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */, + 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, + ); + path = SettingsView; + sourceTree = ""; + }; E1FCD08E26C466F3007C8DCF /* Errors */ = { isa = PBXGroup; children = ( @@ -2131,6 +2142,7 @@ C4BE0769271FC164003F4AD1 /* TVLibrariesView.swift in Sources */, E1267D3E271A1F46003C492E /* PreferenceUIHostingController.swift in Sources */, 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */, + E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */, 6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */, 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */, 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, diff --git a/JellyfinPlayer/Views/SettingsView/ExperimentalSettingsView.swift b/JellyfinPlayer/Views/SettingsView/ExperimentalSettingsView.swift new file mode 100644 index 00000000..179b1889 --- /dev/null +++ b/JellyfinPlayer/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/Views/OverlaySettingsView.swift b/JellyfinPlayer/Views/SettingsView/OverlaySettingsView.swift similarity index 100% rename from JellyfinPlayer/Views/OverlaySettingsView.swift rename to JellyfinPlayer/Views/SettingsView/OverlaySettingsView.swift diff --git a/JellyfinPlayer/Views/SettingsView.swift b/JellyfinPlayer/Views/SettingsView/SettingsView.swift similarity index 94% rename from JellyfinPlayer/Views/SettingsView.swift rename to JellyfinPlayer/Views/SettingsView/SettingsView.swift index 3bb528fd..0cbe663d 100644 --- a/JellyfinPlayer/Views/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView/SettingsView.swift @@ -97,7 +97,17 @@ struct SettingsView: View { .foregroundColor(.primary) Spacer() Text(overlayType.label) - + Image(systemName: "chevron.right") + } + } + + Button { + settingsRouter.route(to: \.experimentalSettings) + } label: { + HStack { + Text("Experimental") + .foregroundColor(.primary) + Spacer() Image(systemName: "chevron.right") } } diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 0b7da98e..6d91d12a 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -18,6 +18,7 @@ final class SettingsCoordinator: NavigationCoordinatable { @Root var start = makeStart @Route(.push) var serverDetail = makeServerDetail @Route(.push) var overlaySettings = makeOverlaySettings + @Route(.push) var experimentalSettings = makeExperimentalSettings @ViewBuilder func makeServerDetail() -> some View { let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server) @@ -27,6 +28,10 @@ final class SettingsCoordinator: NavigationCoordinatable { @ViewBuilder func makeOverlaySettings() -> some View { OverlaySettingsView() } + + @ViewBuilder func makeExperimentalSettings() -> some View { + ExperimentalSettingsView() + } @ViewBuilder func makeStart() -> some View { let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user) diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index f810ce62..dc2ee91c 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -29,9 +29,23 @@ final class VideoPlayerViewModel: ViewModel { @Published var leftLabelText: String = "--:--" @Published var rightLabelText: String = "--:--" @Published var playbackSpeed: PlaybackSpeed = .one - @Published var subtitlesEnabled: Bool + @Published var subtitlesEnabled: Bool { + didSet { + if syncSubtitleStateWithAdjacent { + previousItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) + nextItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) + } + } + } @Published var selectedAudioStreamIndex: Int - @Published var selectedSubtitleStreamIndex: Int + @Published var selectedSubtitleStreamIndex: Int { + didSet { + if syncSubtitleStateWithAdjacent { + previousItemVideoPlayerViewModel?.matchSubtitleStream(with: self) + nextItemVideoPlayerViewModel?.matchSubtitleStream(with: self) + } + } + } @Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel? @Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel? @Published var jumpBackwardLength: VideoPlayerJumpLength { @@ -75,6 +89,9 @@ final class VideoPlayerViewModel: ViewModel { let overlayType: OverlayType let jumpGesturesEnabled: Bool + // MARK: Experimental + let syncSubtitleStateWithAdjacent: Bool + // Full response kept for convenience let response: PlaybackInfoResponse @@ -137,6 +154,8 @@ final class VideoPlayerViewModel: ViewModel { self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled] self.shouldShowJumpButtonsInOverlayMenu = Defaults[.shouldShowJumpButtonsInOverlayMenu] + self.syncSubtitleStateWithAdjacent = Defaults[.Experimental.syncSubtitleStateWithAdjacent] + super.init() self.sliderPercentage = (item.userData?.playedPercentage ?? 0) / 100 From 2d7cad8cec72e65e851192f291f24592f57a141a Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 3 Jan 2022 22:55:39 -0700 Subject: [PATCH 46/62] 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() + } } } } From 25b4e382f2fbb67ec6c8d8b0af2bfe5229c1e047 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 3 Jan 2022 23:37:48 -0700 Subject: [PATCH 47/62] confirm close tfor tvos --- .../Views/SettingsView/SettingsView.swift | 3 + .../VideoPlayer/VLCPlayerViewController.swift | 116 ++++++++++++++---- .../tvOSOverlay/ConfirmCloseOverlay.swift | 40 ++++++ JellyfinPlayer.xcodeproj/project.pbxproj | 4 + .../SwiftfinStore/SwiftfinStoreDefaults.swift | 1 + Shared/ViewModels/VideoPlayerViewModel.swift | 15 ++- 6 files changed, 154 insertions(+), 25 deletions(-) create mode 100644 JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/ConfirmCloseOverlay.swift diff --git a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift index 2ce3ce98..a211cd19 100644 --- a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift +++ b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift @@ -19,6 +19,7 @@ struct SettingsView: View { @Default(.videoPlayerJumpForward) var jumpForwardLength @Default(.videoPlayerJumpBackward) var jumpBackwardLength @Default(.downActionShowsMenu) var downActionShowsMenu + @Default(.confirmClose) var confirmClose var body: some View { GeometryReader { reader in @@ -76,6 +77,8 @@ struct SettingsView: View { Toggle("Press Down for Menu", isOn: $downActionShowsMenu) + Toggle("Confirm Close", isOn: $confirmClose) + Button { settingsRouter.route(to: \.overlaySettings) } label: { diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 08218ee3..451b664e 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -29,6 +29,7 @@ class VLCPlayerViewController: UIViewController { private var lastProgressReportTicks: Int64 = 0 private var viewModelListeners = Set() private var overlayDismissTimer: Timer? + private var confirmCloseOverlayDismissTimer: Timer? private var currentPlayerTicks: Int64 { return Int64(vlcMediaPlayer.time.intValue) * 100_000 @@ -42,11 +43,16 @@ class VLCPlayerViewController: UIViewController { return currentOverlayContentHostingController?.view.alpha ?? 0 > 0 } + private var displayingConfirmClose: Bool { + return currentConfirmCloseHostingController?.view.alpha ?? 0 > 0 + } + private lazy var videoContentView = makeVideoContentView() private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() private var currentOverlayHostingController: UIHostingController? private var currentOverlayContentHostingController: UIHostingController? + private var currentConfirmCloseHostingController: UIHostingController? // MARK: init @@ -192,10 +198,16 @@ class VLCPlayerViewController: UIViewController { switch(buttonPress) { case .menu: () // Captured by other gesture case .playPause: + hideConfirmCloseOverlay() + didSelectMain() case .select: + hideConfirmCloseOverlay() + didGenerallyTap() case .upArrow: + hideConfirmCloseOverlay() + if displayingContentOverlay { hideOverlayContent() @@ -203,16 +215,22 @@ class VLCPlayerViewController: UIViewController { restartOverlayDismissTimer() } case .downArrow: + hideConfirmCloseOverlay() + if Defaults[.downActionShowsMenu] { if !displayingContentOverlay { didSelectMenu() } } case .leftArrow: + hideConfirmCloseOverlay() + if !displayingContentOverlay { didSelectBackward() } case .rightArrow: + hideConfirmCloseOverlay() + if !displayingContentOverlay { didSelectForward() } @@ -229,6 +247,7 @@ class VLCPlayerViewController: UIViewController { view.addGestureRecognizer(pressRecognizer) } + // MARK: didPressMenu @objc private func didPressMenu() { if displayingOverlay { hideOverlay() @@ -237,6 +256,11 @@ class VLCPlayerViewController: UIViewController { showOverlay() restartOverlayDismissTimer() + } else if viewModel.confirmClose && !displayingConfirmClose { + + showConfirmCloseOverlay() + restartConfirmCloseDismissTimer() + } else { vlcMediaPlayer.pause() @@ -255,7 +279,7 @@ class VLCPlayerViewController: UIViewController { // TODO: Look at injecting viewModel into the environment so it updates the current overlay - // Overlay + // Main overlay if let currentOverlayHostingController = currentOverlayHostingController { // UX fade-out UIView.animate(withDuration: 0.5) { @@ -295,7 +319,7 @@ class VLCPlayerViewController: UIViewController { self.currentOverlayHostingController = newOverlayHostingController - // OverlayContent + // Media Stream selection if let currentOverlayContentHostingController = currentOverlayContentHostingController { currentOverlayContentHostingController.view.isHidden = true @@ -303,26 +327,9 @@ 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, -// 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) newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false newOverlayContentHostingController.view.backgroundColor = UIColor.clear @@ -341,6 +348,36 @@ class VLCPlayerViewController: UIViewController { ]) self.currentOverlayContentHostingController = newOverlayContentHostingController + + // Confirm close + if let currentConfirmCloseHostingController = currentConfirmCloseHostingController { + currentConfirmCloseHostingController.view.isHidden = true + + currentConfirmCloseHostingController.view.removeFromSuperview() + currentConfirmCloseHostingController.removeFromParent() + } + + let newConfirmCloseOverlay = ConfirmCloseOverlay() + + let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay) + + newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newConfirmCloseHostingController.view.backgroundColor = UIColor.clear + + newConfirmCloseHostingController.view.alpha = 0 + + addChild(newConfirmCloseHostingController) + view.addSubview(newConfirmCloseHostingController.view) + newConfirmCloseHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) + ]) + + self.currentConfirmCloseHostingController = newConfirmCloseHostingController // There is a behavior when setting this that the navigation bar // on the current navigation controller pops up, re-hide it @@ -543,6 +580,26 @@ extension VLCPlayerViewController { } } +// MARK: Show/Hide Confirm close +extension VLCPlayerViewController { + + private func showConfirmCloseOverlay() { + guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } + + UIView.animate(withDuration: 0.2) { + currentConfirmCloseHostingController.view.alpha = 1 + } + } + + private func hideConfirmCloseOverlay() { + guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } + + UIView.animate(withDuration: 0.5) { + currentConfirmCloseHostingController.view.alpha = 0 + } + } +} + // MARK: OverlayTimer extension VLCPlayerViewController { @@ -552,11 +609,28 @@ extension VLCPlayerViewController { } @objc private func dismissTimerFired() { - self.hideOverlay() + hideOverlay() } private func stopOverlayDismissTimer() { - self.overlayDismissTimer?.invalidate() + overlayDismissTimer?.invalidate() + } +} + +// MARK: Confirm Close Overlay Timer +extension VLCPlayerViewController { + + private func restartConfirmCloseDismissTimer() { + self.confirmCloseOverlayDismissTimer?.invalidate() + self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(timeInterval: 5, target: self, selector: #selector(confirmCloseTimerFired), userInfo: nil, repeats: false) + } + + @objc private func confirmCloseTimerFired() { + hideConfirmCloseOverlay() + } + + private func stopConfirmCloseDismissTimer() { + confirmCloseOverlayDismissTimer?.invalidate() } } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/ConfirmCloseOverlay.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/ConfirmCloseOverlay.swift new file mode 100644 index 00000000..b9e2b493 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSOverlay/ConfirmCloseOverlay.swift @@ -0,0 +1,40 @@ +// + /* + * 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 SwiftUI + +struct ConfirmCloseOverlay: View { + var body: some View { + VStack { + HStack { + Image(systemName: "chevron.left.circle.fill") + .font(.system(size: 96)) + .padding(3) + .background(Color.black.opacity(0.4).mask(Circle())) + + Spacer() + } + .padding() + + Spacer() + } + .padding() + } +} + +struct ConfirmCloseOverlay_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color.red.ignoresSafeArea() + + ConfirmCloseOverlay() + .ignoresSafeArea() + } + } +} diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 51fa5158..f1d84a70 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -385,6 +385,7 @@ 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 */; }; + E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D552278419D900692DFE /* ConfirmCloseOverlay.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 */; }; @@ -670,6 +671,7 @@ 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 = ""; }; + E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmCloseOverlay.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 = ""; }; @@ -1332,6 +1334,7 @@ E17885A7278130690094FBCF /* tvOSOverlay */ = { isa = PBXGroup; children = ( + E1E5D552278419D900692DFE /* ConfirmCloseOverlay.swift */, E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */, E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */, ); @@ -1983,6 +1986,7 @@ 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */, + E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */, E13DD3D6271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */, E193D549271941CC00900D82 /* UserSignInView.swift in Sources */, 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */, diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 8f999033..79393781 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -59,4 +59,5 @@ extension Defaults.Keys { // tvos specific static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let confirmClose = Key("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite) } diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 274db3d7..cba905f3 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -92,6 +92,9 @@ final class VideoPlayerViewModel: ViewModel { // MARK: Experimental let syncSubtitleStateWithAdjacent: Bool + // MARK: tvOS + let confirmClose: Bool + // Full response kept for convenience let response: PlaybackInfoResponse @@ -161,6 +164,8 @@ final class VideoPlayerViewModel: ViewModel { self.syncSubtitleStateWithAdjacent = Defaults[.Experimental.syncSubtitleStateWithAdjacent] + self.confirmClose = Defaults[.confirmClose] + super.init() self.sliderPercentage = (item.userData?.playedPercentage ?? 0) / 100 @@ -355,7 +360,7 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { _ in - print("Playback start report sent!") + LogManager.shared.log.debug("Start report sent for item: \(self.item.id ?? "No ID")") } .store(in: &cancellables) } @@ -391,7 +396,7 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { _ in - print("Pause report sent!") + LogManager.shared.log.debug("Pause report sent for item: \(self.item.id ?? "No ID")") } .store(in: &cancellables) } @@ -434,9 +439,11 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { _ in - print("Playback progress sent!") + LogManager.shared.log.debug("Playback progress sent for item: \(self.item.id ?? "No ID")") } .store(in: &cancellables) + + self.lastProgressReport = nil } // MARK: sendStopReport @@ -458,7 +465,7 @@ extension VideoPlayerViewModel { .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { _ in - print("Playback stop report sent!") + LogManager.shared.log.debug("Stop report sent for item: \(self.item.id ?? "No ID")") } .store(in: &cancellables) } From e21aa66372544443d1e3fe854a64d0c00eec382d Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 00:17:03 -0700 Subject: [PATCH 48/62] episode/movie cinematic view toggle --- .../Views/ItemView/EpisodesRowView.swift | 1 + .../Views/ItemView/ItemView.swift | 19 ++++++++++++++++--- .../Views/SettingsView/SettingsView.swift | 10 ++++++++++ .../VideoPlayer/VLCPlayerViewController.swift | 11 +++-------- .../VideoPlayer/tvOSSLider/SliderView.swift | 1 + .../VideoPlayer/tvOSSLider/tvOSSlider.swift | 3 ++- .../SwiftfinStore/SwiftfinStoreDefaults.swift | 2 ++ .../ViewModels/ConnectToServerViewModel.swift | 2 ++ 8 files changed, 37 insertions(+), 12 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift b/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift index e0731189..1f1faf17 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift @@ -63,6 +63,7 @@ struct EpisodesRowView: View { .padding(.horizontal, 50) .padding(.vertical) .onAppear { + // TODO: Get this working reader.scrollTo(viewModel.item.name) } } diff --git a/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift index be61887f..cc523200 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift @@ -5,9 +5,10 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI +import Defaults import Introspect import JellyfinAPI +import SwiftUI // Useless view necessary in tvOS because of iOS's implementation struct ItemNavigationView: View { @@ -23,6 +24,10 @@ struct ItemNavigationView: View { } struct ItemView: View { + + @Default(.tvOSEpisodeItemCinematicView) var tvOSEpisodeItemCinematicView + @Default(.tvOSMovieItemCinematicView) var tvOSMovieItemCinematicView + private var item: BaseItemDto init(item: BaseItemDto) { @@ -32,13 +37,21 @@ struct ItemView: View { var body: some View { Group { if item.type == "Movie" { - CinematicMovieItemView(viewModel: MovieItemViewModel(item: item)) + if tvOSMovieItemCinematicView { + CinematicMovieItemView(viewModel: MovieItemViewModel(item: item)) + } else { + MovieItemView(viewModel: MovieItemViewModel(item: item)) + } } else if item.type == "Series" { SeriesItemView(viewModel: .init(item: item)) } else if item.type == "Season" { SeasonItemView(viewModel: .init(item: item)) } else if item.type == "Episode" { - CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) + if tvOSEpisodeItemCinematicView { + CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) + } else { + EpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) + } } else { Text(L10n.notImplementedYetWithType(item.type ?? "")) } diff --git a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift index a211cd19..d76d20e3 100644 --- a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift +++ b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift @@ -20,6 +20,8 @@ struct SettingsView: View { @Default(.videoPlayerJumpBackward) var jumpBackwardLength @Default(.downActionShowsMenu) var downActionShowsMenu @Default(.confirmClose) var confirmClose + @Default(.tvOSEpisodeItemCinematicView) var tvOSEpisodeItemCinematicView + @Default(.tvOSMovieItemCinematicView) var tvOSMovieItemCinematicView var body: some View { GeometryReader { reader in @@ -37,6 +39,7 @@ struct SettingsView: View { Text(viewModel.user.username) .foregroundColor(.jellyfinPurple) } + .focusable() Button { settingsRouter.route(to: \.serverDetail) @@ -101,6 +104,13 @@ struct SettingsView: View { } } } + + Section { + Toggle("Episode Item Cinematic View", isOn: $tvOSEpisodeItemCinematicView) + Toggle("Movie Item Cinematic View", isOn: $tvOSMovieItemCinematicView) + } header: { + Text("Views") + } } } } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 451b664e..6c5964da 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -196,7 +196,7 @@ class VLCPlayerViewController: UIViewController { guard let buttonPress = presses.first?.type else { return } switch(buttonPress) { - case .menu: () // Captured by other gesture + case .menu: () // Captured by other recognizer case .playPause: hideConfirmCloseOverlay() @@ -207,13 +207,6 @@ class VLCPlayerViewController: UIViewController { didGenerallyTap() case .upArrow: hideConfirmCloseOverlay() - - if displayingContentOverlay { - hideOverlayContent() - - showOverlay() - restartOverlayDismissTimer() - } case .downArrow: hideConfirmCloseOverlay() @@ -776,6 +769,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { case .playing: viewModel.sendPauseReport(paused: true) vlcMediaPlayer.pause() + + showOverlay() restartOverlayDismissTimer(interval: 5) case .paused: viewModel.sendPauseReport(paused: false) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift index aebb9831..cb7f3535 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift @@ -32,6 +32,7 @@ struct SliderView: UIViewRepresentable { slider.minimumTrackTintColor = .white slider.focusScaleFactor = 1.4 slider.panDampingValue = 50 + slider.fineTunningVelocityThreshold = 1000 return slider } diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift index 77d08528..79db94e5 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift @@ -33,7 +33,6 @@ private let defaultFocusScaleFactor: CGFloat = 1.05 private let defaultStepValue: Float = 0.1 private let decelerationRate: Float = 0.92 private let decelerationMaxVelocity: Float = 1000 -private let fineTunningVelocityThreshold: Float = 600 /// A control used to select a single value from a continuous range of values. public final class TvOSSlider: UIControl { @@ -117,6 +116,8 @@ public final class TvOSSlider: UIControl { // Size for thumb view public var thumbSize: CGFloat = 30 + public var fineTunningVelocityThreshold: Float = 600 + /** Sets the slider’s current value, allowing you to animate the change visually. diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 79393781..9a9400be 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -60,4 +60,6 @@ extension Defaults.Keys { // tvos specific static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let confirmClose = Key("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let tvOSEpisodeItemCinematicView = Key("tvOSEpisodeItemCinematicView", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let tvOSMovieItemCinematicView = Key("tvOSMovieItemCinematicView", default: false, suite: SwiftfinStore.Defaults.generalSuite) } diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index b28581a8..378c297b 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -48,6 +48,8 @@ final class ConnectToServerViewModel: ViewModel { uri = "http://localhost:8096" } #endif + + let uri = uri.trimmingCharacters(in: .whitespaces) LogManager.shared.log.debug("Attempting to connect to server at \"\(uri)\"", tag: "connectToServer") SessionManager.main.connectToServer(with: uri) From 1f199dc4f8d20a9e858868006e0bc3bffd353440 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 00:33:43 -0700 Subject: [PATCH 49/62] fix scrubbing tvos --- .../Views/VideoPlayer/VLCPlayerViewController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 6c5964da..5a59e095 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -657,8 +657,6 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { viewModel.sliderPercentage = Double(vlcMediaPlayer.position) } - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - // Have to manually set playing because VLCMediaPlayer doesn't // properly set it itself if abs(currentPlayerTicks - lastPlayerTicks) >= 10_000 { From 78c061d4de5b30373021caca59c6e4c1cefd955b Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 18:47:18 -0700 Subject: [PATCH 50/62] iOS advanced seasons episodes selection --- .../Views/ItemView/ItemDetailsView.swift | 47 ------ .../Views/LatestMediaView.swift | 19 ++- JellyfinPlayer.xcodeproj/project.pbxproj | 26 ++- .../Components/EpisodeCardVStackView.swift | 1 - .../Components/PillHStackView.swift | 1 - .../Components/PortraitHStackView.swift | 70 ++++---- .../Components/PortraitItemView.swift | 1 - .../Views/ContinueWatchingView.swift | 100 ++++++------ JellyfinPlayer/Views/HomeView.swift | 56 ++++--- .../Views/ItemView/EpisodesRowView.swift | 150 ++++++++++++++++++ JellyfinPlayer/Views/ItemView/ItemView.swift | 21 +-- .../Views/ItemView/ItemViewBody.swift | 68 +++++--- .../Views/ItemView/ItemViewDetailsView.swift | 58 +++++++ JellyfinPlayer/Views/LatestMediaView.swift | 21 ++- JellyfinPlayer/Views/LibraryListView.swift | 21 --- JellyfinPlayer/Views/LoadingView.swift | 85 ---------- .../SettingsView/OverlaySettingsView.swift | 2 +- .../Views/SettingsView/SettingsView.swift | 25 +-- .../VideoPlayer/VLCPlayerViewController.swift | 7 +- .../BaseItemDto+Stackable.swift | 33 +++- .../BaseItemDto+VideoPlayerViewModel.swift | 9 +- .../BaseItemDtoExtensions.swift | 60 ++++++- .../BaseItemPersonExtensions.swift | 10 +- Shared/Objects/PortraitImageStackable.swift | 4 +- Shared/Objects/PosterSize.swift | 15 ++ .../SwiftfinNotificationCenter.swift | 2 + .../SwiftfinStore/SwiftfinStoreDefaults.swift | 5 + Shared/ViewModels/EpisodeItemViewModel.swift | 34 +--- Shared/ViewModels/EpisodesRowViewModel.swift | 65 ++++++++ Shared/ViewModels/HomeViewModel.swift | 34 +++- Shared/ViewModels/ItemViewModel.swift | 12 +- 31 files changed, 670 insertions(+), 392 deletions(-) create mode 100644 JellyfinPlayer/Views/ItemView/EpisodesRowView.swift create mode 100644 JellyfinPlayer/Views/ItemView/ItemViewDetailsView.swift delete mode 100644 JellyfinPlayer/Views/LoadingView.swift create mode 100644 Shared/Objects/PosterSize.swift create mode 100644 Shared/ViewModels/EpisodesRowViewModel.swift diff --git a/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift b/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift index 6d3afa1a..b302e613 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift @@ -12,55 +12,8 @@ import SwiftUI struct ItemDetailsView: View { @ObservedObject var viewModel: ItemViewModel - private let detailItems: [(String, String)] - private let mediaItems: [(String, String)] @FocusState private var focused: Bool - init(viewModel: ItemViewModel) { - self.viewModel = viewModel - - var initialDetailItems: [(String, String)] = [] - - if let productionYear = viewModel.item.productionYear { - initialDetailItems.append(("Released", "\(productionYear)")) - } - - if let rating = viewModel.item.officialRating { - initialDetailItems.append(("Rated", "\(rating)")) - } - - if let runtime = viewModel.item.getItemRuntime() { - initialDetailItems.append(("Runtime", "\(runtime)")) - } - - var initialMediatems: [(String, String)] = [] - - if let container = viewModel.item.container { - let containerList = container.split(separator: ",") - if containerList.count > 1 { - initialMediatems.append(("Containers", containerList.joined(separator: ", "))) - } else { - initialMediatems.append(("Container", containerList.joined(separator: ", "))) - } - } - - if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel { - - if !itemVideoPlayerViewModel.audioStreams.isEmpty { - let audioList = itemVideoPlayerViewModel.audioStreams.compactMap({ $0.displayTitle }).joined(separator: ", ") - initialMediatems.append(("Audio", audioList)) - } - - if !itemVideoPlayerViewModel.subtitleStreams.isEmpty { - let subtitlesList = itemVideoPlayerViewModel.subtitleStreams.compactMap({ $0.displayTitle }).joined(separator: ", ") - initialMediatems.append(("Subtitles", subtitlesList)) - } - } - - detailItems = initialDetailItems - mediaItems = initialMediatems - } - var body: some View { ZStack(alignment: .leading) { diff --git a/JellyfinPlayer tvOS/Views/LatestMediaView.swift b/JellyfinPlayer tvOS/Views/LatestMediaView.swift index 0bc78790..79905357 100644 --- a/JellyfinPlayer tvOS/Views/LatestMediaView.swift +++ b/JellyfinPlayer tvOS/Views/LatestMediaView.swift @@ -13,8 +13,9 @@ struct LatestMediaView: View { @StateObject var tempViewModel = ViewModel() @State var items: [BaseItemDto] = [] - private var library_id: String = "" @State private var viewDidLoad: Bool = false + + private var library_id: String = "" init(usingParentID: String) { library_id = usingParentID @@ -26,15 +27,13 @@ struct LatestMediaView: View { } viewDidLoad = true - DispatchQueue.global(qos: .userInitiated).async { - UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { response in - items = response - }) - .store(in: &tempViewModel.cancellables) - } + UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12) + .sink(receiveCompletion: { completion in + print(completion) + }, receiveValue: { response in + items = response + }) + .store(in: &tempViewModel.cancellables) } var body: some View { diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index f1d84a70..9fd95481 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -76,7 +76,6 @@ 5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */; }; 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276D263C25100035E14B /* ContinueWatchingView.swift */; }; 53892770263C25230035E14B /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276F263C25230035E14B /* NextUpView.swift */; }; - 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53892771263C8C6F0035E14B /* LoadingView.swift */; }; 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; }; 53913BEF26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; 53913BF026D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; @@ -230,6 +229,13 @@ C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; }; C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; }; E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */; }; + E10D87DA2784E4F100BD264C /* ItemViewDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */; }; + E10D87DC2784EC5200BD264C /* EpisodesRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */; }; + E10D87DE278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; + E10D87DF278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; + E10D87E0278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; + E10D87E227852FD000BD264C /* EpisodesRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */; }; + E10D87E327852FD000BD264C /* EpisodesRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */; }; E10EAA45277BB646000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA44277BB646000269ED /* JellyfinAPI */; }; E10EAA47277BB670000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA46277BB670000269ED /* JellyfinAPI */; }; E10EAA4D277BB716000269ED /* Sliders in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA4C277BB716000269ED /* Sliders */; }; @@ -493,7 +499,6 @@ 5377CC02263B596B003A4E83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5389276D263C25100035E14B /* ContinueWatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; 5389276F263C25230035E14B /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = ""; }; - 53892771263C8C6F0035E14B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; 53913BCA26D323FE00EB3286 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = ""; }; 53913BCD26D323FE00EB3286 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Localizable.strings; sourceTree = ""; }; @@ -587,6 +592,10 @@ C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; + E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewDetailsView.swift; sourceTree = ""; }; + E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = ""; }; + E10D87DD278510E300BD264C /* PosterSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterSize.swift; sourceTree = ""; }; + E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowViewModel.swift; sourceTree = ""; }; E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = ""; }; E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; @@ -767,6 +776,7 @@ E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */, + E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, 62E632F2267D54030063E547 /* ItemViewModel.swift */, 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */, @@ -891,6 +901,7 @@ E1AA331E2782639D00F6439C /* OverlayType.swift */, E193D4DA27193CCA00900D82 /* PillStackable.swift */, E193D4D727193CAC00900D82 /* PortraitImageStackable.swift */, + E10D87DD278510E300BD264C /* PosterSize.swift */, E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */, 535870AC2669D8DD00D05A09 /* Typings.swift */, E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */, @@ -1299,7 +1310,6 @@ 6213388F265F83A900A81A2A /* LibraryListView.swift */, 53EE24E5265060780068F029 /* LibrarySearchView.swift */, 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, - 53892771263C8C6F0035E14B /* LoadingView.swift */, 5389276F263C25230035E14B /* NextUpView.swift */, E1E5D54A2783E26100692DFE /* SettingsView */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, @@ -1315,7 +1325,9 @@ isa = PBXGroup; children = ( 535BAE9E2649E569005FA86D /* ItemView.swift */, + E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */, E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */, + E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */, E18845FB26DEACC400B0C5B7 /* Landscape */, E18845FA26DEACBE00B0C5B7 /* Portrait */, ); @@ -1955,6 +1967,7 @@ 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */, E193D53727193F8700900D82 /* LibraryListCoordinator.swift in Sources */, + E10D87DF278510E400BD264C /* PosterSize.swift in Sources */, E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */, E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */, E193D54D2719426600900D82 /* LibraryFilterView.swift in Sources */, @@ -2020,6 +2033,7 @@ E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */, E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */, 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, + E10D87E327852FD000BD264C /* EpisodesRowViewModel.swift in Sources */, 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, E193D547271941C500900D82 /* UserListView.swift in Sources */, @@ -2066,6 +2080,7 @@ E1C812C0277A8E5D00918266 /* VLCPlayerView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, + E10D87E227852FD000BD264C /* EpisodesRowViewModel.swift in Sources */, C40CD925271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, @@ -2100,6 +2115,7 @@ C4BE0763271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */, 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */, + E10D87DA2784E4F100BD264C /* ItemViewDetailsView.swift in Sources */, E1C812C3277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */, E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */, 091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */, @@ -2125,6 +2141,7 @@ E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, + E10D87DC2784EC5200BD264C /* EpisodesRowView.swift in Sources */, E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */, E1AA331D2782541500F6439C /* PrimaryButtonView.swift in Sources */, 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, @@ -2172,10 +2189,10 @@ E13DD3EF27178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, C40CD928271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */, + E10D87DE278510E400BD264C /* PosterSize.swift in Sources */, E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, - 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */, 625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2195,6 +2212,7 @@ E11B1B6E2718CDBA006DA3E8 /* JellyfinAPIError.swift in Sources */, 628B95372670CB800091AF3B /* JellyfinWidget.swift in Sources */, 6264E88E273850380081A12A /* Strings.swift in Sources */, + E10D87E0278510E400BD264C /* PosterSize.swift in Sources */, E1AD105426D97161003E4A08 /* BaseItemDtoExtensions.swift in Sources */, E1FCD09A26C4F35A007C8DCF /* ErrorMessage.swift in Sources */, 628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */, diff --git a/JellyfinPlayer/Components/EpisodeCardVStackView.swift b/JellyfinPlayer/Components/EpisodeCardVStackView.swift index fafad2ab..4df40dbd 100644 --- a/JellyfinPlayer/Components/EpisodeCardVStackView.swift +++ b/JellyfinPlayer/Components/EpisodeCardVStackView.swift @@ -60,7 +60,6 @@ struct EpisodeCardVStackView: View { .overlay( Rectangle() .fill(Color.jellyfinPurple) - .mask(ProgressBar()) .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 1.5), height: 7) .padding(0), alignment: .bottomLeading ) diff --git a/JellyfinPlayer/Components/PillHStackView.swift b/JellyfinPlayer/Components/PillHStackView.swift index d66cf279..f89bd307 100644 --- a/JellyfinPlayer/Components/PillHStackView.swift +++ b/JellyfinPlayer/Components/PillHStackView.swift @@ -13,7 +13,6 @@ struct PillHStackView: View { let title: String let items: [ItemType] -// let navigationView: (ItemType) -> NavigationView let selectedAction: (ItemType) -> Void var body: some View { diff --git a/JellyfinPlayer/Components/PortraitHStackView.swift b/JellyfinPlayer/Components/PortraitHStackView.swift index ec2e2ce9..5b09eac0 100644 --- a/JellyfinPlayer/Components/PortraitHStackView.swift +++ b/JellyfinPlayer/Components/PortraitHStackView.swift @@ -12,66 +12,70 @@ import SwiftUI struct PortraitImageHStackView: View { let items: [ItemType] - let maxWidth: Int + let maxWidth: CGFloat let horizontalAlignment: HorizontalAlignment + let textAlignment: TextAlignment let topBarView: () -> TopBarView let selectedAction: (ItemType) -> Void - init(items: [ItemType], maxWidth: Int, horizontalAlignment: HorizontalAlignment = .leading, topBarView: @escaping () -> TopBarView, selectedAction: @escaping (ItemType) -> Void) { + init(items: [ItemType], + maxWidth: CGFloat = 110, + horizontalAlignment: HorizontalAlignment = .leading, + textAlignment: TextAlignment = .leading, + topBarView: @escaping () -> TopBarView, + selectedAction: @escaping (ItemType) -> Void) { self.items = items self.maxWidth = maxWidth self.horizontalAlignment = horizontalAlignment + self.textAlignment = textAlignment self.topBarView = topBarView self.selectedAction = selectedAction } var body: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 0) { topBarView() ScrollView(.horizontal, showsIndicators: false) { - VStack { - Spacer().frame(height: 8) - HStack(alignment: .top) { - - Spacer().frame(width: 16) - - ForEach(items, id: \.title) { item in - Button { - selectedAction(item) - } label: { - VStack { - ImageView(src: item.imageURLContsructor(maxWidth: maxWidth), - bh: item.blurHash, - failureInitials: item.failureInitials) - .frame(width: 100, height: CGFloat(maxWidth)) - .cornerRadius(10) - .shadow(radius: 4, y: 2) + HStack(alignment: .top, spacing: 15) { + ForEach(items, id: \.self.portraitImageID) { item in + Button { + selectedAction(item) + } label: { + VStack(alignment: horizontalAlignment) { + ImageView(src: item.imageURLContsructor(maxWidth: Int(maxWidth)), + bh: item.blurHash, + failureInitials: item.failureInitials) + .frame(width: maxWidth, height: maxWidth * 1.5) + .cornerRadius(10) + .shadow(radius: 4, y: 2) + if item.showTitle { Text(item.title) .font(.footnote) .fontWeight(.regular) - .frame(width: 100) .foregroundColor(.primary) - .multilineTextAlignment(.center) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) .lineLimit(2) + } - if let description = item.description { - Text(description) - .font(.caption) - .fontWeight(.medium) - .frame(width: 100) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .lineLimit(2) - } + if let description = item.subtitle { + Text(description) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) } } + .frame(width: maxWidth) } - Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } } - }.padding(.top, -3) + .padding(.horizontal) + } } } } diff --git a/JellyfinPlayer/Components/PortraitItemView.swift b/JellyfinPlayer/Components/PortraitItemView.swift index cf638bad..79ef3b79 100644 --- a/JellyfinPlayer/Components/PortraitItemView.swift +++ b/JellyfinPlayer/Components/PortraitItemView.swift @@ -23,7 +23,6 @@ struct PortraitItemView: View { .shadow(radius: 4, y: 2) .overlay(Rectangle() .fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) - .mask(ProgressBar()) .frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7) .padding(0), alignment: .bottomLeading) .overlay(ZStack { diff --git a/JellyfinPlayer/Views/ContinueWatchingView.swift b/JellyfinPlayer/Views/ContinueWatchingView.swift index 2b2702c4..25e8e3e8 100644 --- a/JellyfinPlayer/Views/ContinueWatchingView.swift +++ b/JellyfinPlayer/Views/ContinueWatchingView.swift @@ -9,71 +9,79 @@ import JellyfinAPI import SwiftUI -struct ProgressBar: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - - let tl = CGPoint(x: rect.minX, y: rect.minY) - let tr = CGPoint(x: rect.maxX, y: rect.minY) - let br = CGPoint(x: rect.maxX, y: rect.maxY) - let bls = CGPoint(x: rect.minX + 10, y: rect.maxY) - let blc = CGPoint(x: rect.minX + 10, y: rect.maxY - 10) - - path.move(to: tl) - path.addLine(to: tr) - path.addLine(to: br) - path.addLine(to: bls) - path.addRelativeArc(center: blc, radius: 10, - startAngle: Angle.degrees(90), delta: Angle.degrees(90)) - - return path - } -} - struct ContinueWatchingView: View { + @EnvironmentObject var homeRouter: HomeCoordinator.Router - var items: [BaseItemDto] + @ObservedObject var viewModel: HomeViewModel var body: some View { ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(items, id: \.id) { item in + HStack(alignment: .top, spacing: 20) { + ForEach(viewModel.resumeItems, id: \.id) { item in + Button { homeRouter.route(to: \.item, item) } label: { VStack(alignment: .leading) { - ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash()) - .frame(width: 320, height: 180) - .cornerRadius(10) - .shadow(radius: 4, y: 2) - .shadow(radius: 4, y: 2) - .overlay(Rectangle() - .fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) - .mask(ProgressBar()) - .frame(width: CGFloat((item.userData?.playedPercentage ?? 0) * 3.2), height: 7) - .padding(0), alignment: .bottomLeading) - HStack { + + ZStack { + ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash()) + .frame(width: 320, height: 180) + + HStack { + VStack{ + + Spacer() + + ZStack(alignment: .bottom) { + + LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], + startPoint: .top, + endPoint: .bottom) + .frame(height: 35) + + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? "Continue") + .font(.subheadline) + .padding(.bottom, 5) + .padding(.leading, 10) + .foregroundColor(.white) + + HStack { + Color.jellyfinPurple + .frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) + + Spacer(minLength: 0) + } + } + } + } + } + } + .frame(width: 320, height: 180) + .mask(Rectangle().cornerRadius(10)) + .shadow(radius: 4, y: 2) + + VStack(alignment: .leading) { Text("\(item.seriesName ?? item.name ?? "")") .font(.callout) .fontWeight(.semibold) .foregroundColor(.primary) .lineLimit(1) - if item.type == "Episode" { - Text("• \(item.getEpisodeLocator() ?? "") - \(item.name ?? "")") + + if item.itemType == .episode { + Text(item.getEpisodeLocator() ?? "") .font(.callout) - .fontWeight(.semibold) + .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) - .offset(x: -1.4) } - Spacer() - }.frame(width: 320, alignment: .leading) - }.padding(.top, 10) - .padding(.bottom, 5) + } + } } - }.padding(.trailing, 16) - }.frame(height: 215) - .padding(EdgeInsets(top: 8, leading: 20, bottom: 10, trailing: 2)) + } + } + .padding(.horizontal) } } } diff --git a/JellyfinPlayer/Views/HomeView.swift b/JellyfinPlayer/Views/HomeView.swift index 892d0ab7..71e9f944 100644 --- a/JellyfinPlayer/Views/HomeView.swift +++ b/JellyfinPlayer/Views/HomeView.swift @@ -51,35 +51,49 @@ struct HomeView: View { ScrollView { VStack(alignment: .leading) { if !viewModel.resumeItems.isEmpty { - ContinueWatchingView(items: viewModel.resumeItems) + ContinueWatchingView(viewModel: viewModel) } if !viewModel.nextUpItems.isEmpty { - NextUpView(items: viewModel.nextUpItems) - } - - ForEach(viewModel.libraries, id: \.self) { library in - HStack { - Text(L10n.latestWithString(library.name ?? "")) + PortraitImageHStackView(items: viewModel.nextUpItems, + horizontalAlignment: .leading) { + L10n.nextUp.text .font(.title2) .fontWeight(.bold) - Spacer() - Button { - homeRouter - .route(to: \.library, (viewModel: .init(parentID: library.id!, - filters: viewModel.recentFilterSet), - title: library.name ?? "")) - } label: { - HStack { - L10n.seeAll.text.font(.subheadline).fontWeight(.bold) - Image(systemName: "chevron.right").font(Font.subheadline.bold()) + .padding() + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } + + } + + ForEach(viewModel.libraries, id: \.self) { library in + + LatestMediaView(viewModel: LatestMediaViewModel(libraryID: library.id!)) { + HStack { + Text(L10n.latestWithString(library.name ?? "")) + .font(.title2) + .fontWeight(.bold) + + Spacer() + + Button { + homeRouter + .route(to: \.library, (viewModel: .init(parentID: library.id!, + filters: viewModel.recentFilterSet), + title: library.name ?? "")) + } label: { + HStack { + L10n.seeAll.text.font(.subheadline).fontWeight(.bold) + Image(systemName: "chevron.right").font(Font.subheadline.bold()) + } } } - }.padding(.leading, 16) - .padding(.trailing, 16) - LatestMediaView(viewModel: .init(libraryID: library.id!)) + .padding() + } + } } - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .phone ? 20 : 30) + .padding(.bottom, 50) } .introspectScrollView { scrollView in let control = UIRefreshControl() diff --git a/JellyfinPlayer/Views/ItemView/EpisodesRowView.swift b/JellyfinPlayer/Views/ItemView/EpisodesRowView.swift new file mode 100644 index 00000000..a75504c8 --- /dev/null +++ b/JellyfinPlayer/Views/ItemView/EpisodesRowView.swift @@ -0,0 +1,150 @@ +// + /* + * 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 SwiftUI +import JellyfinAPI + +struct EpisodesRowView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router + @ObservedObject var viewModel: EpisodesRowViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + HStack { + Menu { + ForEach(Array(viewModel.seasonsEpisodes.keys).sorted(by: { $0.name ?? "" < $1.name ?? ""}), id:\.self) { season in + Button { + viewModel.selectedSeason = season + } label: { + if season.id == viewModel.selectedSeason?.id { + Label(season.name ?? "Season", systemImage: "checkmark") + } else { + Text(season.name ?? "Season") + } + } + } + } label: { + HStack(spacing: 5) { + Text(viewModel.selectedSeason?.name ?? "Unknown") + .fontWeight(.semibold) + .fixedSize() + Image(systemName: "chevron.down") + } + } + + Spacer() + } + .padding() + + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { reader in + HStack(alignment: .top, spacing: 15) { + if viewModel.isLoading { + VStack(alignment: .leading) { + + ZStack { + Color.gray.ignoresSafeArea() + + ProgressView() + } + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) + + VStack(alignment: .leading) { + Text("--") + .font(.footnote) + .foregroundColor(.secondary) + Text("Loading") + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + } + + Spacer() + } + .frame(width: 200) + } else if let selectedSeason = viewModel.selectedSeason { + if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty { + VStack(alignment: .leading) { + + Color.gray.ignoresSafeArea() + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) + + VStack(alignment: .leading) { + Text("--") + .font(.footnote) + .foregroundColor(.secondary) + Text("No episodes available") + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + } + + Spacer() + } + .frame(width: 200) + } else { + ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id:\.self) { episode in + Button { + itemRouter.route(to: \.item, episode) + } label: { + HStack(alignment: .top) { + VStack(alignment: .leading) { + + ImageView(src: episode.getBackdropImage(maxWidth: 200), + bh: episode.getBackdropImageBlurHash()) + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) + + VStack(alignment: .leading) { + Text(episode.getEpisodeLocator() ?? "") + .font(.footnote) + .foregroundColor(.secondary) + Text(episode.name ?? "") + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + Text(episode.overview ?? "") + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + .lineLimit(3) + } + + Spacer() + } + .frame(width: 200) + } + } + .buttonStyle(PlainButtonStyle()) + .id(episode.name) + } + } + } + } + .padding(.horizontal) + .onChange(of: viewModel.selectedSeason) { _ in + if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { + reader.scrollTo(viewModel.episodeItemViewModel.item.name) + } + } + .onChange(of: viewModel.seasonsEpisodes) { _ in + if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { + reader.scrollTo(viewModel.episodeItemViewModel.item.name) + } + } + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } +} diff --git a/JellyfinPlayer/Views/ItemView/ItemView.swift b/JellyfinPlayer/Views/ItemView/ItemView.swift index e95dbc72..0e257ba7 100644 --- a/JellyfinPlayer/Views/ItemView/ItemView.swift +++ b/JellyfinPlayer/Views/ItemView/ItemView.swift @@ -19,7 +19,11 @@ struct ItemNavigationView: View { var body: some View { ItemView(item: item) - .navigationBarTitle("", displayMode: .inline) + .navigationBarTitle(item.name ?? "", displayMode: .inline) + .introspectNavigationController { navigationController in + let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.clear] + navigationController.navigationBar.titleTextAttributes = textAttributes + } } } @@ -60,21 +64,6 @@ private struct ItemView: View { } label: { Image(systemName: "ellipsis.circle.fill") } - case .episode: - Menu { - Button { - (viewModel as? EpisodeItemViewModel)?.routeToSeriesItem() - } label: { - Label("Show Series", systemImage: "text.below.photo") - } - Button { - (viewModel as? EpisodeItemViewModel)?.routeToSeasonItem() - } label: { - Label("Show Season", systemImage: "square.fill.text.grid.1x2") - } - } label: { - Image(systemName: "ellipsis.circle.fill") - } default: EmptyView() } diff --git a/JellyfinPlayer/Views/ItemView/ItemViewBody.swift b/JellyfinPlayer/Views/ItemView/ItemViewBody.swift index 3df1d559..823cb270 100644 --- a/JellyfinPlayer/Views/ItemView/ItemViewBody.swift +++ b/JellyfinPlayer/Views/ItemView/ItemViewBody.swift @@ -7,12 +7,15 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Defaults import JellyfinAPI import SwiftUI struct ItemViewBody: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router @EnvironmentObject private var viewModel: ItemViewModel + @Default(.showCastAndCrew) var showCastAndCrew var body: some View { VStack(alignment: .leading) { @@ -27,13 +30,11 @@ struct ItemViewBody: View { if let seriesViewModel = viewModel as? SeriesItemViewModel { PortraitImageHStackView(items: seriesViewModel.seasons, - maxWidth: 150, topBarView: { L10n.seasons.text - .font(.callout) .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) + .padding(.bottom) + .padding(.horizontal) }, selectedAction: { season in itemRouter.route(to: \.item, season) }) @@ -46,6 +47,7 @@ struct ItemViewBody: View { selectedAction: { genre in itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) }) + .padding(.bottom) // MARK: Studios @@ -53,42 +55,66 @@ struct ItemViewBody: View { PillHStackView(title: L10n.studios, items: studios) { studio in itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + .padding(.bottom) + } + + // MARK: Episodes + + if let episodeViewModel = viewModel as? EpisodeItemViewModel { + EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: episodeViewModel)) + } + + if let episodeViewModel = viewModel as? EpisodeItemViewModel { + if let seriesItem = episodeViewModel.series { + let a = [seriesItem] + PortraitImageHStackView(items: a) { + Text("Series") + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + } selectedAction: { seriesItem in + itemRouter.route(to: \.item, seriesItem) + } } } // MARK: Cast & Crew - if let castAndCrew = viewModel.item.people { - PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }, - maxWidth: 150, - topBarView: { - Text("Cast & Crew") - .font(.callout) - .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) - }, - selectedAction: { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - }) + if showCastAndCrew { + if let castAndCrew = viewModel.item.people { + PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }, + topBarView: { + Text("Cast & Crew") + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + }, + selectedAction: { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + }) + } } // MARK: More Like This if !viewModel.similarItems.isEmpty { PortraitImageHStackView(items: viewModel.similarItems, - maxWidth: 150, topBarView: { L10n.moreLikeThis.text - .font(.callout) .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) + .padding(.bottom) + .padding(.horizontal) }, selectedAction: { item in itemRouter.route(to: \.item, item) }) } + + // MARK: Details + + ItemViewDetailsView(viewModel: viewModel) + .padding() } } } diff --git a/JellyfinPlayer/Views/ItemView/ItemViewDetailsView.swift b/JellyfinPlayer/Views/ItemView/ItemViewDetailsView.swift new file mode 100644 index 00000000..63e76eef --- /dev/null +++ b/JellyfinPlayer/Views/ItemView/ItemViewDetailsView.swift @@ -0,0 +1,58 @@ +// + /* + * 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 ItemViewDetailsView: View { + + @ObservedObject var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .leading) { + + if !viewModel.informationItems.isEmpty { + VStack(alignment: .leading, spacing: 20) { + Text("Information") + .font(.title3) + .fontWeight(.bold) + + ForEach(viewModel.informationItems, id: \.self.title) { informationItem in + VStack(alignment: .leading, spacing: 2) { + Text(informationItem.title) + .font(.subheadline) + Text(informationItem.content) + .font(.subheadline) + .foregroundColor(Color.secondary) + } + } + } + .padding(.bottom, 20) + } + + if !viewModel.mediaItems.isEmpty { + VStack(alignment: .leading, spacing: 20) { + Text("Media") + .font(.title3) + .fontWeight(.bold) + + ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in + VStack(alignment: .leading, spacing: 2) { + Text(mediaItem.title) + .font(.subheadline) + Text(mediaItem.content) + .font(.subheadline) + .foregroundColor(Color.secondary) + } + } + } + } + } + } +} diff --git a/JellyfinPlayer/Views/LatestMediaView.swift b/JellyfinPlayer/Views/LatestMediaView.swift index 300f435e..3672fef2 100644 --- a/JellyfinPlayer/Views/LatestMediaView.swift +++ b/JellyfinPlayer/Views/LatestMediaView.swift @@ -8,21 +8,18 @@ import Stinsen import SwiftUI -struct LatestMediaView: View { +struct LatestMediaView: View { + @EnvironmentObject var homeRouter: HomeCoordinator.Router @StateObject var viewModel: LatestMediaViewModel + var topBarView: () -> TopBarView var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(viewModel.items, id: \.id) { item in - Button { - homeRouter.route(to: \.item, item) - } label: { - PortraitItemView(item: item) - } - }.padding(.trailing, 16) - }.padding(.leading, 20) - }.frame(height: 200) + PortraitImageHStackView(items: viewModel.items, + horizontalAlignment: .leading) { + topBarView() + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } } } diff --git a/JellyfinPlayer/Views/LibraryListView.swift b/JellyfinPlayer/Views/LibraryListView.swift index 83bbd080..e6bf0364 100644 --- a/JellyfinPlayer/Views/LibraryListView.swift +++ b/JellyfinPlayer/Views/LibraryListView.swift @@ -38,27 +38,6 @@ struct LibraryListView: View { .shadow(radius: 5) .padding(.bottom, 5) - NavigationLink(destination: LazyView { - L10n.wip.text - }) { - ZStack { - HStack { - Spacer() - L10n.allGenres.text - .foregroundColor(.black) - .font(.subheadline) - .fontWeight(.semibold) - Spacer() - } - } - .padding(16) - .background(Color.white) - .frame(minWidth: 100, maxWidth: .infinity) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 15) - if !viewModel.isLoading { ForEach(viewModel.libraries, id: \.id) { library in if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" { diff --git a/JellyfinPlayer/Views/LoadingView.swift b/JellyfinPlayer/Views/LoadingView.swift deleted file mode 100644 index be7b67a2..00000000 --- a/JellyfinPlayer/Views/LoadingView.swift +++ /dev/null @@ -1,85 +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 SwiftUI - -struct LoadingView: View where Content: View { - @Environment(\.colorScheme) var colorScheme - @Binding var isShowing: Bool // should the modal be visible? - var content: () -> Content - var text: String? // the text to display under the ProgressView - defaults to "Loading..." - - var body: some View { - GeometryReader { _ in - ZStack(alignment: .center) { - // the content to display - if the modal is showing, we'll blur it - content() - .disabled(isShowing) - .blur(radius: isShowing ? 2 : 0) - - // all contents inside here will only be shown when isShowing is true - if isShowing { - // this Rectangle is a semi-transparent black overlay - Rectangle() - .fill(Color.black).opacity(isShowing ? 0.6 : 0) - .edgesIgnoringSafeArea(.all) - - // the magic bit - our ProgressView just displays an activity - // indicator, with some text underneath showing what we are doing - HStack { - ProgressView() - Text(text ?? L10n.loading).fontWeight(.semibold).font(.callout).offset(x: 60) - Spacer() - } - .padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 10)) - .frame(width: 250) - .background(colorScheme == .dark ? Color(UIColor.systemGray6) : Color.white) - .foregroundColor(Color.primary) - .cornerRadius(16) - } - } - } - } -} - -struct LoadingViewNoBlur: View where Content: View { - @Environment(\.colorScheme) var colorScheme - @Binding var isShowing: Bool // should the modal be visible? - var content: () -> Content - var text: String? // the text to display under the ProgressView - defaults to "Loading..." - - var body: some View { - GeometryReader { _ in - ZStack(alignment: .center) { - // the content to display - if the modal is showing, we'll blur it - content() - .disabled(isShowing) - - // all contents inside here will only be shown when isShowing is true - if isShowing { - // this Rectangle is a semi-transparent black overlay - Rectangle() - .fill(Color.black).opacity(isShowing ? 0.6 : 0) - .edgesIgnoringSafeArea(.all) - - // the magic bit - our ProgressView just displays an activity - // indicator, with some text underneath showing what we are doing - HStack { - ProgressView() - Text(text ?? L10n.loading).fontWeight(.semibold).font(.callout).offset(x: 60) - Spacer() - } - .padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 10)) - .frame(width: 250) - .background(colorScheme == .dark ? Color(UIColor.systemGray6) : Color.white) - .foregroundColor(Color.primary) - .cornerRadius(16) - } - } - } - } -} diff --git a/JellyfinPlayer/Views/SettingsView/OverlaySettingsView.swift b/JellyfinPlayer/Views/SettingsView/OverlaySettingsView.swift index eb0e611a..0154107b 100644 --- a/JellyfinPlayer/Views/SettingsView/OverlaySettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView/OverlaySettingsView.swift @@ -30,7 +30,7 @@ struct OverlaySettingsView: View { 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) - Toggle("Allow Edit Jump Lengths", isOn: $shouldShowJumpButtonsInOverlayMenu) + Toggle("Edit Jump Lengths", isOn: $shouldShowJumpButtonsInOverlayMenu) } } } diff --git a/JellyfinPlayer/Views/SettingsView/SettingsView.swift b/JellyfinPlayer/Views/SettingsView/SettingsView.swift index 0cbe663d..ab98e490 100644 --- a/JellyfinPlayer/Views/SettingsView/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView/SettingsView.swift @@ -25,6 +25,8 @@ struct SettingsView: View { @Default(.videoPlayerJumpForward) var jumpForwardLength @Default(.videoPlayerJumpBackward) var jumpBackwardLength @Default(.jumpGesturesEnabled) var jumpGesturesEnabled + @Default(.showPosterLabels) var showPosterLabels + @Default(.showCastAndCrew) var showCastAndCrew var body: some View { Form { @@ -114,26 +116,9 @@ struct SettingsView: View { } 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 })) + Toggle("Show Poster Labels", isOn: $showPosterLabels) + Toggle("Show Cast and Crew", isOn: $showCastAndCrew) + Picker(L10n.appearance, selection: $appAppearance) { ForEach(AppAppearance.allCases, id: \.self) { appearance in Text(appearance.localizedName).tag(appearance.rawValue) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 5a6a1d74..880a9244 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -84,10 +84,9 @@ class VLCPlayerViewController: UIViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) + NotificationCenter.default.removeObserver(self) + + SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didEndPlayback, object: nil) } // MARK: viewDidLoad diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift index b4b23403..1118f3c4 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift @@ -7,24 +7,36 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Defaults import Foundation import JellyfinAPI // MARK: PortraitImageStackable extension BaseItemDto: PortraitImageStackable { + public var portraitImageID: String { + return id ?? "no id" + } + public func imageURLContsructor(maxWidth: Int) -> URL { - return self.getPrimaryImage(maxWidth: maxWidth) + switch self.itemType { + case .episode: + return getSeriesPrimaryImage(maxWidth: maxWidth) + default: + return self.getPrimaryImage(maxWidth: maxWidth) + } } public var title: String { - return self.name ?? "" + switch self.itemType { + case .episode: + return self.seriesName ?? self.name ?? "" + default: + return self.name ?? "" + } } - public var description: String? { + public var subtitle: String? { switch self.itemType { - case .season: - guard let productionYear = productionYear else { return nil } - return "\(productionYear)" case .episode: return getEpisodeLocator() default: @@ -41,4 +53,13 @@ extension BaseItemDto: PortraitImageStackable { let initials = name.split(separator: " ").compactMap({ String($0).first }) return String(initials) } + + public var showTitle: Bool { + switch self.itemType { + case .episode, .series, .movie: + return Defaults[.showPosterLabels] + default: + return true + } + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index 6068f539..4ee051d4 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -84,6 +84,11 @@ extension BaseItemDto { var subtitle: String? = nil + // MARK: Attach media content to self + + var modifiedSelfItem = self + modifiedSelfItem.mediaStreams = mediaSource.mediaStreams + // TODO: other forms of media subtitle if self.itemType == .episode { if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { @@ -101,8 +106,8 @@ extension BaseItemDto { let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode - let videoPlayerViewModel = VideoPlayerViewModel(item: self, - title: self.name ?? "", + let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem, + title: modifiedSelfItem.name ?? "", subtitle: subtitle, streamURL: streamURL.url!, hlsURL: hlsURL.url!, diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 370635dc..24f689b7 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -156,9 +156,9 @@ public extension BaseItemDto { return text } - func getItemProgressString() -> String { + func getItemProgressString() -> String? { if userData?.playbackPositionTicks == nil || userData?.playbackPositionTicks == 0 { - return "" + return nil } let remainingSecs = ((runTimeTicks ?? 0) - (userData?.playbackPositionTicks ?? 0)) / 10_000_000 @@ -208,4 +208,60 @@ public extension BaseItemDto { return getPrimaryImage(maxWidth: maxWidth) } } + + // MARK: ItemDetail + + struct ItemDetail { + let title: String + let content: String + } + + func createInformationItems() -> [ItemDetail] { + var informationItems: [ItemDetail] = [] + + if let productionYear = productionYear { + informationItems.append(ItemDetail(title: "Released", content: "\(productionYear)")) + } + + if let rating = officialRating { + informationItems.append(ItemDetail(title: "Rated", content: "\(rating)")) + } + + if let runtime = getItemRuntime() { + informationItems.append(ItemDetail(title: "Runtime", content: runtime)) + } + + return informationItems + } + + func createMediaItems() -> [ItemDetail] { + var mediaItems: [ItemDetail] = [] + + if let container = container { + let containerList = container.split(separator: ",").joined(separator: ", ") + + if containerList.count > 1 { + mediaItems.append(ItemDetail(title: "Containers", content: containerList)) + } else { + mediaItems.append(ItemDetail(title: "Container", content: containerList)) + } + } + + if let mediaStreams = mediaStreams { + let audioStreams = mediaStreams.filter({ $0.type == .audio }) + let subtitleStreams = mediaStreams.filter({ $0.type == .subtitle }) + + if !audioStreams.isEmpty { + let audioList = audioStreams.compactMap({ $0.displayTitle }).joined(separator: ", ") + mediaItems.append(ItemDetail(title: "Audio", content: audioList)) + } + + if !subtitleStreams.isEmpty { + let subtitleList = subtitleStreams.compactMap({ "\($0.displayTitle ?? "No Title") (\($0.codec ?? "No Codec"))" }).joined(separator: ", ") + mediaItems.append(ItemDetail(title: "Subtitles", content: subtitleList)) + } + } + + return mediaItems + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift index dc2f1c98..2c7cc7af 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift @@ -60,6 +60,10 @@ extension BaseItemPerson { // MARK: PortraitImageStackable extension BaseItemPerson: PortraitImageStackable { + public var portraitImageID: String { + return (id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials + } + public func imageURLContsructor(maxWidth: Int) -> URL { return self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth) } @@ -68,7 +72,7 @@ extension BaseItemPerson: PortraitImageStackable { return self.name ?? "" } - public var description: String? { + public var subtitle: String? { return self.firstRole() } @@ -81,6 +85,10 @@ extension BaseItemPerson: PortraitImageStackable { let initials = name.split(separator: " ").compactMap({ String($0).first }) return String(initials) } + + public var showTitle: Bool { + return true + } } // MARK: DiplayedType diff --git a/Shared/Objects/PortraitImageStackable.swift b/Shared/Objects/PortraitImageStackable.swift index e866de31..ceb98529 100644 --- a/Shared/Objects/PortraitImageStackable.swift +++ b/Shared/Objects/PortraitImageStackable.swift @@ -12,7 +12,9 @@ import Foundation public protocol PortraitImageStackable { func imageURLContsructor(maxWidth: Int) -> URL var title: String { get } - var description: String? { get } + var subtitle: String? { get } var blurHash: String { get } var failureInitials: String { get } + var portraitImageID: String { get } + var showTitle: Bool { get } } diff --git a/Shared/Objects/PosterSize.swift b/Shared/Objects/PosterSize.swift new file mode 100644 index 00000000..799a27a7 --- /dev/null +++ b/Shared/Objects/PosterSize.swift @@ -0,0 +1,15 @@ +// + /* + * 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 Foundation + +enum PosterSize { + case small + case normal +} diff --git a/Shared/Singleton/SwiftfinNotificationCenter.swift b/Shared/Singleton/SwiftfinNotificationCenter.swift index a5d8407f..a9382880 100644 --- a/Shared/Singleton/SwiftfinNotificationCenter.swift +++ b/Shared/Singleton/SwiftfinNotificationCenter.swift @@ -21,5 +21,7 @@ enum SwiftfinNotificationCenter { static let processDeepLink = Notification.Name("processDeepLink") static let didPurge = Notification.Name("didPurge") static let didChangeServerCurrentURI = Notification.Name("didChangeCurrentLoginURI") + + static let didEndPlayback = Notification.Name("didEndPlayback") } } diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 9a9400be..5805882b 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -38,6 +38,11 @@ extension Defaults.Keys { static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) + // Customize settings + static let showPosterLabels = Key("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let showCastAndCrew = Key("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite) + + // Video player / overlay settings static let overlayType = Key("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) diff --git a/Shared/ViewModels/EpisodeItemViewModel.swift b/Shared/ViewModels/EpisodeItemViewModel.swift index a51b92b2..00d11973 100644 --- a/Shared/ViewModels/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/EpisodeItemViewModel.swift @@ -15,12 +15,12 @@ import Stinsen final class EpisodeItemViewModel: ItemViewModel { @RouterObject var itemRouter: ItemCoordinator.Router? - var seasonEpisodes: [BaseItemDto] = [] + @Published var series: BaseItemDto? override init(item: BaseItemDto) { super.init(item: item) - getSeasonEpisodes() + getEpisodeSeries() } override func getItemDisplayName() -> String { @@ -32,41 +32,15 @@ final class EpisodeItemViewModel: ItemViewModel { return false } - func routeToSeasonItem() { - guard let id = item.seasonId else { return } - UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] item in - self?.itemRouter?.route(to: \.item, item) - }) - .store(in: &cancellables) - } - - func routeToSeriesItem() { + func getEpisodeSeries() { guard let id = item.seriesId else { return } UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] item in - self?.itemRouter?.route(to: \.item, item) + self?.series = item }) .store(in: &cancellables) } - - private func getSeasonEpisodes() { - guard let seriesID = item.seriesId else { return } - TvShowsAPI.getEpisodes(seriesId: seriesID, - userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - seasonId: item.seasonId ?? "") - .sink { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - } receiveValue: { [weak self] item in - self?.seasonEpisodes = item.items ?? [] - } - .store(in: &cancellables) - } } diff --git a/Shared/ViewModels/EpisodesRowViewModel.swift b/Shared/ViewModels/EpisodesRowViewModel.swift new file mode 100644 index 00000000..19874d51 --- /dev/null +++ b/Shared/ViewModels/EpisodesRowViewModel.swift @@ -0,0 +1,65 @@ +// + /* + * 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 + +final class EpisodesRowViewModel: ViewModel { + + @ObservedObject var episodeItemViewModel: EpisodeItemViewModel + @Published var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] + @Published var selectedSeason: BaseItemDto? { + willSet { + if seasonsEpisodes[newValue!]!.isEmpty { + retrieveEpisodesForSeason(newValue!) + } + } + } + + init(episodeItemViewModel: EpisodeItemViewModel) { + self.episodeItemViewModel = episodeItemViewModel + super.init() + + retrieveSeasons() + } + + private func retrieveSeasons() { + TvShowsAPI.getSeasons(seriesId: episodeItemViewModel.item.seriesId ?? "", + userId: SessionManager.main.currentLogin.user.id) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { response in + let seasons = response.items ?? [] + seasons.forEach { season in + self.seasonsEpisodes[season] = [] + + if season.id == self.episodeItemViewModel.item.seasonId ?? "" { + self.selectedSeason = season + } + } + } + .store(in: &cancellables) + } + + private func retrieveEpisodesForSeason(_ season: BaseItemDto) { + guard let seasonID = season.id else { return } + + TvShowsAPI.getEpisodes(seriesId: episodeItemViewModel.item.seriesId ?? "", + userId: SessionManager.main.currentLogin.user.id, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + seasonId: seasonID) + .trackActivity(loading) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { episodes in + self.seasonsEpisodes[season] = episodes.items ?? [] + } + .store(in: &cancellables) + } +} diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 6143c459..a162a6fb 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -32,9 +32,14 @@ final class HomeViewModel: ViewModel { let nc = SwiftfinNotificationCenter.main nc.addObserver(self, selector: #selector(didSignIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) nc.addObserver(self, selector: #selector(didSignOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) + nc.addObserver(self, selector: #selector(didEndPlayback), name: SwiftfinNotificationCenter.Keys.didEndPlayback, object: nil) + } + + deinit { + SwiftfinNotificationCenter.main.removeObserver(self) } - @objc func didSignIn() { + @objc private func didSignIn() { for cancellable in cancellables { cancellable.cancel() } @@ -47,16 +52,29 @@ final class HomeViewModel: ViewModel { refresh() } - @objc func didSignOut() { + @objc private func didSignOut() { for cancellable in cancellables { cancellable.cancel() } cancellables.removeAll() } + + @objc private func didEndPlayback() { + refreshResumeItems() + refreshNextUpItems() + } - func refresh() { + @objc func refresh() { LogManager.shared.log.debug("Refresh called.") + + refreshLibrariesLatest() + refreshResumeItems() + refreshNextUpItems() + } + + // MARK: Libraries Latest Items + private func refreshLibrariesLatest() { UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) .trackActivity(loading) .sink(receiveCompletion: { completion in @@ -101,7 +119,10 @@ final class HomeViewModel: ViewModel { .store(in: &self.cancellables) }) .store(in: &cancellables) - + } + + // MARK: Resume Items + private func refreshResumeItems() { ItemsAPI.getResumeItems(userId: SessionManager.main.currentLogin.user.id, limit: 12, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], mediaTypes: ["Video"], @@ -121,7 +142,10 @@ final class HomeViewModel: ViewModel { self.resumeItems = response.items ?? [] }) .store(in: &cancellables) - + } + + // MARK: Next Up Items + private func refreshNextUpItems() { TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, limit: 12, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters]) .trackActivity(loading) diff --git a/Shared/ViewModels/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel.swift index 0a0b6664..d37ec64d 100644 --- a/Shared/ViewModels/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel.swift @@ -19,6 +19,8 @@ class ItemViewModel: ViewModel { @Published var similarItems: [BaseItemDto] = [] @Published var isWatched = false @Published var isFavorited = false + @Published var informationItems: [BaseItemDto.ItemDetail] + @Published var mediaItems: [BaseItemDto.ItemDetail] var itemVideoPlayerViewModel: VideoPlayerViewModel? init(item: BaseItemDto) { @@ -29,6 +31,9 @@ class ItemViewModel: ViewModel { self.playButtonItem = item default: () } + + informationItems = item.createInformationItems() + mediaItems = item.createMediaItems() isFavorited = item.userData?.isFavorite ?? false isWatched = item.userData?.played ?? false @@ -41,12 +46,17 @@ class ItemViewModel: ViewModel { self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in self.itemVideoPlayerViewModel = videoPlayerViewModel + self.mediaItems = videoPlayerViewModel.item.createMediaItems() } .store(in: &cancellables) } func playButtonText() -> String { - return item.getItemProgressString() == "" ? L10n.play : item.getItemProgressString() + if let itemProgressString = item.getItemProgressString() { + return itemProgressString + } + + return L10n.play } func getItemDisplayName() -> String { From 00aaa246fd637cc6caf9eb34ac3c8a750f9ce3ec Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 19:07:18 -0700 Subject: [PATCH 51/62] begin work on embedded subtitles --- .../VideoPlayer/VLCPlayerViewController.swift | 1 - Shared/ViewModels/VideoPlayerViewModel.swift | 20 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 880a9244..7a6e02a8 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -552,7 +552,6 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { /// Do not call when setting to index -1 func didSelectSubtitleStream(index: Int) { - viewModel.subtitlesEnabled = true vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index cba905f3..1793ad39 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -471,9 +471,23 @@ extension VideoPlayerViewModel { } } -// MARK: Embedded SubtitleStreamViewModel +// MARK: Embedded/Normal Subtitle Streams extension VideoPlayerViewModel { - - + func createEmbeddedSubtitleStream(with subtitleStream: MediaStream) -> URL { + + guard let baseURL = URLComponents(url: streamURL, resolvingAgainstBaseURL: false) else { fatalError() } + guard let queryItems = baseURL.queryItems else { fatalError() } + + var newURL = baseURL + var newQueryItems = queryItems + + newQueryItems.removeAll(where: { $0.name == "SubtitleStreamIndex" }) + newQueryItems.removeAll(where: { $0.name == "SubtitleMethod" }) + + newURL.addQueryItem(name: "SubtitleMethod", value: "Encode") + newURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(subtitleStream.index ?? -1)") + + return newURL.url! + } } From f2374de29130e4c963f54b43eba37156479ca579 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 20:35:00 -0700 Subject: [PATCH 52/62] more final work --- .../Components/LandscapeItemElement.swift | 2 +- .../CinematicEpisodeItemView.swift | 3 +- .../Views/ItemView/EpisodesRowView.swift | 128 +++++++++++++----- .../Views/ItemView/ItemDetailsView.swift | 13 +- JellyfinPlayer.xcodeproj/project.pbxproj | 4 +- .../Views/ItemView/EpisodesRowView.swift | 4 +- .../iOSVideoPlayerCoordinator.swift | 2 +- .../SwiftfinNotificationCenter.swift | 2 - .../ViewModels/ConnectToServerViewModel.swift | 6 +- Shared/ViewModels/HomeViewModel.swift | 10 -- 10 files changed, 111 insertions(+), 63 deletions(-) diff --git a/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift b/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift index 23b07c1d..f4932fb6 100644 --- a/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift +++ b/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift @@ -70,7 +70,7 @@ struct LandscapeItemElement: View { .frame(width: 445, height: 90) .mask(CutOffShadow()) VStack(alignment: .leading) { - Text("CONTINUE • \(item.getItemProgressString())") + Text("CONTINUE • \(item.getItemProgressString() ?? "")") .font(.caption) .fontWeight(.medium) .offset(y: 5) diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift index 014dc7ee..97aa4baa 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift @@ -32,12 +32,13 @@ struct CinematicEpisodeItemView: View { ZStack(alignment: .topLeading) { Color.black.ignoresSafeArea() + .frame(minHeight: UIScreen.main.bounds.height) VStack(alignment: .leading, spacing: 20) { CinematicItemAboutView(viewModel: viewModel) - EpisodesRowView(viewModel: viewModel) + EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: viewModel)) .focusSection() if !viewModel.similarItems.isEmpty { diff --git a/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift b/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift index 1f1faf17..735216a0 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift @@ -13,58 +13,118 @@ import SwiftUI struct EpisodesRowView: View { @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: EpisodeItemViewModel + @ObservedObject var viewModel: EpisodesRowViewModel var body: some View { VStack(alignment: .leading) { - Text("Episodes") + Text(viewModel.selectedSeason?.name ?? "Episodes") .font(.title3) .padding(.horizontal, 50) ScrollView(.horizontal) { ScrollViewReader { reader in HStack(alignment: .top) { - ForEach(viewModel.seasonEpisodes, id:\.self) { episode in - Button { - itemRouter.route(to: \.item, episode) - } label: { - HStack(alignment: .top) { + if viewModel.isLoading { + VStack(alignment: .leading) { + + ZStack { + Color.secondary.ignoresSafeArea() + + ProgressView() + } + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) + + VStack(alignment: .leading) { + Text("S-E-") + .font(.caption) + .foregroundColor(.secondary) + Text("--") + .font(.footnote) + .padding(.bottom, 1) + Text("--") + .font(.caption) + .fontWeight(.light) + .lineLimit(4) + } + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + .focusable() + } else if let selectedSeason = viewModel.selectedSeason { + if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty { + VStack(alignment: .leading) { + + Color.secondary + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) + VStack(alignment: .leading) { - - ImageView(src: episode.getBackdropImage(maxWidth: 445), - bh: episode.getBackdropImageBlurHash()) - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) - - VStack(alignment: .leading) { - Text(episode.getEpisodeLocator() ?? "") - .font(.caption) - .foregroundColor(.secondary) - Text(episode.name ?? "") - .font(.footnote) - .padding(.bottom, 1) - Text(episode.overview ?? "") - .font(.caption) - .fontWeight(.light) - .lineLimit(4) - } - .padding(.horizontal) - - Spacer() + Text("--") + .font(.caption) + .foregroundColor(.secondary) + Text("No episodes available") + .font(.footnote) + .padding(.bottom, 1) } - .frame(width: 500) + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + .focusable() + } else { + ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id:\.self) { episode in + Button { + itemRouter.route(to: \.item, episode) + } label: { + HStack(alignment: .top) { + VStack(alignment: .leading) { + + ImageView(src: episode.getBackdropImage(maxWidth: 445), + bh: episode.getBackdropImageBlurHash()) + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) + + VStack(alignment: .leading) { + Text(episode.getEpisodeLocator() ?? "") + .font(.caption) + .foregroundColor(.secondary) + Text(episode.name ?? "") + .font(.footnote) + .padding(.bottom, 1) + Text(episode.overview ?? "") + .font(.caption) + .fontWeight(.light) + .lineLimit(4) + } + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + } + } + .buttonStyle(PlainButtonStyle()) + .id(episode.name) } } - .buttonStyle(PlainButtonStyle()) - .id(episode.name) } } .padding(.horizontal, 50) .padding(.vertical) - .onAppear { - // TODO: Get this working - reader.scrollTo(viewModel.item.name) + .onChange(of: viewModel.selectedSeason) { _ in + if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { + reader.scrollTo(viewModel.episodeItemViewModel.item.name) + } + } + .onChange(of: viewModel.seasonsEpisodes) { _ in + if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { + reader.scrollTo(viewModel.episodeItemViewModel.item.name) + } } } .edgesIgnoringSafeArea(.horizontal) diff --git a/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift b/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift index b302e613..c235679b 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift @@ -23,12 +23,12 @@ struct ItemDetailsView: View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 20) { - Text("Details") + Text("Information") .font(.title3) .padding(.bottom, 5) - ForEach(detailItems, id: \.self.0) { (title, content) in - ItemDetail(title: title, content: content) + ForEach(viewModel.informationItems, id: \.self.title) { informationItem in + ItemDetail(title: informationItem.title, content: informationItem.content) } } @@ -39,8 +39,8 @@ struct ItemDetailsView: View { .font(.title3) .padding(.bottom, 5) - ForEach(mediaItems, id: \.self.0) { (title, content) in - ItemDetail(title: title, content: content) + ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in + ItemDetail(title: mediaItem.title, content: mediaItem.content) } } @@ -49,8 +49,7 @@ struct ItemDetailsView: View { .ignoresSafeArea() .focusable() .focused($focused) - .padding(.horizontal, 50) - .padding(.bottom, 50) + .padding(50) } } } diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 9fd95481..d52dc955 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -1324,8 +1324,8 @@ E14F7D0A26DB3714007C3AE6 /* ItemView */ = { isa = PBXGroup; children = ( - 535BAE9E2649E569005FA86D /* ItemView.swift */, E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */, + 535BAE9E2649E569005FA86D /* ItemView.swift */, E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */, E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */, E18845FB26DEACC400B0C5B7 /* Landscape */, @@ -1399,8 +1399,8 @@ children = ( E1E5D53C2783A85F00692DFE /* CinematicItemView */, 53272538268C20100035FBF1 /* EpisodeItemView.swift */, - E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */, E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */, + E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */, 53CD2A3F268A49C2002ABD4E /* ItemView.swift */, 53CD2A41268A4B38002ABD4E /* MovieItemView.swift */, E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */, diff --git a/JellyfinPlayer/Views/ItemView/EpisodesRowView.swift b/JellyfinPlayer/Views/ItemView/EpisodesRowView.swift index a75504c8..d327341b 100644 --- a/JellyfinPlayer/Views/ItemView/EpisodesRowView.swift +++ b/JellyfinPlayer/Views/ItemView/EpisodesRowView.swift @@ -59,10 +59,10 @@ struct EpisodesRowView: View { .frame(width: 200, height: 112) VStack(alignment: .leading) { - Text("--") + Text("S-E-") .font(.footnote) .foregroundColor(.secondary) - Text("Loading") + Text("--") .font(.body) .padding(.bottom, 1) .lineLimit(2) diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift index ea74d736..33ff2ee4 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift @@ -32,7 +32,7 @@ final class VideoPlayerCoordinator: NavigationCoordinatable { .statusBar(hidden: true) .ignoresSafeArea() .prefersHomeIndicatorAutoHidden(true) - .supportedOrientations(.landscape) + .supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape) }.ignoresSafeArea() } } diff --git a/Shared/Singleton/SwiftfinNotificationCenter.swift b/Shared/Singleton/SwiftfinNotificationCenter.swift index a9382880..a5d8407f 100644 --- a/Shared/Singleton/SwiftfinNotificationCenter.swift +++ b/Shared/Singleton/SwiftfinNotificationCenter.swift @@ -21,7 +21,5 @@ enum SwiftfinNotificationCenter { static let processDeepLink = Notification.Name("processDeepLink") static let didPurge = Notification.Name("didPurge") static let didChangeServerCurrentURI = Notification.Name("didChangeCurrentLoginURI") - - static let didEndPlayback = Notification.Name("didEndPlayback") } } diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index 378c297b..4e2ec728 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -49,10 +49,10 @@ final class ConnectToServerViewModel: ViewModel { } #endif - let uri = uri.trimmingCharacters(in: .whitespaces) + let trimmedURI = uri.trimmingCharacters(in: .whitespaces) - LogManager.shared.log.debug("Attempting to connect to server at \"\(uri)\"", tag: "connectToServer") - SessionManager.main.connectToServer(with: uri) + LogManager.shared.log.debug("Attempting to connect to server at \"\(trimmedURI)\"", tag: "connectToServer") + SessionManager.main.connectToServer(with: trimmedURI) .trackActivity(loading) .sink(receiveCompletion: { completion in // This is disgusting. ViewModel Error handling overall needs to be refactored diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index a162a6fb..e00f07c5 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -32,11 +32,6 @@ final class HomeViewModel: ViewModel { let nc = SwiftfinNotificationCenter.main nc.addObserver(self, selector: #selector(didSignIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) nc.addObserver(self, selector: #selector(didSignOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) - nc.addObserver(self, selector: #selector(didEndPlayback), name: SwiftfinNotificationCenter.Keys.didEndPlayback, object: nil) - } - - deinit { - SwiftfinNotificationCenter.main.removeObserver(self) } @objc private func didSignIn() { @@ -59,11 +54,6 @@ final class HomeViewModel: ViewModel { cancellables.removeAll() } - - @objc private func didEndPlayback() { - refreshResumeItems() - refreshNextUpItems() - } @objc func refresh() { LogManager.shared.log.debug("Refresh called.") From 07ee90c29fc9f1fa98f9ac3718bd4af00bb7dd9c Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 20:35:33 -0700 Subject: [PATCH 53/62] Update VLCPlayerViewController.swift --- JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 7a6e02a8..325ada14 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -85,8 +85,6 @@ class VLCPlayerViewController: UIViewController { super.viewWillDisappear(animated) NotificationCenter.default.removeObserver(self) - - SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didEndPlayback, object: nil) } // MARK: viewDidLoad From 8d6e5fb897a9b25127681b29972550dff491733e Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 20:39:08 -0700 Subject: [PATCH 54/62] fix normal overlay --- .../Views/VideoPlayer/VLCPlayerOverlayView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift index 5e7dce0d..a61ea653 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift @@ -359,12 +359,13 @@ struct VLCPlayerOverlayView: View { var body: some View { if viewModel.overlayType == .normal { mainBody + .contentShape(Rectangle()) + .onTapGesture { + viewModel.playerOverlayDelegate?.didGenerallyTap() + } .background { Color(uiColor: .black.withAlphaComponent(0.5)) .ignoresSafeArea() - .onTapGesture { - viewModel.playerOverlayDelegate?.didGenerallyTap() - } } } else { mainBody From a7f5b15a8a8c10ccf568af69124ac6bdcda4344f Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 20:43:00 -0700 Subject: [PATCH 55/62] audio codec in details --- .../JellyfinAPIExtensions/BaseItemDtoExtensions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 24f689b7..5a9e508f 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -252,7 +252,7 @@ public extension BaseItemDto { let subtitleStreams = mediaStreams.filter({ $0.type == .subtitle }) if !audioStreams.isEmpty { - let audioList = audioStreams.compactMap({ $0.displayTitle }).joined(separator: ", ") + let audioList = audioStreams.compactMap({ "\($0.displayTitle ?? "No Title") (\($0.codec ?? "No Codec"))" }).joined(separator: ", ") mediaItems.append(ItemDetail(title: "Audio", content: audioList)) } From 6c1d99db7ce9f705585877132242cd01f6b98b82 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 21:48:38 -0700 Subject: [PATCH 56/62] final tvos work --- JellyfinPlayer tvOS/Views/HomeView.swift | 55 +++++++++++-------- .../CinematicEpisodeItemView.swift | 14 ++--- .../CinematicItemAboutView.swift | 1 + .../CinematicMovieItemView.swift | 14 ++--- .../Views/ItemView/PortraitItemsRowView.swift | 21 +++++-- .../Views/SettingsView/SettingsView.swift | 6 +- 6 files changed, 64 insertions(+), 47 deletions(-) diff --git a/JellyfinPlayer tvOS/Views/HomeView.swift b/JellyfinPlayer tvOS/Views/HomeView.swift index a340d57a..243ea8d6 100644 --- a/JellyfinPlayer tvOS/Views/HomeView.swift +++ b/JellyfinPlayer tvOS/Views/HomeView.swift @@ -21,38 +21,49 @@ struct HomeView: View { Color.black .ignoresSafeArea() - ScrollView { - if viewModel.isLoading { - ProgressView() - } else { + if viewModel.isLoading { + ProgressView() + .scaleEffect(2) + } else { + ScrollView { LazyVStack(alignment: .leading) { if !viewModel.resumeItems.isEmpty { ContinueWatchingView(items: viewModel.resumeItems) } + if !viewModel.nextUpItems.isEmpty { NextUpView(items: viewModel.nextUpItems) } - if !viewModel.librariesShowRecentlyAddedIDs.isEmpty { - ForEach(viewModel.librariesShowRecentlyAddedIDs, id: \.self) { libraryID in - VStack(alignment: .leading) { - let library = viewModel.libraries.first(where: { $0.id == libraryID }) - - Button { - self.homeRouter.route(to: \.modalLibrary, (.init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? "")) - } label: { - HStack { - Text(L10n.latestWithString(library?.name ?? "")) - .font(.headline) - .fontWeight(.semibold) - Image(systemName: "chevron.forward.circle.fill") - } - }.padding(EdgeInsets(top: 0, leading: 90, bottom: 0, trailing: 0)) - LatestMediaView(usingParentID: libraryID) + ForEach(viewModel.libraries, id: \.self) { library in + Button { + self.homeRouter.route(to: \.modalLibrary, (.init(parentID: library.id, filters: viewModel.recentFilterSet), title: library.name ?? "")) + } label: { + HStack { + Text(L10n.latestWithString(library.name ?? "")) + .font(.headline) + .fontWeight(.semibold) + Image(systemName: "chevron.forward.circle.fill") } - } + }.padding(EdgeInsets(top: 0, leading: 90, bottom: 0, trailing: 0)) + + LatestMediaView(usingParentID: library.id ?? "") } - Spacer().frame(height: 30) + + Spacer(minLength: 100) + + HStack { + Spacer() + + Button { + viewModel.refresh() + } label: { + Text("Refresh") + } + + Spacer() + } + .focusSection() } } } diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift index 97aa4baa..e438421d 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift @@ -7,6 +7,7 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Defaults import Introspect import SwiftUI @@ -14,6 +15,7 @@ struct CinematicEpisodeItemView: View { @ObservedObject var viewModel: EpisodeItemViewModel @State var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) var showPosterLabels var body: some View { ZStack { @@ -42,18 +44,12 @@ struct CinematicEpisodeItemView: View { .focusSection() if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: "Recommended", items: viewModel.similarItems) + PortraitItemsRowView(rowTitle: "Recommended", + items: viewModel.similarItems, + showItemTitles: showPosterLabels) } ItemDetailsView(viewModel: viewModel) - -// HStack { -// SFSymbolButton(systemName: "heart.fill", pointSize: 48, action: {}) -// .frame(width: 60, height: 60) -// SFSymbolButton(systemName: "checkmark.circle", pointSize: 48, action: {}) -// .frame(width: 60, height: 60) -// } -// .padding(.horizontal, 50) } .padding(.top, 50) } diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift index 476f8871..2b417eab 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift @@ -31,6 +31,7 @@ struct CinematicItemAboutView: View { Text(viewModel.item.overview ?? "No details available") .padding(.top, 2) + .lineLimit(7) } .padding() } diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift index 9f202917..6363fcf5 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift @@ -7,6 +7,7 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Defaults import Introspect import SwiftUI @@ -14,6 +15,7 @@ struct CinematicMovieItemView: View { @ObservedObject var viewModel: MovieItemViewModel @State var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) var showPosterLabels var body: some View { ZStack { @@ -38,18 +40,12 @@ struct CinematicMovieItemView: View { CinematicItemAboutView(viewModel: viewModel) if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: "Recommended", items: viewModel.similarItems) + PortraitItemsRowView(rowTitle: "Recommended", + items: viewModel.similarItems, + showItemTitles: showPosterLabels) } ItemDetailsView(viewModel: viewModel) - -// HStack { -// SFSymbolButton(systemName: "heart.fill", pointSize: 48, action: {}) -// .frame(width: 60, height: 60) -// SFSymbolButton(systemName: "checkmark.circle", pointSize: 48, action: {}) -// .frame(width: 60, height: 60) -// } -// .padding(.horizontal, 50) } .padding(.top, 50) diff --git a/JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift b/JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift index 53a3b7e9..08358d3b 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift @@ -16,6 +16,13 @@ struct PortraitItemsRowView: View { let rowTitle: String let items: [BaseItemDto] + let showItemTitles: Bool + + init(rowTitle: String, items: [BaseItemDto], showItemTitles: Bool = true) { + self.rowTitle = rowTitle + self.items = items + self.showItemTitles = showItemTitles + } var body: some View { VStack(alignment: .leading) { @@ -32,15 +39,17 @@ struct PortraitItemsRowView: View { Button { itemRouter.route(to: \.item, item) } label: { - ImageView(src: item.portraitHeaderViewURL(maxWidth: 200)) - .frame(width: 200, height: 334) + ImageView(src: item.portraitHeaderViewURL(maxWidth: 257)) + .frame(width: 257, height: 380) } - .frame(height: 334) + .frame(height: 380) .buttonStyle(PlainButtonStyle()) - Text(item.title) - .lineLimit(2) - .frame(width: 200) + if showItemTitles { + Text(item.title) + .lineLimit(2) + .frame(width: 257) + } } } } diff --git a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift index d76d20e3..f3753a1e 100644 --- a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift +++ b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift @@ -22,12 +22,14 @@ struct SettingsView: View { @Default(.confirmClose) var confirmClose @Default(.tvOSEpisodeItemCinematicView) var tvOSEpisodeItemCinematicView @Default(.tvOSMovieItemCinematicView) var tvOSMovieItemCinematicView + @Default(.showPosterLabels) var showPosterLabels var body: some View { GeometryReader { reader in HStack { Image(uiImage: UIImage(named: "App Icon")!) + .cornerRadius(30) .scaleEffect(2) .frame(width: reader.size.width / 2) @@ -108,8 +110,10 @@ struct SettingsView: View { Section { Toggle("Episode Item Cinematic View", isOn: $tvOSEpisodeItemCinematicView) Toggle("Movie Item Cinematic View", isOn: $tvOSMovieItemCinematicView) + Toggle("Show Poster Labels", isOn: $showPosterLabels) + } header: { - Text("Views") + Text("Appearance") } } } From b63492fa021bf9a0545da6cf8c790fdbb41cdee9 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 21:59:50 -0700 Subject: [PATCH 57/62] pad overlay on ipados --- JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift index a61ea653..cf6240c7 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerOverlayView.swift @@ -248,6 +248,8 @@ struct VLCPlayerOverlayView: View { } } } + .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 50 : 0) + .padding(.top, UIDevice.current.userInterfaceIdiom == .pad ? 10 : 0) // MARK: Center From 8c91a95ca416dbb3a3297199b61c99ac8e972539 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 4 Jan 2022 22:18:23 -0700 Subject: [PATCH 58/62] sadly remove live tv from playing and merge fixes --- .../Components/LandscapeItemElement.swift | 2 +- JellyfinPlayer/Components/PortraitItemView.swift | 6 +++--- .../Coordinators/LiveTVChannelsCoordinator.swift | 16 ++++++++++++++-- .../Coordinators/LiveTVProgramsCoordinator.swift | 5 +++-- Shared/Views/LiveTVChannelItemElement.swift | 2 +- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift b/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift index 8bc61cf5..d0c5caaa 100644 --- a/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift +++ b/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift @@ -80,7 +80,7 @@ struct LandscapeItemElement: View { .opacity(0.4) .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) RoundedRectangle(cornerRadius: 6) - .fill(Color(red: 172/255, green: 92/255, blue: 195/255)) + .fill(Color.jellyfinPurple) .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12) } }.padding(12) diff --git a/JellyfinPlayer/Components/PortraitItemView.swift b/JellyfinPlayer/Components/PortraitItemView.swift index 79ef3b79..c677a92f 100644 --- a/JellyfinPlayer/Components/PortraitItemView.swift +++ b/JellyfinPlayer/Components/PortraitItemView.swift @@ -22,9 +22,9 @@ struct PortraitItemView: View { .shadow(radius: 4, y: 2) .shadow(radius: 4, y: 2) .overlay(Rectangle() - .fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) - .frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7) - .padding(0), alignment: .bottomLeading) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7) + .padding(0), alignment: .bottomLeading) .overlay(ZStack { if item.userData?.isFavorite ?? false { Image(systemName: "circle.fill") diff --git a/Shared/Coordinators/LiveTVChannelsCoordinator.swift b/Shared/Coordinators/LiveTVChannelsCoordinator.swift index f2a6e482..4b521208 100644 --- a/Shared/Coordinators/LiveTVChannelsCoordinator.swift +++ b/Shared/Coordinators/LiveTVChannelsCoordinator.swift @@ -23,8 +23,9 @@ final class LiveTVChannelsCoordinator: NavigationCoordinatable { return NavigationViewCoordinator(ItemCoordinator(item: item)) } - func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) + func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { +// NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) + NavigationViewCoordinator(EmptyViewCoordinator()) } @ViewBuilder @@ -32,3 +33,14 @@ final class LiveTVChannelsCoordinator: NavigationCoordinatable { LiveTVChannelsView() } } + +final class EmptyViewCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \EmptyViewCoordinator.start) + + @Root var start = makeStart + + @ViewBuilder + func makeStart() -> some View { + Text("Empty") + } +} diff --git a/Shared/Coordinators/LiveTVProgramsCoordinator.swift b/Shared/Coordinators/LiveTVProgramsCoordinator.swift index 1dd26daf..a216dd46 100644 --- a/Shared/Coordinators/LiveTVProgramsCoordinator.swift +++ b/Shared/Coordinators/LiveTVProgramsCoordinator.swift @@ -19,8 +19,9 @@ final class LiveTVProgramsCoordinator: NavigationCoordinatable { @Root var start = makeStart @Route(.fullScreen) var videoPlayer = makeVideoPlayer - func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) + func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { +// NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) + NavigationViewCoordinator(EmptyViewCoordinator()) } @ViewBuilder diff --git a/Shared/Views/LiveTVChannelItemElement.swift b/Shared/Views/LiveTVChannelItemElement.swift index 2f4e2126..b6f50e81 100644 --- a/Shared/Views/LiveTVChannelItemElement.swift +++ b/Shared/Views/LiveTVChannelItemElement.swift @@ -61,7 +61,7 @@ struct LiveTVChannelItemElement: View { .opacity(0.4) .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) RoundedRectangle(cornerRadius: 6) - .fill(Color(red: 172/255, green: 92/255, blue: 195/255)) + .fill(Color.jellyfinPurple) .frame(width: CGFloat(progressPercent * gp.size.width), height: 12) } } From 16efb3da7a42566e6df13201733364cf4b8e1feb Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Wed, 5 Jan 2022 11:46:31 -0700 Subject: [PATCH 59/62] update os in Podfile --- Podfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Podfile b/Podfile index 167b20a5..b8f76af5 100644 --- a/Podfile +++ b/Podfile @@ -5,14 +5,14 @@ def shared_pods end target 'JellyfinPlayer iOS' do - platform :ios, '14.0' + platform :ios, '15.0' shared_pods pod 'google-cast-sdk' pod 'MobileVLCKit' pod 'SwizzleSwift' end target 'JellyfinPlayer tvOS' do - platform :tvos, '14.0' + platform :tvos, '15.0' shared_pods pod 'TVVLCKit' end From c310b71963f71bdaa4e6dba538ceac856ed635af Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Wed, 5 Jan 2022 12:01:55 -0700 Subject: [PATCH 60/62] recommended project settings and various fixes --- JellyfinPlayer.xcodeproj/project.pbxproj | 14 ++------------ .../xcschemes/JellyfinPlayer tvOS.xcscheme | 2 +- .../xcshareddata/xcschemes/JellyfinPlayer.xcscheme | 2 +- .../xcschemes/WidgetExtension.xcscheme | 3 +-- JellyfinPlayer/Components/PortraitHStackView.swift | 1 + .../Views/ItemView/EpisodesRowView.swift | 3 +++ JellyfinPlayer/Views/ItemView/ItemViewBody.swift | 2 ++ 7 files changed, 11 insertions(+), 16 deletions(-) diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 51bce21a..4c54ff53 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -165,7 +165,6 @@ 6264E88C273850380081A12A /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6264E88B273850380081A12A /* Strings.swift */; }; 6264E88D273850380081A12A /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6264E88B273850380081A12A /* Strings.swift */; }; 6264E88E273850380081A12A /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6264E88B273850380081A12A /* Strings.swift */; }; - 62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */; }; 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; 6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; @@ -620,8 +619,6 @@ C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; - D79953919FED0C4DF72BA578 /* Pods-JellyfinPlayer tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.release.xcconfig"; sourceTree = ""; }; - DE5004F745B19E28744A7DE7 /* Pods-JellyfinPlayer tvOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.debug.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.debug.xcconfig"; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewDetailsView.swift; sourceTree = ""; }; E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = ""; }; @@ -818,8 +815,6 @@ C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */, 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */, C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */, - C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */, - C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */, 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */, C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */, 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */, @@ -1650,7 +1645,7 @@ New, ); LastSwiftUpdateCheck = 1250; - LastUpgradeCheck = 1250; + LastUpgradeCheck = 1320; TargetAttributes = { 5358705F2669D21600D05A09 = { CreatedOnToolsVersion = 12.5; @@ -1684,6 +1679,7 @@ he, sk, kk, + Base, ); mainGroup = 5377CBE8263B596A003A4E83; packageReferences = ( @@ -1954,7 +1950,6 @@ files = ( E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */, - 531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, E1E5D53E2783B05200692DFE /* CinematicMovieItemView.swift in Sources */, E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */, @@ -1977,7 +1972,6 @@ E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, 53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */, - E193D53E27193F9A00900D82 /* VideoPlayerCoordinator.swift in Sources */, C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, 536D3D88267C17350004248C /* PublicUserButton.swift in Sources */, 62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, @@ -1995,7 +1989,6 @@ E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, - 62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */, E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */, C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */, E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */, @@ -2095,7 +2088,6 @@ E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */, 53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, - 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */, C4BE0764271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */, E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */, @@ -2114,7 +2106,6 @@ files = ( 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, - 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, E1D4BF7E2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */, 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */, @@ -2187,7 +2178,6 @@ 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */, 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */, - 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, C4AE2C3327498DBE00AE13CF /* LiveTVChannelItemElement.swift in Sources */, E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */, diff --git a/JellyfinPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinPlayer tvOS.xcscheme b/JellyfinPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinPlayer tvOS.xcscheme index 490b03f0..a5a2b18d 100644 --- a/JellyfinPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinPlayer tvOS.xcscheme +++ b/JellyfinPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinPlayer tvOS.xcscheme @@ -1,6 +1,6 @@ diff --git a/JellyfinPlayer/Components/PortraitHStackView.swift b/JellyfinPlayer/Components/PortraitHStackView.swift index 5b09eac0..d2c55051 100644 --- a/JellyfinPlayer/Components/PortraitHStackView.swift +++ b/JellyfinPlayer/Components/PortraitHStackView.swift @@ -72,6 +72,7 @@ struct PortraitImageHStackView Date: Wed, 5 Jan 2022 13:38:15 -0700 Subject: [PATCH 61/62] resume offset and live tv moved to experimental --- .../Components/MediaPlayButtonRowView.swift | 2 +- .../Views/LibraryListView.swift | 70 +++++++++++++------ .../ExperimentalSettingsView.swift | 3 + .../Views/SettingsView/SettingsView.swift | 3 + .../VideoPlayer/VLCPlayerViewController.swift | 10 ++- JellyfinPlayer.xcodeproj/project.pbxproj | 8 +++ .../Views/SettingsView/SettingsView.swift | 3 + .../VideoPlayer/VLCPlayerViewController.swift | 11 ++- Shared/Extensions/DoubleExtensions.swift | 23 ++++++ .../SwiftfinStore/SwiftfinStoreDefaults.swift | 2 + Shared/ViewModels/VideoPlayerViewModel.swift | 3 + 11 files changed, 113 insertions(+), 25 deletions(-) create mode 100644 Shared/Extensions/DoubleExtensions.swift diff --git a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift index 23c4e862..9e4e67e2 100644 --- a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift +++ b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift @@ -23,7 +23,7 @@ struct MediaPlayButtonRowView: View { MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) } - Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play) + Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString() ?? "") left" : L10n.play) .font(.caption) } VStack { diff --git a/JellyfinPlayer tvOS/Views/LibraryListView.swift b/JellyfinPlayer tvOS/Views/LibraryListView.swift index 57e9efae..3332bbe5 100644 --- a/JellyfinPlayer tvOS/Views/LibraryListView.swift +++ b/JellyfinPlayer tvOS/Views/LibraryListView.swift @@ -7,6 +7,7 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Defaults import Foundation import SwiftUI @@ -14,6 +15,8 @@ struct LibraryListView: View { @EnvironmentObject var mainCoordinator: MainCoordinator.Router @EnvironmentObject var libraryListRouter: LibraryListCoordinator.Router @StateObject var viewModel = LibraryListViewModel() + + @Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled var body: some View { ScrollView { @@ -23,32 +26,55 @@ struct LibraryListView: View { if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" || library.collectionType ?? "" == "music" { EmptyView() } else { - Button() { - if library.collectionType == "livetv" { - self.mainCoordinator.root(\.liveTV) - } else { + if library.collectionType == "livetv" { + if liveTVAlphaEnabled { + Button() { + self.mainCoordinator.root(\.liveTV) + } + label: { + ZStack { + HStack { + Spacer() + VStack { + Text(library.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + Spacer() + }.padding(32) + } + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) + } + } else { + Button() { self.libraryListRouter.route(to: \.library, (viewModel: LibraryViewModel(), title: library.name ?? "")) } - } - label: { - ZStack { - HStack { - Spacer() - VStack { - Text(library.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) + label: { + ZStack { + HStack { + Spacer() + VStack { + Text(library.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + Spacer() + }.padding(32) + } + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) } - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) } } } else { diff --git a/JellyfinPlayer tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView/ExperimentalSettingsView.swift index 179b1889..b79b53c0 100644 --- a/JellyfinPlayer tvOS/Views/SettingsView/ExperimentalSettingsView.swift +++ b/JellyfinPlayer tvOS/Views/SettingsView/ExperimentalSettingsView.swift @@ -13,6 +13,7 @@ import SwiftUI struct ExperimentalSettingsView: View { @Default(.Experimental.syncSubtitleStateWithAdjacent) var syncSubtitleStateWithAdjacent + @Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled var body: some View { Form { @@ -20,6 +21,8 @@ struct ExperimentalSettingsView: View { Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) + Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) + } header: { Text("Experimental") } diff --git a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift index f3753a1e..fd6d3d3c 100644 --- a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift +++ b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift @@ -23,6 +23,7 @@ struct SettingsView: View { @Default(.tvOSEpisodeItemCinematicView) var tvOSEpisodeItemCinematicView @Default(.tvOSMovieItemCinematicView) var tvOSMovieItemCinematicView @Default(.showPosterLabels) var showPosterLabels + @Default(.resumeOffset) var resumeOffset var body: some View { GeometryReader { reader in @@ -80,6 +81,8 @@ struct SettingsView: View { } } + Toggle("Resume 5 Second Offset", isOn: $resumeOffset) + Toggle("Press Down for Menu", isOn: $downActionShowsMenu) Toggle("Confirm Close", isOn: $confirmClose) diff --git a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 5a59e095..e335d271 100644 --- a/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -418,7 +418,15 @@ extension VLCPlayerViewController { let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 if startPercentage > 0 { - newViewModel.sliderPercentage = startPercentage / 100 + if viewModel.resumeOffset { + let videoDurationSeconds = Double(viewModel.item.runTimeTicks! / 10_000_000) + var startSeconds = round((startPercentage / 100) * videoDurationSeconds) + startSeconds = startSeconds.subtract(5, floor: 0) + let newStartPercentage = startSeconds / videoDurationSeconds + newViewModel.sliderPercentage = newStartPercentage + } else { + newViewModel.sliderPercentage = startPercentage / 100 + } } viewModel = newViewModel diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 4c54ff53..39af1ab1 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -395,6 +395,9 @@ E1D4BF8C2719F39F00A11E64 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; }; E1D4BF8D2719F3A300A11E64 /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */; }; + E1E00A35278628A40022235B /* DoubleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E00A34278628A40022235B /* DoubleExtensions.swift */; }; + E1E00A36278628A40022235B /* DoubleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E00A34278628A40022235B /* DoubleExtensions.swift */; }; + E1E00A37278628A40022235B /* DoubleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E00A34278628A40022235B /* DoubleExtensions.swift */; }; E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */; }; E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */; }; E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */; }; @@ -695,6 +698,7 @@ E1D4BF862719D27100A11E64 /* Bitrates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bitrates.swift; sourceTree = ""; }; E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsCoordinator.swift; sourceTree = ""; }; E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; + E1E00A34278628A40022235B /* DoubleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleExtensions.swift; sourceTree = ""; }; E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshHelper.swift; sourceTree = ""; }; E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicEpisodeItemView.swift; sourceTree = ""; }; E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = ""; }; @@ -1179,6 +1183,7 @@ E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */, 6267B3D526710B8900A7371D /* CollectionExtensions.swift */, E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */, + E1E00A34278628A40022235B /* DoubleExtensions.swift */, 6267B3D92671138200A7371D /* ImageExtensions.swift */, E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */, 621338922660107500A81A2A /* StringExtensions.swift */, @@ -2060,6 +2065,7 @@ 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */, 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */, E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */, + E1E00A36278628A40022235B /* DoubleExtensions.swift in Sources */, E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */, E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */, E13DD3C927164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, @@ -2231,6 +2237,7 @@ 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */, E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */, 6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */, + E1E00A35278628A40022235B /* DoubleExtensions.swift in Sources */, 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */, 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, @@ -2259,6 +2266,7 @@ E13DD3CB27164BA8009D4DAF /* UIDeviceExtensions.swift in Sources */, 6264E88A27384A6F0081A12A /* NetworkError.swift in Sources */, E19169D0272514760085832A /* HTTPScheme.swift in Sources */, + E1E00A37278628A40022235B /* DoubleExtensions.swift in Sources */, 6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */, 628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */, 6267B3DB2671139400A7371D /* ImageExtensions.swift in Sources */, diff --git a/JellyfinPlayer/Views/SettingsView/SettingsView.swift b/JellyfinPlayer/Views/SettingsView/SettingsView.swift index ab98e490..81ac1b09 100644 --- a/JellyfinPlayer/Views/SettingsView/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView/SettingsView.swift @@ -27,6 +27,7 @@ struct SettingsView: View { @Default(.jumpGesturesEnabled) var jumpGesturesEnabled @Default(.showPosterLabels) var showPosterLabels @Default(.showCastAndCrew) var showCastAndCrew + @Default(.resumeOffset) var resumeOffset var body: some View { Form { @@ -91,6 +92,8 @@ struct SettingsView: View { Toggle("Jump Gestures Enabled", isOn: $jumpGesturesEnabled) + Toggle("Resume 5 Second Offset", isOn: $resumeOffset) + Button { settingsRouter.route(to: \.overlaySettings) } label: { diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 325ada14..7f1c6216 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -316,7 +316,15 @@ extension VLCPlayerViewController { let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 if startPercentage > 0 { - newViewModel.sliderPercentage = startPercentage / 100 + if viewModel.resumeOffset { + let videoDurationSeconds = Double(viewModel.item.runTimeTicks! / 10_000_000) + var startSeconds = round((startPercentage / 100) * videoDurationSeconds) + startSeconds = startSeconds.subtract(5, floor: 0) + let newStartPercentage = startSeconds / videoDurationSeconds + newViewModel.sliderPercentage = newStartPercentage + } else { + newViewModel.sliderPercentage = startPercentage / 100 + } } viewModel = newViewModel @@ -522,6 +530,7 @@ extension VLCPlayerViewController: VLCMediaPlayerDelegate { didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) } + // If needing to fix audio stream during playback if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) } diff --git a/Shared/Extensions/DoubleExtensions.swift b/Shared/Extensions/DoubleExtensions.swift new file mode 100644 index 00000000..4d1dc42b --- /dev/null +++ b/Shared/Extensions/DoubleExtensions.swift @@ -0,0 +1,23 @@ +// + /* + * 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 Foundation + +extension Double { + + func subtract(_ other: Double, floor: Double) -> Double { + var v = self - other + + if v < floor { + v += abs(floor - v) + } + + return v + } +} diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 5805882b..d74fd68c 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -48,6 +48,7 @@ extension Defaults.Keys { static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) static let autoplayEnabled = Key("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let resumeOffset = Key("resumeOffset", default: false, suite: SwiftfinStore.Defaults.generalSuite) // Should show video player items static let shouldShowPlayPreviousItem = Key("shouldShowPreviousItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) @@ -60,6 +61,7 @@ extension Defaults.Keys { // Experimental settings struct Experimental { static let syncSubtitleStateWithAdjacent = Key("experimental.syncSubtitleState", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let liveTVAlphaEnabled = Key("liveTVAlphaEnabled", default: false, suite: SwiftfinStore.Defaults.generalSuite) } // tvos specific diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index 1793ad39..ee3d4cba 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -88,6 +88,7 @@ final class VideoPlayerViewModel: ViewModel { let subtitleStreams: [MediaStream] let overlayType: OverlayType let jumpGesturesEnabled: Bool + let resumeOffset: Bool // MARK: Experimental let syncSubtitleStateWithAdjacent: Bool @@ -162,6 +163,8 @@ final class VideoPlayerViewModel: ViewModel { self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled] self.shouldShowJumpButtonsInOverlayMenu = Defaults[.shouldShowJumpButtonsInOverlayMenu] + self.resumeOffset = Defaults[.resumeOffset] + self.syncSubtitleStateWithAdjacent = Defaults[.Experimental.syncSubtitleStateWithAdjacent] self.confirmClose = Defaults[.confirmClose] From 8a6be62829b1c77ed859b41f467df0507d105f20 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Wed, 5 Jan 2022 14:06:58 -0700 Subject: [PATCH 62/62] remove dev team --- JellyfinPlayer.xcodeproj/project.pbxproj | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 39af1ab1..d1aa7b36 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -2445,7 +2445,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer tvOS/Preview Content\""; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "JellyfinPlayer tvOS/Info.plist"; @@ -2454,7 +2454,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -2475,7 +2475,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer tvOS/Preview Content\""; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "JellyfinPlayer tvOS/Info.plist"; @@ -2484,7 +2484,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; SWIFT_EMIT_LOC_STRINGS = YES; @@ -2627,7 +2627,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2639,7 +2639,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; @@ -2664,7 +2664,7 @@ CURRENT_PROJECT_VERSION = 66; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2676,7 +2676,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; @@ -2695,7 +2695,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2704,7 +2704,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin.widget; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -2722,7 +2722,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2731,7 +2731,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = pips.swiftfin.widget; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftfin.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES;