From 2dace313d1a1e4c98c29acaacb2bccfe638c097c Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Fri, 19 Apr 2024 15:55:47 -0600 Subject: [PATCH] Search Channels and Programs (#1037) --- .../tvOSMainTabCoordinator.swift | 8 +++-- .../ViewModels/LiveVideoPlayerManager.swift | 36 +++++++++++++++++++ Shared/ViewModels/ProgramsViewModel.swift | 1 - Shared/ViewModels/SearchViewModel.swift | 22 +++++++++--- Swiftfin tvOS/Views/SearchView.swift | 26 ++++++++++++-- .../Components/ProgramProgressOverlay.swift | 2 ++ Swiftfin/Views/SearchView.swift | 28 +++++++++++++-- 7 files changed, 111 insertions(+), 12 deletions(-) diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift index 0009e67f8..00edeab5c 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift @@ -73,8 +73,12 @@ final class MainTabCoordinator: TabCoordinatable { } } - func makeSearch() -> NavigationViewCoordinator { - NavigationViewCoordinator(SearchCoordinator()) + // TODO: does this cause issues? + func makeSearch() -> VideoPlayerWrapperCoordinator { + VideoPlayerWrapperCoordinator { + SearchCoordinator() + .view() + } } @ViewBuilder diff --git a/Shared/ViewModels/LiveVideoPlayerManager.swift b/Shared/ViewModels/LiveVideoPlayerManager.swift index e2014e68c..1f2307706 100644 --- a/Shared/ViewModels/LiveVideoPlayerManager.swift +++ b/Shared/ViewModels/LiveVideoPlayerManager.swift @@ -9,6 +9,10 @@ import Foundation import JellyfinAPI +// TODO: the video player needs to be slightly refactored anyways, so I'm fine +// with the channel retrieving method below and is mainly just for reference +// for how I should probably handle getting the channels of programs elsewhere. + class LiveVideoPlayerManager: VideoPlayerManager { @Published @@ -26,4 +30,36 @@ class LiveVideoPlayerManager: VideoPlayerManager { } } } + + init(program: BaseItemDto) { + super.init() + + Task { + guard let channel = try? await self.getChannel(for: program), let mediaSource = channel.mediaSources?.first else { + assertionFailure("No channel for program?") + return + } + + let viewModel = try await program.liveVideoPlayerViewModel(with: mediaSource, logger: logger) + + await MainActor.run { + self.currentViewModel = viewModel + } + } + } + + private func getChannel(for program: BaseItemDto) async throws -> BaseItemDto? { + + var parameters = Paths.GetItemsByUserIDParameters() + parameters.fields = .MinimumFields + parameters.ids = [program.channelID ?? ""] + + let request = Paths.getItemsByUserID( + userID: userSession.user.id, + parameters: parameters + ) + let response = try await userSession.client.send(request) + + return response.value.items?.first + } } diff --git a/Shared/ViewModels/ProgramsViewModel.swift b/Shared/ViewModels/ProgramsViewModel.swift index 40a7571d8..37d1e85f2 100644 --- a/Shared/ViewModels/ProgramsViewModel.swift +++ b/Shared/ViewModels/ProgramsViewModel.swift @@ -172,7 +172,6 @@ final class ProgramsViewModel: ViewModel, Stateful { var parameters = Paths.GetLiveTvProgramsParameters() parameters.fields = .MinimumFields .appending(.channelInfo) - .appending(.mediaSources) parameters.hasAired = false parameters.limit = 10 parameters.userID = userSession.user.id diff --git a/Shared/ViewModels/SearchViewModel.swift b/Shared/ViewModels/SearchViewModel.swift index eb19c7ac7..3de164f9b 100644 --- a/Shared/ViewModels/SearchViewModel.swift +++ b/Shared/ViewModels/SearchViewModel.swift @@ -31,6 +31,8 @@ final class SearchViewModel: ViewModel, Stateful { case searching } + @Published + private(set) var channels: [BaseItemDto] = [] @Published private(set) var collections: [BaseItemDto] = [] @Published @@ -40,6 +42,8 @@ final class SearchViewModel: ViewModel, Stateful { @Published private(set) var people: [BaseItemDto] = [] @Published + private(set) var programs: [BaseItemDto] = [] + @Published private(set) var series: [BaseItemDto] = [] @Published private(set) var suggestions: [BaseItemDto] = [] @@ -55,11 +59,15 @@ final class SearchViewModel: ViewModel, Stateful { let filterViewModel: FilterViewModel var hasNoResults: Bool { - collections.isEmpty && - episodes.isEmpty && - movies.isEmpty && - people.isEmpty && - series.isEmpty + [ + collections, + channels, + episodes, + movies, + people, + programs, + series, + ].allSatisfy(\.isEmpty) } // MARK: init @@ -144,7 +152,9 @@ final class SearchViewModel: ViewModel, Stateful { .boxSet, .episode, .movie, + .liveTvProgram, .series, + .tvChannel, ] for type in retrievingItemTypes { @@ -173,9 +183,11 @@ final class SearchViewModel: ViewModel, Stateful { await MainActor.run { self.collections = items[.boxSet] ?? [] + self.channels = items[.tvChannel] ?? [] self.episodes = items[.episode] ?? [] self.movies = items[.movie] ?? [] self.people = items[.person] ?? [] + self.programs = items[.liveTvProgram] ?? [] self.series = items[.series] ?? [] self.state = .content diff --git a/Swiftfin tvOS/Views/SearchView.swift b/Swiftfin tvOS/Views/SearchView.swift index 7642b4dc2..9195a2de5 100644 --- a/Swiftfin tvOS/Views/SearchView.swift +++ b/Swiftfin tvOS/Views/SearchView.swift @@ -15,6 +15,8 @@ struct SearchView: View { @Default(.Customization.searchPosterType) private var searchPosterType + @EnvironmentObject + private var videoPlayerRouter: VideoPlayerWrapperCoordinator.Router @EnvironmentObject private var router: SearchCoordinator.Router @@ -58,15 +60,35 @@ struct SearchView: View { if viewModel.people.isNotEmpty { itemsSection(title: L10n.people, keyPath: \.people, posterType: .portrait) } + + if viewModel.programs.isNotEmpty { + itemsSection(title: L10n.programs, keyPath: \.programs, posterType: .landscape) + } + + if viewModel.channels.isNotEmpty { + itemsSection(title: L10n.channels, keyPath: \.channels, posterType: .portrait) + } } } } private func select(_ item: BaseItemDto) { - if item.type == .person { + switch item.type { + case .person: let viewModel = ItemLibraryViewModel(parent: item) router.route(to: \.library, viewModel) - } else { + case .program: + videoPlayerRouter.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(program: item) + ) + case .tvChannel: + guard let mediaSource = item.mediaSources?.first else { return } + videoPlayerRouter.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: item, mediaSource: mediaSource) + ) + default: router.route(to: \.item, item) } } diff --git a/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift b/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift index 793b809a5..4208ea66f 100644 --- a/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift +++ b/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift @@ -9,6 +9,8 @@ import JellyfinAPI import SwiftUI +// TODO: item-type dependent views may be more appropriate near/on +// the `PosterButton` object instead of on these larger views extension ProgramsView { struct ProgramProgressOverlay: View { diff --git a/Swiftfin/Views/SearchView.swift b/Swiftfin/Views/SearchView.swift index fea6e836c..26b2fbe5b 100644 --- a/Swiftfin/Views/SearchView.swift +++ b/Swiftfin/Views/SearchView.swift @@ -13,6 +13,8 @@ import SwiftUI // TODO: have a `SearchLibraryViewModel` that allows paging on searched items? // TODO: implement search view result type between `PosterHStack` // and `ListHStack` (3 row list columns)? (iOS only) +// TODO: have programs only pull recommended/current? +// - have progress overlay struct SearchView: View { @Default(.Customization.Search.enabledDrawerFilters) @@ -20,6 +22,8 @@ struct SearchView: View { @Default(.Customization.searchPosterType) private var searchPosterType + @EnvironmentObject + private var mainRouter: MainCoordinator.Router @EnvironmentObject private var router: SearchCoordinator.Router @@ -68,16 +72,36 @@ struct SearchView: View { if viewModel.people.isNotEmpty { itemsSection(title: L10n.people, keyPath: \.people, posterType: .portrait) } + + if viewModel.programs.isNotEmpty { + itemsSection(title: L10n.programs, keyPath: \.programs, posterType: .landscape) + } + + if viewModel.channels.isNotEmpty { + itemsSection(title: L10n.channels, keyPath: \.channels, posterType: .portrait) + } } .edgePadding(.vertical) } } private func select(_ item: BaseItemDto) { - if item.type == .person { + switch item.type { + case .person: let viewModel = ItemLibraryViewModel(parent: item) router.route(to: \.library, viewModel) - } else { + case .program: + mainRouter.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(program: item) + ) + case .tvChannel: + guard let mediaSource = item.mediaSources?.first else { return } + mainRouter.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: item, mediaSource: mediaSource) + ) + default: router.route(to: \.item, item) } }