diff --git a/JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift b/JellyfinPlayer tvOS/Components/EpisodesRowView.swift similarity index 100% rename from JellyfinPlayer tvOS/Views/ItemView/EpisodesRowView.swift rename to JellyfinPlayer tvOS/Components/EpisodesRowView.swift diff --git a/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift b/JellyfinPlayer tvOS/Components/ItemDetailsView.swift similarity index 100% rename from JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift rename to JellyfinPlayer tvOS/Components/ItemDetailsView.swift diff --git a/JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift b/JellyfinPlayer tvOS/Components/PortraitItemsRowView.swift similarity index 85% rename from JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift rename to JellyfinPlayer tvOS/Components/PortraitItemsRowView.swift index 08358d3b..f2711fe1 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/PortraitItemsRowView.swift +++ b/JellyfinPlayer tvOS/Components/PortraitItemsRowView.swift @@ -17,11 +17,16 @@ struct PortraitItemsRowView: View { let rowTitle: String let items: [BaseItemDto] let showItemTitles: Bool + let selectedAction: (BaseItemDto) -> Void - init(rowTitle: String, items: [BaseItemDto], showItemTitles: Bool = true) { + init(rowTitle: String, + items: [BaseItemDto], + showItemTitles: Bool = true, + selectedAction: @escaping (BaseItemDto) -> Void) { self.rowTitle = rowTitle self.items = items self.showItemTitles = showItemTitles + self.selectedAction = selectedAction } var body: some View { @@ -37,7 +42,7 @@ struct PortraitItemsRowView: View { VStack(spacing: 15) { Button { - itemRouter.route(to: \.item, item) + selectedAction(item) } label: { ImageView(src: item.portraitHeaderViewURL(maxWidth: 257)) .frame(width: 257, height: 380) @@ -58,5 +63,6 @@ struct PortraitItemsRowView: View { } .edgesIgnoringSafeArea(.horizontal) } + .focusSection() } } diff --git a/JellyfinPlayer tvOS/Components/SingleSeasonEpisodesRowView.swift b/JellyfinPlayer tvOS/Components/SingleSeasonEpisodesRowView.swift new file mode 100644 index 00000000..55bba152 --- /dev/null +++ b/JellyfinPlayer tvOS/Components/SingleSeasonEpisodesRowView.swift @@ -0,0 +1,122 @@ +// + /* + * 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 SingleSeasonEpisodesRowView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router + @ObservedObject var viewModel: SingleSeasonEpisodesRowViewModel + + var body: some View { + VStack(alignment: .leading) { + + Text("Episodes") + .font(.title3) + .padding(.horizontal, 50) + + ScrollView(.horizontal) { + ScrollViewReader { reader in + 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 viewModel.episodes.isEmpty { + VStack(alignment: .leading) { + + Color.secondary + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) + + VStack(alignment: .leading) { + Text("--") + .font(.caption) + .foregroundColor(.secondary) + Text("No episodes available") + .font(.footnote) + .padding(.bottom, 1) + } + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + .focusable() + } else { + ForEach(viewModel.episodes, 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) + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } +} diff --git a/JellyfinPlayer tvOS/Views/ContinueWatchingView.swift b/JellyfinPlayer tvOS/Views/ContinueWatchingView.swift deleted file mode 100644 index 02a85f82..00000000 --- a/JellyfinPlayer tvOS/Views/ContinueWatchingView.swift +++ /dev/null @@ -1,46 +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 -import JellyfinAPI -import Combine -import Stinsen - -struct ContinueWatchingView: View { - var items: [BaseItemDto] - @Namespace private var namespace - - var homeRouter: HomeCoordinator.Router? = RouterStore.shared.retrieve() - - var body: some View { - VStack(alignment: .leading) { - if items.count > 0 { - L10n.continueWatching.text - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - self.homeRouter?.route(to: \.modalItem, item) - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } else { - EmptyView() - } - } - } -} diff --git a/JellyfinPlayer tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift b/JellyfinPlayer tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift new file mode 100644 index 00000000..979561f6 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift @@ -0,0 +1,71 @@ +// + /* + * 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 ContinueWatchingCard: View { + + @EnvironmentObject var homeRouter: HomeCoordinator.Router + let item: BaseItemDto + + var body: some View { + VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + ZStack(alignment: .bottom) { + + ImageView(src: item.getBackdropImage(maxWidth: 500)) + .frame(width: 500, height: 281.25) + + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? "") + .font(.subheadline) + .padding(.vertical, 5) + .padding(.leading, 10) + .foregroundColor(.white) + + HStack { + Color(UIColor.systemPurple) + .frame(width: 500 * (item.userData?.playedPercentage ?? 0) / 100, height: 13) + + Spacer(minLength: 0) + } + } + .background { + LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + } + } + .frame(width: 500, height: 281.25) + } + .buttonStyle(CardButtonStyle()) + .padding(.top) + + VStack(alignment: .leading) { + Text("\(item.seriesName ?? item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + + if item.itemType == .episode { + Text(item.getEpisodeLocator() ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } +} diff --git a/JellyfinPlayer tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift b/JellyfinPlayer tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift new file mode 100644 index 00000000..b1b80cbd --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift @@ -0,0 +1,37 @@ +/* + * 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 +import JellyfinAPI +import Combine +import Stinsen + +struct ContinueWatchingView: View { + + @EnvironmentObject var homeRouter: HomeCoordinator.Router + let items: [BaseItemDto] + + var body: some View { + VStack(alignment: .leading) { + + L10n.continueWatching.text + .font(.title3) + .fontWeight(.semibold) + .padding(.leading, 50) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(items, id: \.self) { item in + ContinueWatchingCard(item: item) + } + } + .padding(.horizontal, 50) + } + } + } +} diff --git a/JellyfinPlayer tvOS/Views/HomeView.swift b/JellyfinPlayer tvOS/Views/HomeView.swift index 243ea8d6..0c67b482 100644 --- a/JellyfinPlayer tvOS/Views/HomeView.swift +++ b/JellyfinPlayer tvOS/Views/HomeView.swift @@ -11,62 +11,48 @@ import Foundation import SwiftUI struct HomeView: View { + @EnvironmentObject var homeRouter: HomeCoordinator.Router - @StateObject var viewModel = HomeViewModel() + @ObservedObject var viewModel = HomeViewModel() @State var showingSettings = false var body: some View { - ZStack { - Color.black - .ignoresSafeArea() - - 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) - } - - 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(minLength: 100) - - HStack { - Spacer() - - Button { - viewModel.refresh() - } label: { - Text("Refresh") - } - - Spacer() - } - .focusSection() + 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) + } + + ForEach(viewModel.libraries, id: \.self) { library in + LatestMediaView(viewModel: LatestMediaViewModel(library: library)) + } + + Spacer(minLength: 100) + + HStack { + Spacer() + + Button { + viewModel.refresh() + } label: { + Text("Refresh") + } + + Spacer() + } + .focusSection() } } + .edgesIgnoringSafeArea(.horizontal) } } } diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift index e438421d..8d30bc43 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift @@ -13,10 +13,19 @@ import SwiftUI struct CinematicEpisodeItemView: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: EpisodeItemViewModel @State var wrappedScrollView: UIScrollView? @Default(.showPosterLabels) var showPosterLabels + func generateSubtitle() -> String? { + guard let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() else { + return nil + } + + return "\(seriesName) - \(episodeLocator)" + } + var body: some View { ZStack { @@ -27,7 +36,10 @@ struct CinematicEpisodeItemView: View { ScrollView { VStack(spacing: 0) { - CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView) + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: generateSubtitle()) .focusSection() .frame(height: UIScreen.main.bounds.height - 10) @@ -43,10 +55,19 @@ struct CinematicEpisodeItemView: View { EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: viewModel)) .focusSection() + if let seriesItem = viewModel.series { + PortraitItemsRowView(rowTitle: "Series", + items: [seriesItem]) { seriesItem in + itemRouter.route(to: \.item, seriesItem) + } + } + if !viewModel.similarItems.isEmpty { PortraitItemsRowView(rowTitle: "Recommended", items: viewModel.similarItems, - showItemTitles: showPosterLabels) + showItemTitles: showPosterLabels) { item in + itemRouter.route(to: \.item, item) + } } ItemDetailsView(viewModel: viewModel) diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift index 71c44ec3..e6c51402 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift @@ -16,6 +16,8 @@ struct CinematicItemViewTopRow: View { @Environment(\.isFocused) var envFocused: Bool @State var focused: Bool = false @State var wrappedScrollView: UIScrollView? + @State var title: String + @State var subtitle: String? var body: some View { ZStack(alignment: .bottom) { @@ -34,7 +36,11 @@ struct CinematicItemViewTopRow: View { // MARK: Play Button { - itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) + if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel { + itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel) + } else { + LogManager.shared.log.error("Attempted to play item but no playback information available") + } } label: { HStack(spacing: 15) { Image(systemName: "play.fill") @@ -42,48 +48,46 @@ struct CinematicItemViewTopRow: View { .font(.title3) 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 ?? "") + Text(title) .font(.title2) .lineLimit(2) - if let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() { - Text("\(seriesName) - \(episodeLocator)") + if let subtitle = subtitle { + Text(subtitle) } 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 viewModel.item.itemType == .series { + if let airTime = viewModel.item.airTime { + Text(airTime) + .font(.subheadline) + .fontWeight(.medium) + } + } else { + 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 { @@ -121,17 +125,6 @@ struct CinematicItemViewTopRow: View { } } -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 { diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift index 6363fcf5..0ca3865d 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift @@ -13,6 +13,7 @@ import SwiftUI struct CinematicMovieItemView: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: MovieItemViewModel @State var wrappedScrollView: UIScrollView? @Default(.showPosterLabels) var showPosterLabels @@ -27,7 +28,10 @@ struct CinematicMovieItemView: View { ScrollView { VStack(spacing: 0) { - CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView) + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: nil) .focusSection() .frame(height: UIScreen.main.bounds.height - 10) @@ -42,7 +46,9 @@ struct CinematicMovieItemView: View { if !viewModel.similarItems.isEmpty { PortraitItemsRowView(rowTitle: "Recommended", items: viewModel.similarItems, - showItemTitles: showPosterLabels) + showItemTitles: showPosterLabels) { item in + itemRouter.route(to: \.item, item) + } } ItemDetailsView(viewModel: viewModel) diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift new file mode 100644 index 00000000..c527ce65 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift @@ -0,0 +1,80 @@ +// + /* + * 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 CinematicSeasonItemView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router + @ObservedObject var viewModel: SeasonItemViewModel + @State var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) var showPosterLabels + + var body: some View { + ZStack { + + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + + if let seriesItem = viewModel.seriesItem { + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: seriesItem.name) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + } else { + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "") + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + } + + ZStack(alignment: .topLeading) { + + Color.black.ignoresSafeArea() + .frame(minHeight: UIScreen.main.bounds.height) + + VStack(alignment: .leading, spacing: 20) { + + CinematicItemAboutView(viewModel: viewModel) + + SingleSeasonEpisodesRowView(viewModel: SingleSeasonEpisodesRowViewModel(seasonItemViewModel: viewModel)) + + if let seriesItem = viewModel.seriesItem { + PortraitItemsRowView(rowTitle: "Series", + items: [seriesItem]) { seriesItem in + itemRouter.route(to: \.item, seriesItem) + } + } + + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView(rowTitle: "Recommended", + items: viewModel.similarItems, + showItemTitles: showPosterLabels) { item in + itemRouter.route(to: \.item, item) + } + } + } + .padding(.vertical, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } +} diff --git a/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift new file mode 100644 index 00000000..6ff5e24e --- /dev/null +++ b/JellyfinPlayer tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift @@ -0,0 +1,69 @@ +// + /* + * 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 CinematicSeriesItemView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router + @ObservedObject var viewModel: SeriesItemViewModel + @State var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) var showPosterLabels + + var body: some View { + ZStack { + + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: nil) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + + ZStack(alignment: .topLeading) { + + Color.black.ignoresSafeArea() + .frame(minHeight: UIScreen.main.bounds.height) + + VStack(alignment: .leading, spacing: 20) { + + CinematicItemAboutView(viewModel: viewModel) + + PortraitItemsRowView(rowTitle: "Seasons", + items: viewModel.seasons, + showItemTitles: showPosterLabels) { season in + itemRouter.route(to: \.item, season) + } + + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView(rowTitle: "Recommended", + items: viewModel.similarItems, + showItemTitles: showPosterLabels) { item in + itemRouter.route(to: \.item, item) + } + } + } + .padding(.vertical, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } +} diff --git a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift similarity index 95% rename from JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift rename to JellyfinPlayer tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift index a1033cc7..cb30a302 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/EpisodeItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift @@ -136,10 +136,13 @@ struct EpisodeItemView: View { ScrollView(.horizontal) { LazyHStack { Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItems in - NavigationLink(destination: ItemView(item: similarItems)) { - PortraitItemElement(item: similarItems) - }.buttonStyle(PlainNavigationLinkButtonStyle()) + ForEach(viewModel.similarItems, id: \.id) { similarItem in + Button { + itemRouter.route(to: \.item, similarItem) + } label: { + PortraitItemElement(item: similarItem) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) } Spacer().frame(width: 45) } diff --git a/JellyfinPlayer tvOS/Views/ItemView/MovieItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CompactItemView/MovieItemView.swift similarity index 94% rename from JellyfinPlayer tvOS/Views/ItemView/MovieItemView.swift rename to JellyfinPlayer tvOS/Views/ItemView/CompactItemView/MovieItemView.swift index 32afd5e3..dbe04c5e 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/MovieItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/CompactItemView/MovieItemView.swift @@ -11,12 +11,13 @@ import SwiftUI import JellyfinAPI struct MovieItemView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router @ObservedObject var viewModel: MovieItemViewModel @State var actors: [BaseItemPerson] = [] @State var studio: String? @State var director: String? - @State var wrappedScrollView: UIScrollView? @Namespace private var namespace @@ -141,10 +142,13 @@ struct MovieItemView: View { ScrollView(.horizontal) { LazyHStack { Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItems in - NavigationLink(destination: ItemView(item: similarItems)) { - PortraitItemElement(item: similarItems) - }.buttonStyle(PlainNavigationLinkButtonStyle()) + ForEach(viewModel.similarItems, id: \.id) { similarItem in + Button { + itemRouter.route(to: \.item, similarItem) + } label: { + PortraitItemElement(item: similarItem) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) } Spacer().frame(width: 45) } diff --git a/JellyfinPlayer tvOS/Views/ItemView/SeasonItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift similarity index 100% rename from JellyfinPlayer tvOS/Views/ItemView/SeasonItemView.swift rename to JellyfinPlayer tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift diff --git a/JellyfinPlayer tvOS/Views/ItemView/SeriesItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift similarity index 100% rename from JellyfinPlayer tvOS/Views/ItemView/SeriesItemView.swift rename to JellyfinPlayer tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift diff --git a/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift b/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift index cc523200..ad443c65 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/ItemView.swift @@ -25,8 +25,7 @@ struct ItemNavigationView: View { struct ItemView: View { - @Default(.tvOSEpisodeItemCinematicView) var tvOSEpisodeItemCinematicView - @Default(.tvOSMovieItemCinematicView) var tvOSMovieItemCinematicView + @Default(.tvOSCinematicViews) var tvOSCinematicViews private var item: BaseItemDto @@ -36,23 +35,32 @@ struct ItemView: View { var body: some View { Group { - if item.type == "Movie" { - if tvOSMovieItemCinematicView { + switch item.itemType { + case .movie: + if tvOSCinematicViews { 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" { - if tvOSEpisodeItemCinematicView { + case .episode: + if tvOSCinematicViews { CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) } else { EpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) } - } else { + case .season: + if tvOSCinematicViews { + CinematicSeasonItemView(viewModel: SeasonItemViewModel(item: item)) + } else { + SeasonItemView(viewModel: .init(item: item)) + } + case .series: + if tvOSCinematicViews { + CinematicSeriesItemView(viewModel: SeriesItemViewModel(item: item)) + } else { + SeriesItemView(viewModel: SeriesItemViewModel(item: item)) + } + default: Text(L10n.notImplementedYetWithType(item.type ?? "")) } } diff --git a/JellyfinPlayer tvOS/Views/LatestMediaView.swift b/JellyfinPlayer tvOS/Views/LatestMediaView.swift index 79905357..5f5d14ea 100644 --- a/JellyfinPlayer tvOS/Views/LatestMediaView.swift +++ b/JellyfinPlayer tvOS/Views/LatestMediaView.swift @@ -5,49 +5,21 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI +import Defaults import JellyfinAPI -import Combine +import SwiftUI struct LatestMediaView: View { - - @StateObject var tempViewModel = ViewModel() - @State var items: [BaseItemDto] = [] - @State private var viewDidLoad: Bool = false - private var library_id: String = "" - - init(usingParentID: String) { - library_id = usingParentID - } - - func onAppear() { - if viewDidLoad == true { - return - } - viewDidLoad = true - - 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) - } - + @EnvironmentObject var homeRouter: HomeCoordinator.Router + @StateObject var viewModel: LatestMediaViewModel + @Default(.showPosterLabels) var showPosterLabels + var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - NavigationLink(destination: LazyView { ItemView(item: item) }) { - PortraitItemElement(item: item) - }.buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 480) - .onAppear(perform: onAppear) + PortraitItemsRowView(rowTitle: L10n.latestWithString(viewModel.library.name ?? ""), + items: viewModel.items, + showItemTitles: showPosterLabels) { item in + homeRouter.route(to: \.modalItem, item) + } } } diff --git a/JellyfinPlayer tvOS/Views/NextUpView.swift b/JellyfinPlayer tvOS/Views/NextUpView.swift deleted file mode 100644 index aa68e8ac..00000000 --- a/JellyfinPlayer tvOS/Views/NextUpView.swift +++ /dev/null @@ -1,45 +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 -import JellyfinAPI -import Combine -import Stinsen - -struct NextUpView: View { - var items: [BaseItemDto] - - var homeRouter: HomeCoordinator.Router? = RouterStore.shared.retrieve() - - var body: some View { - VStack(alignment: .leading) { - if items.count > 0 { - L10n.nextUp.text - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - self.homeRouter?.route(to: \.modalItem, item) - } label: { - LandscapeItemElement(item: item) - }.buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - .offset(y: -10) - } else { - EmptyView() - } - } - } -} diff --git a/JellyfinPlayer tvOS/Views/NextUpView/NextUpCard.swift b/JellyfinPlayer tvOS/Views/NextUpView/NextUpCard.swift new file mode 100644 index 00000000..b9668785 --- /dev/null +++ b/JellyfinPlayer tvOS/Views/NextUpView/NextUpCard.swift @@ -0,0 +1,46 @@ +// + /* + * 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 NextUpCard: View { + + @EnvironmentObject var homeRouter: HomeCoordinator.Router + let item: BaseItemDto + + var body: some View { + VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + ImageView(src: item.getBackdropImage(maxWidth: 500)) + .frame(width: 500, height: 281.25) + } + .buttonStyle(CardButtonStyle()) + .padding(.top) + + VStack(alignment: .leading) { + Text("\(item.seriesName ?? item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + + if item.itemType == .episode { + Text(item.getEpisodeLocator() ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } +} diff --git a/JellyfinPlayer tvOS/Views/NextUpView/NextUpView.swift b/JellyfinPlayer tvOS/Views/NextUpView/NextUpView.swift new file mode 100644 index 00000000..b4d8062c --- /dev/null +++ b/JellyfinPlayer tvOS/Views/NextUpView/NextUpView.swift @@ -0,0 +1,37 @@ +/* + * 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 +import JellyfinAPI +import Combine +import Stinsen + +struct NextUpView: View { + var items: [BaseItemDto] + + var homeRouter: HomeCoordinator.Router? = RouterStore.shared.retrieve() + + var body: some View { + VStack(alignment: .leading) { + + L10n.nextUp.text + .font(.title3) + .fontWeight(.semibold) + .padding(.leading, 50) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(items, id: \.id) { item in + NextUpCard(item: item) + } + } + .padding(.horizontal, 50) + } + } + } +} diff --git a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift index fd6d3d3c..c99fe247 100644 --- a/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift +++ b/JellyfinPlayer tvOS/Views/SettingsView/SettingsView.swift @@ -20,8 +20,7 @@ struct SettingsView: View { @Default(.videoPlayerJumpBackward) var jumpBackwardLength @Default(.downActionShowsMenu) var downActionShowsMenu @Default(.confirmClose) var confirmClose - @Default(.tvOSEpisodeItemCinematicView) var tvOSEpisodeItemCinematicView - @Default(.tvOSMovieItemCinematicView) var tvOSMovieItemCinematicView + @Default(.tvOSCinematicViews) var tvOSCinematicViews @Default(.showPosterLabels) var showPosterLabels @Default(.resumeOffset) var resumeOffset @@ -111,8 +110,7 @@ struct SettingsView: View { } Section { - Toggle("Episode Item Cinematic View", isOn: $tvOSEpisodeItemCinematicView) - Toggle("Movie Item Cinematic View", isOn: $tvOSMovieItemCinematicView) + Toggle("Cinematic Views", isOn: $tvOSCinematicViews) Toggle("Show Poster Labels", isOn: $showPosterLabels) } header: { diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index d1aa7b36..4cdf637d 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -307,6 +307,9 @@ E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F82717E961009D4DAF /* UserListViewModel.swift */; }; E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3FB2717EAE8009D4DAF /* UserListView.swift */; }; E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; + E13F26AF278754E300DF4761 /* CinematicSeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F26AE278754E300DF4761 /* CinematicSeriesItemView.swift */; }; + E13F26B12787589300DF4761 /* CinematicSeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F26B02787589300DF4761 /* CinematicSeasonItemView.swift */; }; + E13F26B32787597300DF4761 /* SingleSeasonEpisodesRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F26B22787597300DF4761 /* SingleSeasonEpisodesRowView.swift */; }; E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */; }; E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */; }; E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; @@ -366,6 +369,8 @@ E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */; }; E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; }; E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */; }; + E1B59FD52786ADE500A5287E /* ContinueWatchingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B59FD42786ADE500A5287E /* ContinueWatchingCard.swift */; }; + E1B59FD92786AE4600A5287E /* NextUpCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B59FD82786AE4600A5287E /* NextUpCard.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 */; }; @@ -648,6 +653,9 @@ E13DD3F82717E961009D4DAF /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = ""; }; E13DD3FB2717EAE8009D4DAF /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListCoordinator.swift; sourceTree = ""; }; + E13F26AE278754E300DF4761 /* CinematicSeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicSeriesItemView.swift; sourceTree = ""; }; + E13F26B02787589300DF4761 /* CinematicSeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicSeasonItemView.swift; sourceTree = ""; }; + E13F26B22787597300DF4761 /* SingleSeasonEpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleSeasonEpisodesRowView.swift; sourceTree = ""; }; E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitMainView.swift; sourceTree = ""; }; E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeMainView.swift; sourceTree = ""; }; E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; @@ -680,6 +688,8 @@ 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 = ""; }; + E1B59FD42786ADE500A5287E /* ContinueWatchingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingCard.swift; sourceTree = ""; }; + E1B59FD82786AE4600A5287E /* NextUpCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpCard.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 = ""; }; @@ -946,11 +956,15 @@ 536D3D77267BB9650004248C /* Components */ = { isa = PBXGroup; children = ( + E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */, + E13F26B22787597300DF4761 /* SingleSeasonEpisodesRowView.swift */, + E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */, 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */, E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */, 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */, 53116A18268B947A003024C9 /* PlainLinkButton.swift */, 536D3D80267BDFC60004248C /* PortraitItemElement.swift */, + E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */, 536D3D87267C17350004248C /* PublicUserButton.swift */, E17885A3278105170094FBCF /* SFSymbolButton.swift */, ); @@ -1295,7 +1309,7 @@ children = ( 53ABFDEA2679753200886593 /* ConnectToServerView.swift */, E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */, - 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */, + E1B59FD62786AE2C00A5287E /* ContinueWatchingView */, 531690E6267ABD79005D8AB9 /* HomeView.swift */, E193D54E271942C000900D82 /* ItemView */, 536D3D7E267BDF100004248C /* LatestMediaView.swift */, @@ -1308,7 +1322,7 @@ C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */, C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */, C4BE0768271FC164003F4AD1 /* TVLibrariesView.swift */, - 531690EE267ABF72005D8AB9 /* NextUpView.swift */, + E1B59FD72786AE3E00A5287E /* NextUpView */, E193D54F2719430400900D82 /* ServerDetailView.swift */, E193D54A271941D300900D82 /* ServerListView.swift */, E1E5D54D2783E66600692DFE /* SettingsView */, @@ -1364,6 +1378,17 @@ path = Views; sourceTree = ""; }; + E13F26AD27874ECC00DF4761 /* CompactItemView */ = { + isa = PBXGroup; + children = ( + 53272538268C20100035FBF1 /* EpisodeItemView.swift */, + 53CD2A41268A4B38002ABD4E /* MovieItemView.swift */, + 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */, + 53116A16268B919A003024C9 /* SeriesItemView.swift */, + ); + path = CompactItemView; + sourceTree = ""; + }; E14F7D0A26DB3714007C3AE6 /* ItemView */ = { isa = PBXGroup; children = ( @@ -1441,14 +1466,8 @@ isa = PBXGroup; children = ( E1E5D53C2783A85F00692DFE /* CinematicItemView */, - 53272538268C20100035FBF1 /* EpisodeItemView.swift */, - E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */, - E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */, + E13F26AD27874ECC00DF4761 /* CompactItemView */, 53CD2A3F268A49C2002ABD4E /* ItemView.swift */, - 53CD2A41268A4B38002ABD4E /* MovieItemView.swift */, - E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */, - 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */, - 53116A16268B919A003024C9 /* SeriesItemView.swift */, ); path = ItemView; sourceTree = ""; @@ -1481,6 +1500,24 @@ path = Views; sourceTree = ""; }; + E1B59FD62786AE2C00A5287E /* ContinueWatchingView */ = { + isa = PBXGroup; + children = ( + E1B59FD42786ADE500A5287E /* ContinueWatchingCard.swift */, + 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */, + ); + path = ContinueWatchingView; + sourceTree = ""; + }; + E1B59FD72786AE3E00A5287E /* NextUpView */ = { + isa = PBXGroup; + children = ( + 531690EE267ABF72005D8AB9 /* NextUpView.swift */, + E1B59FD82786AE4600A5287E /* NextUpCard.swift */, + ); + path = NextUpView; + sourceTree = ""; + }; E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = { isa = PBXGroup; children = ( @@ -1506,6 +1543,8 @@ E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */, E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */, E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */, + E13F26B02787589300DF4761 /* CinematicSeasonItemView.swift */, + E13F26AE278754E300DF4761 /* CinematicSeriesItemView.swift */, ); path = CinematicItemView; sourceTree = ""; @@ -1993,6 +2032,7 @@ 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, + E1B59FD92786AE4600A5287E /* NextUpCard.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */, C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */, @@ -2026,6 +2066,7 @@ E1D4BF852719D25A00A11E64 /* TrackLanguage.swift in Sources */, 53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */, 531690F0267ABF72005D8AB9 /* NextUpView.swift in Sources */, + E13F26B32787597300DF4761 /* SingleSeasonEpisodesRowView.swift in Sources */, E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */, E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, @@ -2048,6 +2089,7 @@ E1E5D5402783B0C000692DFE /* CinematicItemViewTopRowButton.swift in Sources */, 5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */, 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */, + E13F26AF278754E300DF4761 /* CinematicSeriesItemView.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */, E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */, @@ -2064,6 +2106,7 @@ E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */, 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */, 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */, + E1B59FD52786ADE500A5287E /* ContinueWatchingCard.swift in Sources */, E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */, E1E00A36278628A40022235B /* DoubleExtensions.swift in Sources */, E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */, @@ -2084,6 +2127,7 @@ C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */, + E13F26B12787589300DF4761 /* CinematicSeasonItemView.swift in Sources */, E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */, 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, E10D87E327852FD000BD264C /* EpisodesRowViewModel.swift in Sources */, diff --git a/JellyfinPlayer/Views/HomeView.swift b/JellyfinPlayer/Views/HomeView.swift index 71e9f944..16274325 100644 --- a/JellyfinPlayer/Views/HomeView.swift +++ b/JellyfinPlayer/Views/HomeView.swift @@ -68,7 +68,7 @@ struct HomeView: View { ForEach(viewModel.libraries, id: \.self) { library in - LatestMediaView(viewModel: LatestMediaViewModel(libraryID: library.id!)) { + LatestMediaView(viewModel: LatestMediaViewModel(library: library)) { HStack { Text(L10n.latestWithString(library.name ?? "")) .font(.title2) diff --git a/JellyfinPlayer/Views/ItemView/ItemView.swift b/JellyfinPlayer/Views/ItemView/ItemView.swift index 0e257ba7..d127c1d6 100644 --- a/JellyfinPlayer/Views/ItemView/ItemView.swift +++ b/JellyfinPlayer/Views/ItemView/ItemView.swift @@ -51,24 +51,6 @@ private struct ItemView: View { } } - @ViewBuilder - var toolbarItemContent: some View { - switch viewModel.item.itemType { - case .season: - Menu { - Button { - (viewModel as? SeasonItemViewModel)?.routeToSeriesItem() - } label: { - Label("Show Series", systemImage: "text.below.photo") - } - } label: { - Image(systemName: "ellipsis.circle.fill") - } - default: - EmptyView() - } - } - var body: some View { Group { if hSizeClass == .compact && vSizeClass == .regular { @@ -79,10 +61,5 @@ private struct ItemView: View { .environmentObject(viewModel) } } - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - toolbarItemContent - } - } } } diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index d74fd68c..ecb744a0 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -67,6 +67,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) - static let tvOSEpisodeItemCinematicView = Key("tvOSEpisodeItemCinematicView", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let tvOSMovieItemCinematicView = Key("tvOSMovieItemCinematicView", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let tvOSCinematicViews = Key("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite) } diff --git a/Shared/ViewModels/EpisodesRowViewModel.swift b/Shared/ViewModels/EpisodesRowViewModel.swift index 19874d51..68686728 100644 --- a/Shared/ViewModels/EpisodesRowViewModel.swift +++ b/Shared/ViewModels/EpisodesRowViewModel.swift @@ -12,6 +12,8 @@ import SwiftUI final class EpisodesRowViewModel: ViewModel { + // TODO: Protocol these viewmodels for generalization instead of Episode + @ObservedObject var episodeItemViewModel: EpisodeItemViewModel @Published var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] @Published var selectedSeason: BaseItemDto? { @@ -63,3 +65,17 @@ final class EpisodesRowViewModel: ViewModel { .store(in: &cancellables) } } + +final class SingleSeasonEpisodesRowViewModel: ViewModel { + + // TODO: Protocol these viewmodels for generalization instead of Season + + @ObservedObject var seasonItemViewModel: SeasonItemViewModel + @Published var episodes: [BaseItemDto] + + init(seasonItemViewModel: SeasonItemViewModel) { + self.seasonItemViewModel = seasonItemViewModel + self.episodes = seasonItemViewModel.episodes + super.init() + } +} diff --git a/Shared/ViewModels/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel.swift index d37ec64d..85ead04a 100644 --- a/Shared/ViewModels/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel.swift @@ -15,7 +15,18 @@ import UIKit class ItemViewModel: ViewModel { @Published var item: BaseItemDto - @Published var playButtonItem: BaseItemDto? + @Published var playButtonItem: BaseItemDto? { + didSet { + playButtonItem?.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.itemVideoPlayerViewModel = videoPlayerViewModel + self.mediaItems = videoPlayerViewModel.item.createMediaItems() + } + .store(in: &cancellables) + } + } @Published var similarItems: [BaseItemDto] = [] @Published var isWatched = false @Published var isFavorited = false diff --git a/Shared/ViewModels/LatestMediaViewModel.swift b/Shared/ViewModels/LatestMediaViewModel.swift index cca5b38e..88cc1564 100644 --- a/Shared/ViewModels/LatestMediaViewModel.swift +++ b/Shared/ViewModels/LatestMediaViewModel.swift @@ -14,11 +14,11 @@ import JellyfinAPI final class LatestMediaViewModel: ViewModel { @Published var items = [BaseItemDto]() + + let library: BaseItemDto - var libraryID: String - - init(libraryID: String) { - self.libraryID = libraryID + init(library: BaseItemDto) { + self.library = library super.init() requestLatestMedia() @@ -27,7 +27,7 @@ final class LatestMediaViewModel: ViewModel { func requestLatestMedia() { LogManager.shared.log.debug("Requesting latest media for user id \(SessionManager.main.currentLogin.user.id)") UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, - parentId: libraryID, + parentId: library.id ?? "", fields: [ .primaryImageAspectRatio, .seriesPrimaryImage, diff --git a/Shared/ViewModels/SeasonItemViewModel.swift b/Shared/ViewModels/SeasonItemViewModel.swift index c5a00a40..78e7edc3 100644 --- a/Shared/ViewModels/SeasonItemViewModel.swift +++ b/Shared/ViewModels/SeasonItemViewModel.swift @@ -13,13 +13,15 @@ import JellyfinAPI import Stinsen final class SeasonItemViewModel: ItemViewModel { + @RouterObject var itemRouter: ItemCoordinator.Router? - @Published private(set) var episodes: [BaseItemDto] = [] + @Published var episodes: [BaseItemDto] = [] + @Published var seriesItem: BaseItemDto? override init(item: BaseItemDto) { super.init(item: item) - self.item = item + getSeriesItem() requestEpisodes() } @@ -70,16 +72,17 @@ final class SeasonItemViewModel: ItemViewModel { playButtonItem = firstEpisode } } - - func routeToSeriesItem() { - guard let id = item.seriesId else { return } - UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id) + + private func getSeriesItem() { + guard let seriesID = item.seriesId else { return } + UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, + itemId: seriesID) .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in + .sink { [weak self] completion in self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] item in - self?.itemRouter?.route(to: \.item, item) - }) + } receiveValue: { [weak self] seriesItem in + self?.seriesItem = seriesItem + } .store(in: &cancellables) } }