From 3eb92cd32548fc141bfa3389e404ea84f35c544d Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 3 Jan 2022 18:38:50 -0700 Subject: [PATCH] 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)