cinematic views for tvOS and more final work

This commit is contained in:
Ethan Pippin 2022-01-03 18:38:50 -07:00
parent 40b6e5c680
commit 3eb92cd325
27 changed files with 956 additions and 122 deletions

View File

@ -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()
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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<Content: View>: 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")
}
}
}
}

View File

@ -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()
}
}
}

View File

@ -59,10 +59,13 @@ struct EpisodeItemView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
} }
Text(viewModel.item.getItemRuntime()).font(.subheadline) if let runtime = viewModel.item.getItemRuntime() {
.fontWeight(.medium) Text(runtime).font(.subheadline)
.foregroundColor(.secondary) .fontWeight(.medium)
.lineLimit(1) .foregroundColor(.secondary)
.lineLimit(1)
}
if viewModel.item.officialRating != nil { if viewModel.item.officialRating != nil {
Text(viewModel.item.officialRating!).font(.subheadline) Text(viewModel.item.officialRating!).font(.subheadline)
.fontWeight(.semibold) .fontWeight(.semibold)

View File

@ -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)
}
}
}
}

View File

@ -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) )
}
}

View File

@ -32,13 +32,13 @@ struct ItemView: View {
var body: some View { var body: some View {
Group { Group {
if item.type == "Movie" { if item.type == "Movie" {
MovieItemView(viewModel: .init(item: item)) CinematicMovieItemView(viewModel: MovieItemViewModel(item: item))
} else if item.type == "Series" { } else if item.type == "Series" {
SeriesItemView(viewModel: .init(item: item)) SeriesItemView(viewModel: .init(item: item))
} else if item.type == "Season" { } else if item.type == "Season" {
SeasonItemView(viewModel: .init(item: item)) SeasonItemView(viewModel: .init(item: item))
} else if item.type == "Episode" { } else if item.type == "Episode" {
EpisodeItemView(viewModel: .init(item: item)) CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item))
} else { } else {
Text(L10n.notImplementedYetWithType(item.type ?? "")) Text(L10n.notImplementedYetWithType(item.type ?? ""))
} }

View File

@ -59,10 +59,12 @@ struct MovieItemView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
} }
Text(viewModel.item.getItemRuntime()).font(.subheadline) if let runtime = viewModel.item.getItemRuntime() {
.fontWeight(.medium) Text(runtime).font(.subheadline)
.foregroundColor(.secondary) .fontWeight(.medium)
.lineLimit(1) .foregroundColor(.secondary)
.lineLimit(1)
}
if viewModel.item.officialRating != nil { if viewModel.item.officialRating != nil {
Text(viewModel.item.officialRating!).font(.subheadline) Text(viewModel.item.officialRating!).font(.subheadline)
.fontWeight(.semibold) .fontWeight(.semibold)

View File

@ -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)
}
}
}

View File

@ -374,6 +374,15 @@
E1D4BF8D2719F3A300A11E64 /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1D4BF8D2719F3A300A11E64 /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; };
E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */; }; E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */; };
E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E48CC8271E6D410021A2F9 /* RefreshHelper.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 */; }; E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; };
E1F0204F26CCCA74001C1C3B /* 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 */; }; 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 = "<group>"; }; E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsCoordinator.swift; sourceTree = "<group>"; };
E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = "<group>"; }; E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = "<group>"; };
E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshHelper.swift; sourceTree = "<group>"; }; E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshHelper.swift; sourceTree = "<group>"; };
E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicEpisodeItemView.swift; sourceTree = "<group>"; };
E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = "<group>"; };
E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemViewTopRow.swift; sourceTree = "<group>"; };
E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicMovieItemView.swift; sourceTree = "<group>"; };
E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemViewTopRowButton.swift; sourceTree = "<group>"; };
E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemsRowView.swift; sourceTree = "<group>"; };
E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsView.swift; sourceTree = "<group>"; };
E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemAboutView.swift; sourceTree = "<group>"; };
E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = "<group>"; };
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = "<group>"; }; E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = "<group>"; };
E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallMenuOverlay.swift; sourceTree = "<group>"; }; E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallMenuOverlay.swift; sourceTree = "<group>"; };
E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; }; E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; };
@ -1277,6 +1295,7 @@
53DF641D263D9C0600A7CD1A /* LibraryView.swift */, 53DF641D263D9C0600A7CD1A /* LibraryView.swift */,
53892771263C8C6F0035E14B /* LoadingView.swift */, 53892771263C8C6F0035E14B /* LoadingView.swift */,
5389276F263C25230035E14B /* NextUpView.swift */, 5389276F263C25230035E14B /* NextUpView.swift */,
E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */,
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */,
E13DD3E427177D15009D4DAF /* ServerListView.swift */, E13DD3E427177D15009D4DAF /* ServerListView.swift */,
539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */,
@ -1361,9 +1380,13 @@
E193D54E271942C000900D82 /* ItemView */ = { E193D54E271942C000900D82 /* ItemView */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E1E5D53C2783A85F00692DFE /* CinematicItemView */,
53272538268C20100035FBF1 /* EpisodeItemView.swift */, 53272538268C20100035FBF1 /* EpisodeItemView.swift */,
E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */,
E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */,
53CD2A3F268A49C2002ABD4E /* ItemView.swift */, 53CD2A3F268A49C2002ABD4E /* ItemView.swift */,
53CD2A41268A4B38002ABD4E /* MovieItemView.swift */, 53CD2A41268A4B38002ABD4E /* MovieItemView.swift */,
E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */,
53272536268C1DBB0035FBF1 /* SeasonItemView.swift */, 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */,
53116A16268B919A003024C9 /* SeriesItemView.swift */, 53116A16268B919A003024C9 /* SeriesItemView.swift */,
); );
@ -1414,6 +1437,18 @@
path = Objects; path = Objects;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
E1E5D53C2783A85F00692DFE /* CinematicItemView */ = {
isa = PBXGroup;
children = (
E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */,
E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */,
E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */,
E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */,
E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */,
);
path = CinematicItemView;
sourceTree = "<group>";
};
E1FCD08E26C466F3007C8DCF /* Errors */ = { E1FCD08E26C466F3007C8DCF /* Errors */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1838,6 +1873,7 @@
files = ( files = (
E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */,
E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */,
E1E5D53E2783B05200692DFE /* CinematicMovieItemView.swift in Sources */,
E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */, E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */,
E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */,
6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */, 6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */,
@ -1849,10 +1885,12 @@
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */,
E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */,
E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */,
E1E5D53B2783A80900692DFE /* CinematicItemViewTopRow.swift in Sources */,
E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */, E1C812CB277AE40900918266 /* NativePlayerViewController.swift in Sources */,
E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */, E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */,
C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */,
62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */, 62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */,
E1E5D5462783C28100692DFE /* CinematicItemAboutView.swift in Sources */,
E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */, E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */,
E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */,
53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */, 53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */,
@ -1866,6 +1904,7 @@
E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */, E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */,
091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */, 091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */,
E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */,
E1E5D5422783B33900692DFE /* PortraitItemsRowView.swift in Sources */,
E17885A6278130610094FBCF /* tvOSOverlayContent.swift in Sources */, E17885A6278130610094FBCF /* tvOSOverlayContent.swift in Sources */,
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */,
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */,
@ -1873,6 +1912,7 @@
E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */,
62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */, 62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */,
E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */,
E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */, E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */,
E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */, 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */,
@ -1917,6 +1957,7 @@
E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */, E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */,
E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */, E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */,
53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */, 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */,
E1E5D5402783B0C000692DFE /* CinematicItemViewTopRowButton.swift in Sources */,
5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */, 5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */,
53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */, 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */,
62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
@ -1950,6 +1991,8 @@
E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */,
C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */, C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */,
E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */,
E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */,
E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */,
536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */,
535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */,
E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */,
@ -2015,6 +2058,7 @@
E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */,
625CB56F2678C23300530A6E /* HomeView.swift in Sources */, 625CB56F2678C23300530A6E /* HomeView.swift in Sources */,
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */,
E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */,
E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */,
53892770263C25230035E14B /* NextUpView.swift in Sources */, 53892770263C25230035E14B /* NextUpView.swift in Sources */,
E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */,

View File

@ -81,10 +81,12 @@ struct EpisodeCardVStackView: View {
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text(item.getItemRuntime()) if let runtime = item.getItemRuntime() {
.font(.subheadline) Text(runtime)
.fontWeight(.medium) .font(.subheadline)
.foregroundColor(.secondary) .fontWeight(.medium)
.foregroundColor(.secondary)
}
Spacer() Spacer()
} }

View File

@ -28,11 +28,13 @@ struct ItemLandscapeTopBarView: View {
if viewModel.item.itemType.showDetails { if viewModel.item.itemType.showDetails {
// MARK: Runtime // MARK: Runtime
Text(viewModel.item.getItemRuntime()) if let runtime = viewModel.item.getItemRuntime() {
.font(.subheadline) Text(runtime)
.fontWeight(.medium) .font(.subheadline)
.foregroundColor(.secondary) .fontWeight(.medium)
.padding(.leading, 16) .foregroundColor(.secondary)
.padding(.leading, 16)
}
} }
// MARK: Details // MARK: Details

View File

@ -38,11 +38,13 @@ struct PortraitHeaderOverlayView: View {
if viewModel.item.itemType.showDetails { if viewModel.item.itemType.showDetails {
// MARK: Runtime // MARK: Runtime
if viewModel.shouldDisplayRuntime() { if viewModel.shouldDisplayRuntime() {
Text(viewModel.item.getItemRuntime()) if let runtime = viewModel.item.getItemRuntime() {
.font(.subheadline) Text(runtime)
.fontWeight(.medium) .font(.subheadline)
.foregroundColor(.secondary) .fontWeight(.medium)
.lineLimit(1) .foregroundColor(.secondary)
.lineLimit(1)
}
} }
} }

View File

@ -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)
}
}
}
}

View File

@ -21,54 +21,32 @@ struct SettingsView: View {
@Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode @Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode @Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
@Default(.appAppearance) var appAppearance @Default(.appAppearance) var appAppearance
@Default(.overlayType) var overlayType
@Default(.videoPlayerJumpForward) var jumpForwardLength @Default(.videoPlayerJumpForward) var jumpForwardLength
@Default(.videoPlayerJumpBackward) var jumpBackwardLength @Default(.videoPlayerJumpBackward) var jumpBackwardLength
@Default(.jumpGesturesEnabled) var jumpGesturesEnabled
var body: some View { var body: some View {
Form { Form {
Section(header: EmptyView()) { 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 Button {
// so this check is made settingsRouter.route(to: \.serverDetail)
if SessionManager.main.currentLogin == nil { } label: {
HStack { HStack {
Text("User") Text("Server")
.foregroundColor(.white)
Spacer() Spacer()
Text("") Text(viewModel.server.name)
.foregroundColor(.jellyfinPurple) .foregroundColor(.jellyfinPurple)
}
Button { Image(systemName: "chevron.right")
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")
}
} }
} }
@ -77,7 +55,7 @@ struct SettingsView: View {
SessionManager.main.logout() SessionManager.main.logout()
} }
} label: { } label: {
Text("Sign out") Text("Switch User")
.font(.callout) .font(.callout)
} }
} }
@ -108,6 +86,21 @@ struct SettingsView: View {
Text(length.label).tag(length.rawValue) 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) { Section(header: L10n.accessibility.text) {

View File

@ -19,15 +19,29 @@ struct VLCPlayerOverlayView: View {
@ViewBuilder @ViewBuilder
private var mainButtonView: some View { private var mainButtonView: some View {
switch viewModel.playerState { if viewModel.overlayType == .normal {
case .stopped, .paused: switch viewModel.playerState {
Image(systemName: "play.fill") case .stopped, .paused:
.font(.system(size: 28, weight: .heavy, design: .default)) Image(systemName: "play.fill")
case .playing: .font(.system(size: 56, weight: .semibold, design: .default))
Image(systemName: "pause") case .playing:
.font(.system(size: 28, weight: .heavy, design: .default)) Image(systemName: "pause")
default: .font(.system(size: 56, weight: .semibold, design: .default))
ProgressView() 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 // MARK: Top Bar
ZStack { ZStack {
LinearGradient(gradient: Gradient(colors: [.black.opacity(0.7), .clear]), if viewModel.overlayType == .compact {
startPoint: .top, LinearGradient(gradient: Gradient(colors: [.black.opacity(0.7), .clear]),
endPoint: .bottom) startPoint: .top,
.ignoresSafeArea() endPoint: .bottom)
.frame(height: 80) .ignoresSafeArea()
.frame(height: 80)
}
VStack(alignment: .EpisodeSeriesAlignmentGuide) { VStack(alignment: .EpisodeSeriesAlignmentGuide) {
@ -236,44 +252,71 @@ struct VLCPlayerOverlayView: View {
Spacer() 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() Spacer()
// MARK: Bottom Bar // MARK: Bottom Bar
ZStack { ZStack {
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]), if viewModel.overlayType == .compact {
startPoint: .top, LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]),
endPoint: .bottom) startPoint: .top,
.ignoresSafeArea() endPoint: .bottom)
.frame(height: 70) .ignoresSafeArea()
.frame(height: 70)
}
HStack { HStack {
HStack { if viewModel.overlayType == .compact {
Button { HStack {
viewModel.playerOverlayDelegate?.didSelectBackward() Button {
} label: { viewModel.playerOverlayDelegate?.didSelectBackward()
Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) } label: {
.padding(.horizontal, 5) Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel)
} .padding(.horizontal, 5)
}
Button {
viewModel.playerOverlayDelegate?.didSelectMain() Button {
} label: { viewModel.playerOverlayDelegate?.didSelectMain()
mainButtonView } label: {
.frame(minWidth: 30, maxWidth: 30) mainButtonView
.padding(.horizontal, 10) .frame(minWidth: 30, maxWidth: 30)
} .padding(.horizontal, 10)
}
Button {
viewModel.playerOverlayDelegate?.didSelectForward() Button {
} label: { viewModel.playerOverlayDelegate?.didSelectForward()
Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) } label: {
.padding(.horizontal, 5) 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) Text(viewModel.leftLabelText)
.font(.system(size: 18, weight: .semibold, design: .default)) .font(.system(size: 18, weight: .semibold, design: .default))
@ -296,6 +339,7 @@ struct VLCPlayerOverlayView: View {
thumbInteractiveSize: CGSize.Circle(radius: 40), thumbInteractiveSize: CGSize.Circle(radius: 40),
options: .defaultOptions) options: .defaultOptions)
) )
.frame(maxHeight: 50)
Text(viewModel.rightLabelText) Text(viewModel.rightLabelText)
.font(.system(size: 18, weight: .semibold, design: .default)) .font(.system(size: 18, weight: .semibold, design: .default))
@ -312,11 +356,22 @@ struct VLCPlayerOverlayView: View {
} }
var body: some View { var body: some View {
mainBody if viewModel.overlayType == .normal {
.contentShape(Rectangle()) mainBody
.onTapGesture { .background {
viewModel.playerOverlayDelegate?.didGenerallyTap() Color(uiColor: .black.withAlphaComponent(0.5))
} .ignoresSafeArea()
.onTapGesture {
viewModel.playerOverlayDelegate?.didGenerallyTap()
}
}
} else {
mainBody
.contentShape(Rectangle())
.onTapGesture {
viewModel.playerOverlayDelegate?.didGenerallyTap()
}
}
} }
} }

View File

@ -104,6 +104,8 @@ class VLCPlayerViewController: UIViewController {
// they aren't unnecessarily set more than once // they aren't unnecessarily set more than once
vlcMediaPlayer.delegate = self vlcMediaPlayer.delegate = self
vlcMediaPlayer.drawable = videoContentView vlcMediaPlayer.drawable = videoContentView
// TODO: Custom subtitle sizes
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14)
setupMediaPlayer(newViewModel: viewModel) setupMediaPlayer(newViewModel: viewModel)
@ -152,15 +154,20 @@ class VLCPlayerViewController: UIViewController {
view.translatesAutoresizingMaskIntoConstraints = false view.translatesAutoresizingMaskIntoConstraints = false
let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap))
let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe))
rightSwipeGesture.direction = .right rightSwipeGesture.direction = .right
let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe))
leftSwipeGesture.direction = .left leftSwipeGesture.direction = .left
view.addGestureRecognizer(singleTapGesture) view.addGestureRecognizer(singleTapGesture)
view.addGestureRecognizer(rightSwipeGesture)
view.addGestureRecognizer(leftSwipeGesture)
if viewModel.jumpGesturesEnabled {
view.addGestureRecognizer(rightSwipeGesture)
view.addGestureRecognizer(leftSwipeGesture)
}
return view return view
} }
@ -420,7 +427,7 @@ extension VLCPlayerViewController {
extension VLCPlayerViewController { extension VLCPlayerViewController {
private func flashJumpBackwardOverlay() { private func flashJumpBackwardOverlay() {
guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return }
currentJumpBackwardOverlayView.layer.removeAllAnimations() currentJumpBackwardOverlayView.layer.removeAllAnimations()
@ -440,7 +447,7 @@ extension VLCPlayerViewController {
} }
private func flashJumpFowardOverlay() { private func flashJumpFowardOverlay() {
guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return }
currentJumpForwardOverlayView.layer.removeAllAnimations() currentJumpForwardOverlayView.layer.removeAllAnimations()

View File

@ -17,13 +17,19 @@ final class SettingsCoordinator: NavigationCoordinatable {
@Root var start = makeStart @Root var start = makeStart
@Route(.push) var serverDetail = makeServerDetail @Route(.push) var serverDetail = makeServerDetail
@Route(.push) var overlaySettings = makeOverlaySettings
@ViewBuilder func makeServerDetail() -> some View { @ViewBuilder func makeServerDetail() -> some View {
let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server) let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server)
ServerDetailView(viewModel: viewModel) ServerDetailView(viewModel: viewModel)
} }
@ViewBuilder func makeOverlaySettings() -> some View {
OverlaySettingsView()
}
@ViewBuilder func makeStart() -> some View { @ViewBuilder func makeStart() -> some View {
SettingsView(viewModel: .init()) let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user)
SettingsView(viewModel: viewModel)
} }
} }

View File

@ -91,7 +91,7 @@ extension BaseItemDto {
} }
} }
let subtitlesEnabled = Defaults[.subtitlesEnabledIfDefault] && defaultSubtitleStream != nil let subtitlesEnabled = defaultSubtitleStream != nil
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay

View File

@ -142,7 +142,7 @@ public extension BaseItemDto {
// MARK: Calculations // MARK: Calculations
func getItemRuntime() -> String { func getItemRuntime() -> String? {
let timeHMSFormatter: DateComponentsFormatter = { let timeHMSFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter() let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated formatter.unitsStyle = .abbreviated
@ -151,7 +151,7 @@ public extension BaseItemDto {
}() }()
guard let runTimeTicks = runTimeTicks, 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 return text
} }

View File

@ -13,5 +13,13 @@ import Foundation
enum OverlayType: String, CaseIterable, Defaults.Serializable { enum OverlayType: String, CaseIterable, Defaults.Serializable {
case normal case normal
case compact case compact
case bottom
var label: String {
switch self {
case .normal:
return "Normal"
case .compact:
return "Compact"
}
}
} }

View File

@ -39,10 +39,9 @@ extension Defaults.Keys {
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite)
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite)
static let gesturesEnabled = Key<Bool>("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let jumpGesturesEnabled = Key<Bool>("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite)
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite)
static let subtitlesEnabledIfDefault = Key<Bool>("subtitlesEnabledIfDefault", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let autoplayEnabled = Key<Bool>("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let autoplayEnabled = Key<Bool>("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Should show video player items // Should show video player items

View File

@ -13,7 +13,15 @@ import JellyfinAPI
import Stinsen import Stinsen
final class EpisodeItemViewModel: ItemViewModel { final class EpisodeItemViewModel: ItemViewModel {
@RouterObject var itemRouter: ItemCoordinator.Router? @RouterObject var itemRouter: ItemCoordinator.Router?
var seasonEpisodes: [BaseItemDto] = []
override init(item: BaseItemDto) {
super.init(item: item)
getSeasonEpisodes()
}
override func getItemDisplayName() -> String { override func getItemDisplayName() -> String {
guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" } guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" }
@ -47,4 +55,18 @@ final class EpisodeItemViewModel: ItemViewModel {
}) })
.store(in: &cancellables) .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)
}
} }

View File

@ -15,8 +15,14 @@ final class SettingsViewModel: ObservableObject {
var bitrates: [Bitrates] = [] var bitrates: [Bitrates] = []
var langs: [TrackLanguage] = [] 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 // Bitrates
let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")! let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")!

View File

@ -34,8 +34,16 @@ final class VideoPlayerViewModel: ViewModel {
@Published var selectedSubtitleStreamIndex: Int @Published var selectedSubtitleStreamIndex: Int
@Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel? @Published var previousItemVideoPlayerViewModel: VideoPlayerViewModel?
@Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel? @Published var nextItemVideoPlayerViewModel: VideoPlayerViewModel?
@Published var jumpBackwardLength: VideoPlayerJumpLength @Published var jumpBackwardLength: VideoPlayerJumpLength {
@Published var jumpForwardLength: VideoPlayerJumpLength willSet {
Defaults[.videoPlayerJumpBackward] = newValue
}
}
@Published var jumpForwardLength: VideoPlayerJumpLength {
willSet {
Defaults[.videoPlayerJumpForward] = newValue
}
}
@Published var sliderIsScrubbing: Bool = false @Published var sliderIsScrubbing: Bool = false
@Published var sliderPercentage: Double = 0 { @Published var sliderPercentage: Double = 0 {
willSet { willSet {
@ -64,6 +72,7 @@ final class VideoPlayerViewModel: ViewModel {
let audioStreams: [MediaStream] let audioStreams: [MediaStream]
let subtitleStreams: [MediaStream] let subtitleStreams: [MediaStream]
let overlayType: OverlayType let overlayType: OverlayType
let jumpGesturesEnabled: Bool
// Full response kept for convenience // Full response kept for convenience
let response: PlaybackInfoResponse let response: PlaybackInfoResponse
@ -124,6 +133,7 @@ final class VideoPlayerViewModel: ViewModel {
self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward] self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward]
self.jumpForwardLength = Defaults[.videoPlayerJumpForward] self.jumpForwardLength = Defaults[.videoPlayerJumpForward]
self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled]
super.init() super.init()
@ -242,6 +252,9 @@ extension VideoPlayerViewModel {
.store(in: &cancellables) .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) { private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) {
if !masterViewModel.subtitlesEnabled { if !masterViewModel.subtitlesEnabled {
matchSubtitlesEnabled(with: masterViewModel) matchSubtitlesEnabled(with: masterViewModel)