From 7a79b78e38314c7b7242f2a9379c83d8f404c571 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Thu, 26 Aug 2021 22:02:53 -0600 Subject: [PATCH 1/9] Move portrait header view --- JellyfinPlayer/ItemView.swift | 165 ++++++++++++++++++++++++++--- JellyfinPlayer/MovieItemView.swift | 6 +- 2 files changed, 151 insertions(+), 20 deletions(-) diff --git a/JellyfinPlayer/ItemView.swift b/JellyfinPlayer/ItemView.swift index a60937cc..2f0db724 100644 --- a/JellyfinPlayer/ItemView.swift +++ b/JellyfinPlayer/ItemView.swift @@ -25,6 +25,123 @@ struct ItemView: View { init(item: BaseItemDto) { self.item = item } + + var portraitHeaderView: some View { + ImageView(src: item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), + bh: item.getBackdropImageBlurHash()) + .opacity(0.4) + .blur(radius: 2.0) + } + + var portraitHeaderOverlayView: some View { + VStack(alignment: .leading) { + HStack(alignment: .bottom, spacing: 12) { + ImageView(src: item.getPrimaryImage(maxWidth: 130)) + .frame(width: 130, height: 195) + .cornerRadius(10) + VStack(alignment: .leading) { + + Spacer() + + Text(item.name ?? "").font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + .offset(y: 5) + + HStack { + if item.productionYear != nil { + Text(String(item.productionYear ?? 0)).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Text(item.getItemRuntime()).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + + if item.officialRating != nil { + Text(item.officialRating!).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + } + .padding(.top, 1) + } + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) + } + HStack { + // Play button + Button { + () +// self.playbackInfo.itemToPlay = item +// self.playbackInfo.shouldShowPlayer = true + } label: { + HStack { + Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) + Text(item.getItemProgressString() == "" ? "Play" : item.getItemProgressString()) + .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) + } + .frame(width: 130, height: 40) + .background(Color.jellyfinPurple) + .cornerRadius(10) + } + + Spacer() + + Button { + print("Heart") + } label: { + Image(systemName: "heart").foregroundColor(.primary) + .font(.system(size: 20)) + } + + Button { + print("Check") + } label: { + Image(systemName: "checkmark.circle").foregroundColor(.primary) + .font(.system(size: 20)) + } + + +// Button { +// updateFavoriteState() +// () +// } label: { +// if viewModel.isFavorited { +// Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) +// .font(.system(size: 20)) +// } else { +// Image(systemName: "heart").foregroundColor(Color.primary) +// .font(.system(size: 20)) +// } +// } +// .disabled(viewModel.isLoading) +// Button { +// viewModel.updateWatchState() +// () +// } label: { +// if viewModel.isWatched { +// Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) +// .font(.system(size: 20)) +// } else { +// Image(systemName: "checkmark.circle").foregroundColor(Color.primary) +// .font(.system(size: 20)) +// } +// } +// .disabled(viewModel.isLoading) +// } + }.padding(.top, 8) + } + .padding(.horizontal, 16) + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) + } var body: some View { VStack { @@ -37,25 +154,41 @@ struct ItemView: View { }, isActive: $videoPlayerItem.shouldShowPlayer) { EmptyView() } - VStack { - if item.type == "Movie" { - MovieItemView(viewModel: .init(item: item)) - } else if item.type == "Season" { - SeasonItemView(viewModel: .init(item: item)) - } else if item.type == "Series" { - SeriesItemView(viewModel: .init(item: item)) - } else if item.type == "Episode" { - EpisodeItemView(viewModel: .init(item: item)) - } else { - Text("Type: \(item.type ?? "") not implemented yet :(") + + ParallaxHeaderScrollView(header: portraitHeaderView, + staticOverlayView: portraitHeaderOverlayView, + overlayAlignment: .bottomLeading, + headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds + .width * 0.5625) { + VStack { + Spacer() + .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24) + + + + + + if item.type == "Movie" { +// MovieItemView(viewModel: .init(item: item)) + Text("Movie") + } else if item.type == "Season" { + SeasonItemView(viewModel: .init(item: item)) + } else if item.type == "Series" { + SeriesItemView(viewModel: .init(item: item)) + } else if item.type == "Episode" { + EpisodeItemView(viewModel: .init(item: item)) + } else { + Text("Type: \(item.type ?? "") not implemented yet :(") + } } - } - .introspectTabBarController { (UITabBarController) in - UITabBarController.tabBar.isHidden = false + .introspectTabBarController { (UITabBarController) in + UITabBarController.tabBar.isHidden = false + } + .navigationBarBackButtonHidden(false) + .environmentObject(videoPlayerItem) } .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .environmentObject(videoPlayerItem) } } } diff --git a/JellyfinPlayer/MovieItemView.swift b/JellyfinPlayer/MovieItemView.swift index bcef0c77..8dfad20d 100644 --- a/JellyfinPlayer/MovieItemView.swift +++ b/JellyfinPlayer/MovieItemView.swift @@ -10,10 +10,8 @@ import SwiftUI struct MovieItemView: View { @StateObject var viewModel: MovieItemViewModel @State private var orientation = UIDeviceOrientation.unknown - @Environment(\.horizontalSizeClass) - var hSizeClass - @Environment(\.verticalSizeClass) - var vSizeClass + @Environment(\.horizontalSizeClass) var hSizeClass + @Environment(\.verticalSizeClass) var vSizeClass @EnvironmentObject private var playbackInfo: VideoPlayerItem From 2dbb0bd8901fc4f8a9ff92fb2b2df9acbbc6a8c3 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Fri, 27 Aug 2021 23:31:13 -0600 Subject: [PATCH 2/9] Simplify item view creation --- JellyfinPlayer.xcodeproj/project.pbxproj | 100 ++++++++--- .../Components/PillHStackView.swift | 60 +++++++ .../Components/PortraitHStackView.swift | 76 ++++++++ .../PortraitHeaderOverlayView.swift | 132 ++++++++++++++ .../Components/PortraitItemView.swift | 2 + JellyfinPlayer/ItemView.swift | 169 +++++------------- .../BaseItemDtoExtensions.swift} | 65 ++++--- .../BaseItemPersonExtensions.swift | 47 +++++ .../NameGUIDPairExtensions.swift | 17 ++ Shared/Objects/DetailItem.swift | 28 +++ .../DeviceProfileBuilder.swift | 0 .../DeviceRotationViewModifier.swift | 0 .../Views}/ImageView.swift | 0 .../Views}/LazyView.swift | 0 .../Views}/MultiSelectorView.swift | 0 .../Views}/ParallaxHeader.swift | 0 .../Views}/SearchBarView.swift | 0 .../Views}/SearchablePickerView.swift | 0 18 files changed, 521 insertions(+), 175 deletions(-) create mode 100644 JellyfinPlayer/Components/PillHStackView.swift create mode 100644 JellyfinPlayer/Components/PortraitHStackView.swift create mode 100644 JellyfinPlayer/Components/PortraitHeaderOverlayView.swift rename Shared/Extensions/{APIExtensions.swift => JellyfinAPIExtensions/BaseItemDtoExtensions.swift} (82%) create mode 100644 Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift create mode 100644 Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift create mode 100644 Shared/Objects/DetailItem.swift rename Shared/{Extensions => Objects}/DeviceProfileBuilder.swift (100%) rename Shared/{Extensions => Objects}/DeviceRotationViewModifier.swift (100%) rename Shared/{Extensions => Objects/Views}/ImageView.swift (100%) rename Shared/{Extensions => Objects/Views}/LazyView.swift (100%) rename Shared/{Extensions => Objects/Views}/MultiSelectorView.swift (100%) rename Shared/{Extensions => Objects/Views}/ParallaxHeader.swift (100%) rename Shared/{Extensions => Objects/Views}/SearchBarView.swift (100%) rename Shared/{Extensions => Objects/Views}/SearchablePickerView.swift (100%) diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 821cf188..01672051 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -78,8 +78,8 @@ 53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; }; 53649AB3269D3F5B00A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; }; 53649AB5269D423A00A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AB4269D423A00A2D8B7 /* Puppy */; }; - 5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; - 5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; + 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */; }; + 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */; }; 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D73267BA8170004248C /* BackgroundManager.swift */; }; 536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */; }; 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; }; @@ -188,7 +188,6 @@ 625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 625CB5792678C4A400530A6E /* ActivityIndicator */; }; 6260FFF926A09754003FA968 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = 6260FFF826A09754003FA968 /* CombineExt */; }; 6261A0E026A0AB710072EF1C /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = 6261A0DF26A0AB710072EF1C /* CombineExt */; }; - 6267B3D42671024A00A7371D /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; 6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; @@ -242,6 +241,20 @@ E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; + E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104926D94822003E4A08 /* DetailItem.swift */; }; + E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104926D94822003E4A08 /* DetailItem.swift */; }; + E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; + E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; + E1AD105426D97161003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; + E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */; }; + E1AD105726D981CE003E4A08 /* PortraitHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */; }; + E1AD105926D9A543003E4A08 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338B22660A07800A81A2A /* LazyView.swift */; }; + E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */; }; + E1AD105D26D9ABDD003E4A08 /* PillHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */; }; + E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; }; + E1AD106026D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; }; + E1AD106226D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift */; }; + E1AD106326D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift */; }; E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; @@ -347,7 +360,7 @@ 5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; 5362E4C8267D40F7000E2F71 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 53649AB0269CFB1900A2D8B7 /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = ""; }; - 5364F454266CA0DC0026ECBA /* APIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIExtensions.swift; sourceTree = ""; }; + 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemPersonExtensions.swift; sourceTree = ""; }; 536D3D73267BA8170004248C /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = ""; }; 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabViewModel.swift; sourceTree = ""; }; 536D3D7E267BDF100004248C /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = ""; }; @@ -446,6 +459,12 @@ E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; + E1AD104926D94822003E4A08 /* DetailItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItem.swift; sourceTree = ""; }; + E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = ""; }; + E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHStackView.swift; sourceTree = ""; }; + E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillHStackView.swift; sourceTree = ""; }; + E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGUIDPairExtensions.swift; sourceTree = ""; }; + E1AD106126D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHeaderOverlayView.swift; sourceTree = ""; }; E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = ""; }; E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; @@ -658,7 +677,11 @@ 535870AB2669D8D300D05A09 /* Objects */ = { isa = PBXGroup; children = ( + E1AD105326D96F5A003E4A08 /* Views */, + 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */, + 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, 535870AC2669D8DD00D05A09 /* Typings.swift */, + E1AD104926D94822003E4A08 /* DetailItem.swift */, E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */, ); path = Objects; @@ -898,6 +921,9 @@ 53F866422687A45400DCD1D7 /* Components */ = { isa = PBXGroup; children = ( + E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */, + E1AD106126D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift */, + E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */, 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */, ); path = Components; @@ -906,21 +932,13 @@ 621338912660106C00A81A2A /* Extensions */ = { isa = PBXGroup; children = ( - 53DE4BD1267098F300739748 /* SearchBarView.swift */, - E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */, - 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, - 531AC8BE26750DE20091C7EB /* ImageView.swift */, - 5364F454266CA0DC0026ECBA /* APIExtensions.swift */, 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */, - 621338B22660A07800A81A2A /* LazyView.swift */, - 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */, - 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */, - 621338922660107500A81A2A /* StringExtensions.swift */, 6267B3D526710B8900A7371D /* CollectionExtensions.swift */, - 6267B3D92671138200A7371D /* ImageExtensions.swift */, - 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */, + E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */, 62CB3F4A2685BB77003D0A6F /* DefaultsExtension.swift */, - 624C21742685CF60007F1390 /* SearchablePickerView.swift */, + 6267B3D92671138200A7371D /* ImageExtensions.swift */, + E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */, + 621338922660107500A81A2A /* StringExtensions.swift */, ); path = Extensions; sourceTree = ""; @@ -940,10 +958,10 @@ 62EC352A26766657000E9F2D /* Singleton */ = { isa = PBXGroup; children = ( - 62EC352B26766675000E9F2D /* ServerEnvironment.swift */, - 62EC352E267666A5000E9F2D /* SessionManager.swift */, 536D3D73267BA8170004248C /* BackgroundManager.swift */, 53649AB0269CFB1900A2D8B7 /* LogManager.swift */, + 62EC352B26766675000E9F2D /* ServerEnvironment.swift */, + 62EC352E267666A5000E9F2D /* SessionManager.swift */, ); path = Singleton; sourceTree = ""; @@ -968,12 +986,35 @@ path = Pods; sourceTree = ""; }; + E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */ = { + isa = PBXGroup; + children = ( + 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */, + E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */, + E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */, + ); + path = JellyfinAPIExtensions; + sourceTree = ""; + }; + E1AD105326D96F5A003E4A08 /* Views */ = { + isa = PBXGroup; + children = ( + 531AC8BE26750DE20091C7EB /* ImageView.swift */, + 621338B22660A07800A81A2A /* LazyView.swift */, + 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */, + 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */, + 624C21742685CF60007F1390 /* SearchablePickerView.swift */, + 53DE4BD1267098F300739748 /* SearchBarView.swift */, + ); + path = Views; + sourceTree = ""; + }; E1FCD08E26C466F3007C8DCF /* Errors */ = { isa = PBXGroup; children = ( - E1FCD08726C35A0D007C8DCF /* NetworkError.swift */, - E131691626C583BC0074BFEE /* LogConstructor.swift */, E1FCD09526C47118007C8DCF /* ErrorMessage.swift */, + E131691626C583BC0074BFEE /* LogConstructor.swift */, + E1FCD08726C35A0D007C8DCF /* NetworkError.swift */, ); path = Errors; sourceTree = ""; @@ -1346,6 +1387,7 @@ E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, + E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */, 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */, 531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */, 62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */, @@ -1359,15 +1401,18 @@ 62E632F4267D54030063E547 /* DetailItemViewModel.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, + E1AD106326D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift in Sources */, E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */, 535870A52669D8AE00D05A09 /* ParallaxHeader.swift in Sources */, 53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */, 531690F0267ABF72005D8AB9 /* NextUpView.swift in Sources */, 535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */, + E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, 62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, 536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */, 531690E5267ABD5C005D8AB9 /* MainTabView.swift in Sources */, 5310695B2684E7EE00CFFDBA /* AudioView.swift in Sources */, + E1AD106026D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */, 5398514726B64E4100101B49 /* SearchBarView.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */, @@ -1391,9 +1436,11 @@ 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, 53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */, - 5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */, + 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, + E1AD105D26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */, E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */, + E1AD105726D981CE003E4A08 /* PortraitHStackView.swift in Sources */, 535870A32669D89F00D05A09 /* Model.xcdatamodeld in Sources */, 09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */, ); @@ -1403,7 +1450,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */, + 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, 621338932660107500A81A2A /* StringExtensions.swift in Sources */, 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, @@ -1412,6 +1459,7 @@ 5377CBFE263B596B003A4E83 /* PersistenceController.swift in Sources */, 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, 53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */, + E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, 53AD124E26702B8A0094A276 /* SeasonItemView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, @@ -1426,6 +1474,7 @@ 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */, 0959A5FD2686D29800C7C9A9 /* VideoUpNextView.swift in Sources */, 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */, + E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, 625CB56F2678C23300530A6E /* HomeView.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */, 53892770263C25230035E14B /* NextUpView.swift in Sources */, @@ -1447,9 +1496,11 @@ 625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */, 62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, + E1AD106226D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, + E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */, 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, 624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */, E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */, @@ -1457,7 +1508,9 @@ 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, 62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, + E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */, + E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */, 62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */, 6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */, 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */, @@ -1481,11 +1534,12 @@ files = ( 53649AB3269D3F5B00A2D8B7 /* LogManager.swift in Sources */, 62EC353126766848000E9F2D /* ServerEnvironment.swift in Sources */, - 6267B3D42671024A00A7371D /* APIExtensions.swift in Sources */, 6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */, 628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */, 6267B3DB2671139400A7371D /* ImageExtensions.swift in Sources */, + E1AD105926D9A543003E4A08 /* LazyView.swift in Sources */, 628B95372670CB800091AF3B /* JellyfinWidget.swift in Sources */, + E1AD105426D97161003E4A08 /* BaseItemDtoExtensions.swift in Sources */, 6228B1C22670EB010067FD35 /* PersistenceController.swift in Sources */, E1FCD09A26C4F35A007C8DCF /* ErrorMessage.swift in Sources */, 628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */, diff --git a/JellyfinPlayer/Components/PillHStackView.swift b/JellyfinPlayer/Components/PillHStackView.swift new file mode 100644 index 00000000..fe0217bd --- /dev/null +++ b/JellyfinPlayer/Components/PillHStackView.swift @@ -0,0 +1,60 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI + +protocol PillStackable { + var title: String { get } +} + +struct PillHStackView: View { + + let title: String + let items: [ItemType] + let navigationView: (ItemType) -> NavigationView + + var body: some View { + VStack(alignment: .leading) { + Text(title) + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(items, id: \.title) { item in + NavigationLink(destination: LazyView { + navigationView(item) + }) { + ZStack { + Color(UIColor.secondarySystemBackground) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .cornerRadius(10) + + Text(item.title) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.white) + .fixedSize() + .padding(.leading, 10) + .padding(.trailing, 10) + .padding(.top, 10) + .padding(.bottom, 10) + } + .fixedSize() + } + } + } + .padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + } + } + } +} diff --git a/JellyfinPlayer/Components/PortraitHStackView.swift b/JellyfinPlayer/Components/PortraitHStackView.swift new file mode 100644 index 00000000..5723aaec --- /dev/null +++ b/JellyfinPlayer/Components/PortraitHStackView.swift @@ -0,0 +1,76 @@ +// + /* + * 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 + +public protocol PortraitImageStackable { + func imageURLContsructor(maxWidth: Int) -> URL + var title: String { get } + var description: String? { get } + var blurHash: String { get } +} + +struct PortraitImageHStackView: View { + + let title: String + let items: [ItemType] + let maxWidth: Int + let navigationView: (ItemType) -> NavigationView + + var body: some View { + VStack(alignment: .leading) { + Text(title) + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) + + ScrollView(.horizontal, showsIndicators: false) { + VStack { + Spacer().frame(height: 8) + HStack { + Spacer().frame(width: 16) + ForEach(items, id: \.title) { item in + NavigationLink( + destination: LazyView { + navigationView(item) + }, + label: { + VStack { + ImageView(src: item.imageURLContsructor(maxWidth: maxWidth), + bh: item.blurHash) + .frame(width: 100, height: CGFloat(maxWidth)) + .cornerRadius(10) + .shadow(radius: 4, y: 2) + + Text(item.title) + .font(.footnote) + .fontWeight(.regular) + .lineLimit(1) + .frame(width: 100) + .foregroundColor(.primary) + + if let description = item.description { + Text(description) + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + .frame(width: 100) + .foregroundColor(.secondary) + } + } + }) + } + Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + } + } + }.padding(.top, -3) + } + } +} diff --git a/JellyfinPlayer/Components/PortraitHeaderOverlayView.swift b/JellyfinPlayer/Components/PortraitHeaderOverlayView.swift new file mode 100644 index 00000000..dfae5798 --- /dev/null +++ b/JellyfinPlayer/Components/PortraitHeaderOverlayView.swift @@ -0,0 +1,132 @@ +// + /* + * 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 PortraitHeaderOverlayView: View { + + @EnvironmentObject var viewModel: DetailItemViewModel + let item: BaseItemDto + + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .bottom, spacing: 12) { + ImageView(src: item.portraitHeaderViewURL(maxWidth: 130)) + .frame(width: 130, height: 195) + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 1) { + Spacer() + + Text(item.name ?? "") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + .offset(y: 5) + + Text(item.getItemRuntime()) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(.top, 10) + + HStack { + if let productionYear = item.productionYear { + Text(String(productionYear)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + + if let officialRating = item.officialRating { + Text(officialRating) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + } + } + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) + } + + HStack { + // Play button + Button { + () +// self.playbackInfo.itemToPlay = item +// self.playbackInfo.shouldShowPlayer = true + } label: { + HStack { + Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) + Text(item.getItemProgressString() == "" ? "Play" : item.getItemProgressString()) + .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) + } + .frame(width: 130, height: 40) + .background(Color.jellyfinPurple) + .cornerRadius(10) + } + + Spacer() + + Button { + print("Heart") + } label: { + Image(systemName: "heart").foregroundColor(.primary) + .font(.system(size: 20)) + } + + Button { + print("Check") + } label: { + Image(systemName: "checkmark.circle").foregroundColor(.primary) + .font(.system(size: 20)) + } + + +// Button { +// updateFavoriteState() +// () +// } label: { +// if viewModel.isFavorited { +// Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) +// .font(.system(size: 20)) +// } else { +// Image(systemName: "heart").foregroundColor(Color.primary) +// .font(.system(size: 20)) +// } +// } +// .disabled(viewModel.isLoading) +// Button { +// viewModel.updateWatchState() +// () +// } label: { +// if viewModel.isWatched { +// Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) +// .font(.system(size: 20)) +// } else { +// Image(systemName: "checkmark.circle").foregroundColor(Color.primary) +// .font(.system(size: 20)) +// } +// } +// .disabled(viewModel.isLoading) +// } + }.padding(.top, 8) + } + .padding(.horizontal, 16) + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) + } +} diff --git a/JellyfinPlayer/Components/PortraitItemView.swift b/JellyfinPlayer/Components/PortraitItemView.swift index 60aa77a1..1e41226b 100644 --- a/JellyfinPlayer/Components/PortraitItemView.swift +++ b/JellyfinPlayer/Components/PortraitItemView.swift @@ -9,7 +9,9 @@ import SwiftUI import JellyfinAPI + struct PortraitItemView: View { + var item: BaseItemDto var body: some View { diff --git a/JellyfinPlayer/ItemView.swift b/JellyfinPlayer/ItemView.swift index 2f0db724..e9716b5f 100644 --- a/JellyfinPlayer/ItemView.swift +++ b/JellyfinPlayer/ItemView.swift @@ -34,118 +34,15 @@ struct ItemView: View { } var portraitHeaderOverlayView: some View { - VStack(alignment: .leading) { - HStack(alignment: .bottom, spacing: 12) { - ImageView(src: item.getPrimaryImage(maxWidth: 130)) - .frame(width: 130, height: 195) - .cornerRadius(10) - VStack(alignment: .leading) { - - Spacer() - - Text(item.name ?? "").font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - .offset(y: 5) - - HStack { - if item.productionYear != nil { - Text(String(item.productionYear ?? 0)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - - Text(item.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - - if item.officialRating != nil { - Text(item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } - .padding(.top, 1) - } - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) - } - HStack { - // Play button - Button { - () -// self.playbackInfo.itemToPlay = item -// self.playbackInfo.shouldShowPlayer = true - } label: { - HStack { - Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) - Text(item.getItemProgressString() == "" ? "Play" : item.getItemProgressString()) - .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) - } - .frame(width: 130, height: 40) - .background(Color.jellyfinPurple) - .cornerRadius(10) - } - - Spacer() - - Button { - print("Heart") - } label: { - Image(systemName: "heart").foregroundColor(.primary) - .font(.system(size: 20)) - } - - Button { - print("Check") - } label: { - Image(systemName: "checkmark.circle").foregroundColor(.primary) - .font(.system(size: 20)) - } - - -// Button { -// updateFavoriteState() -// () -// } label: { -// if viewModel.isFavorited { -// Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) -// .font(.system(size: 20)) -// } else { -// Image(systemName: "heart").foregroundColor(Color.primary) -// .font(.system(size: 20)) -// } -// } -// .disabled(viewModel.isLoading) -// Button { -// viewModel.updateWatchState() -// () -// } label: { -// if viewModel.isWatched { -// Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) -// .font(.system(size: 20)) -// } else { -// Image(systemName: "checkmark.circle").foregroundColor(Color.primary) -// .font(.system(size: 20)) -// } -// } -// .disabled(viewModel.isLoading) -// } - }.padding(.top, 8) - } - .padding(.horizontal, 16) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) + PortraitHeaderOverlayView(item: item) } var body: some View { VStack { - NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) { VLCPlayerWithControls(item: videoPlayerItem.itemToPlay, loadBinding: $videoIsLoading, pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer) + NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) { + VLCPlayerWithControls(item: videoPlayerItem.itemToPlay, + loadBinding: $videoIsLoading, + pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer) .navigationBarHidden(true) .navigationBarBackButtonHidden(true) .statusBar(hidden: true) @@ -158,29 +55,53 @@ struct ItemView: View { ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitHeaderOverlayView, overlayAlignment: .bottomLeading, - headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds - .width * 0.5625) { + headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) { VStack { Spacer() .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24) + // MARK: Overview + Text(item.overview ?? "") + .font(.footnote) + .padding(.top, 3) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 3) + .padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - - - - if item.type == "Movie" { -// MovieItemView(viewModel: .init(item: item)) - Text("Movie") - } else if item.type == "Season" { - SeasonItemView(viewModel: .init(item: item)) - } else if item.type == "Series" { - SeriesItemView(viewModel: .init(item: item)) - } else if item.type == "Episode" { - EpisodeItemView(viewModel: .init(item: item)) - } else { - Text("Type: \(item.type ?? "") not implemented yet :(") + // MARK: Genres + PillHStackView(title: "Genres", items: item.genreItems ?? []) { genre in + LibraryView(viewModel: .init(genre: genre), title: genre.title) } + + // MARK: Studios + if !(item.studios ?? []).isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + Text("Studios:").font(.callout).fontWeight(.semibold) + ForEach(item.studios!, id: \.id) { studio in + NavigationLink(destination: LazyView { + LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") + }) { + Text(studio.name ?? "").font(.footnote) + } + } + } + .padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + } + } + + // MARK: Cast + PortraitImageHStackView(title: "Cast", + items: item.people!, + maxWidth: 150) { person in + LibraryView(viewModel: .init(person: person), title: person.title) + } + + // MARK: More Like This + } .introspectTabBarController { (UITabBarController) in UITabBarController.tabBar.isHidden = false diff --git a/Shared/Extensions/APIExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift similarity index 82% rename from Shared/Extensions/APIExtensions.swift rename to Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 24a8a5f4..44dd8ab0 100644 --- a/Shared/Extensions/APIExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -1,9 +1,11 @@ -/* 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 - */ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ import Foundation import JellyfinAPI @@ -11,7 +13,8 @@ import UIKit // 001fC^ = dark grey plain blurhash -extension BaseItemDto { +public extension BaseItemDto { + // MARK: Images func getSeriesBackdropImageBlurHash() -> String { @@ -149,27 +152,33 @@ extension BaseItemDto { return "\(String(progminutes))m" } } -} - -func round(_ value: Double, toNearest: Double) -> Double { - return round(value / toNearest) * toNearest -} - -extension BaseItemPerson { - func getImage(baseURL: String, maxWidth: Int) -> URL { - let imageType = "Primary" - let imageTag = primaryImageTag ?? "" - - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - - let urlString = "\(baseURL)/Items/\(id ?? "")/Images/\(imageType)?maxWidth=\(String(Int(x)))&quality=85&tag=\(imageTag)" - return URL(string: urlString)! + + // MARK: ItemType + + enum ItemType: String { + case movie = "Movie" + case season = "Season" + case episode = "Episode" + case series = "Series" + + case unknown } - - func getBlurHash() -> String { - let rawImgURL = getImage(baseURL: "", maxWidth: 1).absoluteString - let imgTag = rawImgURL.components(separatedBy: "&tag=")[1] - - return imageBlurHashes?.primary?[imgTag] ?? "001fC^" + + var itemType: ItemType { + guard let originalType = self.type, let knownType = ItemType(rawValue: originalType) else { return .unknown } + return knownType + } + + // MARK: PortraitHeaderViewURL + + func portraitHeaderViewURL(maxWidth: Int) -> URL { + switch self.itemType { + case .movie, .season, .series: + return getPrimaryImage(maxWidth: maxWidth) + case .episode: + return getSeriesPrimaryImage(maxWidth: maxWidth) + case .unknown: + return getPrimaryImage(maxWidth: maxWidth) + } } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift new file mode 100644 index 00000000..ff1cf99d --- /dev/null +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift @@ -0,0 +1,47 @@ +/* SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import JellyfinAPI +import UIKit + +extension BaseItemPerson { + func getImage(baseURL: String, maxWidth: Int) -> URL { + let imageType = "Primary" + let imageTag = primaryImageTag ?? "" + + let x = UIScreen.main.nativeScale * CGFloat(maxWidth) + + let urlString = "\(baseURL)/Items/\(id ?? "")/Images/\(imageType)?maxWidth=\(String(Int(x)))&quality=85&tag=\(imageTag)" + return URL(string: urlString)! + } + + func getBlurHash() -> String { + let rawImgURL = getImage(baseURL: "", maxWidth: 1).absoluteString + let imgTag = rawImgURL.components(separatedBy: "&tag=")[1] + + return imageBlurHashes?.primary?[imgTag] ?? "001fC^" + } +} + +extension BaseItemPerson: PortraitImageStackable { + public func imageURLContsructor(maxWidth: Int) -> URL { + return self.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: maxWidth) + } + + public var title: String { + return self.name ?? "" + } + + public var description: String? { + return self.role + } + + public var blurHash: String { + return self.getBlurHash() + } +} diff --git a/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift new file mode 100644 index 00000000..bc7311fa --- /dev/null +++ b/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift @@ -0,0 +1,17 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import JellyfinAPI + +extension NameGuidPair: PillStackable { + var title: String { + return self.name ?? "" + } +} diff --git a/Shared/Objects/DetailItem.swift b/Shared/Objects/DetailItem.swift new file mode 100644 index 00000000..bf5b1792 --- /dev/null +++ b/Shared/Objects/DetailItem.swift @@ -0,0 +1,28 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import JellyfinAPI + +enum DetailItemType: String { + case movie = "Movie" + case season = "Season" + case series = "Series" + case episode = "Episode" +} + +struct DetailItem { + + let baseItem: BaseItemDto + let type: DetailItemType + + + + +} diff --git a/Shared/Extensions/DeviceProfileBuilder.swift b/Shared/Objects/DeviceProfileBuilder.swift similarity index 100% rename from Shared/Extensions/DeviceProfileBuilder.swift rename to Shared/Objects/DeviceProfileBuilder.swift diff --git a/Shared/Extensions/DeviceRotationViewModifier.swift b/Shared/Objects/DeviceRotationViewModifier.swift similarity index 100% rename from Shared/Extensions/DeviceRotationViewModifier.swift rename to Shared/Objects/DeviceRotationViewModifier.swift diff --git a/Shared/Extensions/ImageView.swift b/Shared/Objects/Views/ImageView.swift similarity index 100% rename from Shared/Extensions/ImageView.swift rename to Shared/Objects/Views/ImageView.swift diff --git a/Shared/Extensions/LazyView.swift b/Shared/Objects/Views/LazyView.swift similarity index 100% rename from Shared/Extensions/LazyView.swift rename to Shared/Objects/Views/LazyView.swift diff --git a/Shared/Extensions/MultiSelectorView.swift b/Shared/Objects/Views/MultiSelectorView.swift similarity index 100% rename from Shared/Extensions/MultiSelectorView.swift rename to Shared/Objects/Views/MultiSelectorView.swift diff --git a/Shared/Extensions/ParallaxHeader.swift b/Shared/Objects/Views/ParallaxHeader.swift similarity index 100% rename from Shared/Extensions/ParallaxHeader.swift rename to Shared/Objects/Views/ParallaxHeader.swift diff --git a/Shared/Extensions/SearchBarView.swift b/Shared/Objects/Views/SearchBarView.swift similarity index 100% rename from Shared/Extensions/SearchBarView.swift rename to Shared/Objects/Views/SearchBarView.swift diff --git a/Shared/Extensions/SearchablePickerView.swift b/Shared/Objects/Views/SearchablePickerView.swift similarity index 100% rename from Shared/Extensions/SearchablePickerView.swift rename to Shared/Objects/Views/SearchablePickerView.swift From f9114ae6bee7467c16a931bf899c20c6b7b47ba5 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 28 Aug 2021 22:25:43 -0600 Subject: [PATCH 3/9] Create explicit portrait and landscape views --- JellyfinPlayer.xcodeproj/project.pbxproj | 30 +++-- .../Components/PillHStackView.swift | 4 +- .../Components/PortraitHStackView.swift | 19 +-- JellyfinPlayer/ItemView.swift | 115 ------------------ .../ItemView/ItemLandscapeBodyView.swift | 16 +++ .../ItemView/ItemPortraitBodyView.swift | 103 ++++++++++++++++ .../ItemPortraitHeaderOverlayView.swift} | 79 ++++++------ JellyfinPlayer/ItemView/ItemView.swift | 50 ++++++++ 8 files changed, 243 insertions(+), 173 deletions(-) delete mode 100644 JellyfinPlayer/ItemView.swift create mode 100644 JellyfinPlayer/ItemView/ItemLandscapeBodyView.swift create mode 100644 JellyfinPlayer/ItemView/ItemPortraitBodyView.swift rename JellyfinPlayer/{Components/PortraitHeaderOverlayView.swift => ItemView/ItemPortraitHeaderOverlayView.swift} (61%) create mode 100644 JellyfinPlayer/ItemView/ItemView.swift diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 01672051..348ea06d 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -238,6 +238,8 @@ E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; E131691926C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; + E14F7D0726DB36EF007C3AE6 /* ItemPortraitBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */; }; + E14F7D0926DB36F7007C3AE6 /* ItemLandscapeBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */; }; E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; @@ -253,8 +255,8 @@ E1AD105D26D9ABDD003E4A08 /* PillHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */; }; E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; }; E1AD106026D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; }; - E1AD106226D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift */; }; - E1AD106326D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift */; }; + E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */; }; + E1AD106326D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */; }; E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; @@ -456,6 +458,8 @@ DE5004F745B19E28744A7DE7 /* Pods-JellyfinPlayer tvOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.debug.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.debug.xcconfig"; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; E131691626C583BC0074BFEE /* LogConstructor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogConstructor.swift; sourceTree = ""; }; + E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitBodyView.swift; sourceTree = ""; }; + E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeBodyView.swift; sourceTree = ""; }; E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; @@ -464,7 +468,7 @@ E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHStackView.swift; sourceTree = ""; }; E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillHStackView.swift; sourceTree = ""; }; E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGUIDPairExtensions.swift; sourceTree = ""; }; - E1AD106126D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHeaderOverlayView.swift; sourceTree = ""; }; + E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitHeaderOverlayView.swift; sourceTree = ""; }; E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = ""; }; E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; @@ -734,7 +738,7 @@ 5389276D263C25100035E14B /* ContinueWatchingView.swift */, 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */, 5377CC02263B596B003A4E83 /* Info.plist */, - 535BAE9E2649E569005FA86D /* ItemView.swift */, + E14F7D0A26DB3714007C3AE6 /* ItemView */, 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */, 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */, 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */, @@ -922,7 +926,6 @@ isa = PBXGroup; children = ( E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */, - E1AD106126D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift */, E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */, 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */, ); @@ -986,6 +989,17 @@ path = Pods; sourceTree = ""; }; + E14F7D0A26DB3714007C3AE6 /* ItemView */ = { + isa = PBXGroup; + children = ( + 535BAE9E2649E569005FA86D /* ItemView.swift */, + E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */, + E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */, + E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */, + ); + path = ItemView; + sourceTree = ""; + }; E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */ = { isa = PBXGroup; children = ( @@ -1401,7 +1415,7 @@ 62E632F4267D54030063E547 /* DetailItemViewModel.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, - E1AD106326D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift in Sources */, + E1AD106326D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */, 535870A52669D8AE00D05A09 /* ParallaxHeader.swift in Sources */, 53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */, @@ -1484,6 +1498,7 @@ E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, 532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */, + E14F7D0926DB36F7007C3AE6 /* ItemLandscapeBodyView.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, 5377CC01263B596B003A4E83 /* Model.xcdatamodeld in Sources */, 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, @@ -1496,7 +1511,8 @@ 625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */, 62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, - E1AD106226D9B7CD003E4A08 /* PortraitHeaderOverlayView.swift in Sources */, + E14F7D0726DB36EF007C3AE6 /* ItemPortraitBodyView.swift in Sources */, + E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, diff --git a/JellyfinPlayer/Components/PillHStackView.swift b/JellyfinPlayer/Components/PillHStackView.swift index fe0217bd..3c6fcf06 100644 --- a/JellyfinPlayer/Components/PillHStackView.swift +++ b/JellyfinPlayer/Components/PillHStackView.swift @@ -34,14 +34,14 @@ struct PillHStackView: View { navigationView(item) }) { ZStack { - Color(UIColor.secondarySystemBackground) + Color(UIColor.systemFill) .frame(maxWidth: .infinity, maxHeight: .infinity) .cornerRadius(10) Text(item.title) .font(.caption) .fontWeight(.semibold) - .foregroundColor(.white) + .foregroundColor(.primary) .fixedSize() .padding(.leading, 10) .padding(.trailing, 10) diff --git a/JellyfinPlayer/Components/PortraitHStackView.swift b/JellyfinPlayer/Components/PortraitHStackView.swift index 5723aaec..b6668419 100644 --- a/JellyfinPlayer/Components/PortraitHStackView.swift +++ b/JellyfinPlayer/Components/PortraitHStackView.swift @@ -16,20 +16,25 @@ public protocol PortraitImageStackable { var blurHash: String { get } } -struct PortraitImageHStackView: View { +struct PortraitImageHStackView: View { - let title: String let items: [ItemType] let maxWidth: Int + let horizontalAlignment: HorizontalAlignment + let topBarView: () -> TopBarView let navigationView: (ItemType) -> NavigationView + init(items: [ItemType], maxWidth: Int, horizontalAlignment: HorizontalAlignment = .leading, topBarView: @escaping () -> TopBarView, navigationView: @escaping (ItemType) -> NavigationView) { + self.items = items + self.maxWidth = maxWidth + self.horizontalAlignment = horizontalAlignment + self.topBarView = topBarView + self.navigationView = navigationView + } + var body: some View { VStack(alignment: .leading) { - Text(title) - .font(.callout) - .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) + topBarView() ScrollView(.horizontal, showsIndicators: false) { VStack { diff --git a/JellyfinPlayer/ItemView.swift b/JellyfinPlayer/ItemView.swift deleted file mode 100644 index e9716b5f..00000000 --- a/JellyfinPlayer/ItemView.swift +++ /dev/null @@ -1,115 +0,0 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI -import Introspect -import JellyfinAPI - -class VideoPlayerItem: ObservableObject { - @Published var shouldShowPlayer: Bool = false - @Published var itemToPlay: BaseItemDto = BaseItemDto() -} - -struct ItemView: View { - private var item: BaseItemDto - - @StateObject private var videoPlayerItem: VideoPlayerItem = VideoPlayerItem() - @State private var videoIsLoading: Bool = false; // This variable is only changed by the underlying VLC view. - @State private var isLoading: Bool = false - @State private var viewDidLoad: Bool = false - - init(item: BaseItemDto) { - self.item = item - } - - var portraitHeaderView: some View { - ImageView(src: item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), - bh: item.getBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - } - - var portraitHeaderOverlayView: some View { - PortraitHeaderOverlayView(item: item) - } - - var body: some View { - VStack { - NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) { - VLCPlayerWithControls(item: videoPlayerItem.itemToPlay, - loadBinding: $videoIsLoading, - pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer) - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) - .statusBar(hidden: true) - .edgesIgnoringSafeArea(.all) - .prefersHomeIndicatorAutoHidden(true) - }, isActive: $videoPlayerItem.shouldShowPlayer) { - EmptyView() - } - - ParallaxHeaderScrollView(header: portraitHeaderView, - staticOverlayView: portraitHeaderOverlayView, - overlayAlignment: .bottomLeading, - headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) { - VStack { - Spacer() - .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24) - - // MARK: Overview - Text(item.overview ?? "") - .font(.footnote) - .padding(.top, 3) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 3) - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - - // MARK: Genres - PillHStackView(title: "Genres", items: item.genreItems ?? []) { genre in - LibraryView(viewModel: .init(genre: genre), title: genre.title) - } - - // MARK: Studios - if !(item.studios ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(item.studios!, id: \.id) { studio in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - } - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - } - - // MARK: Cast - PortraitImageHStackView(title: "Cast", - items: item.people!, - maxWidth: 150) { person in - LibraryView(viewModel: .init(person: person), title: person.title) - } - - // MARK: More Like This - - } - .introspectTabBarController { (UITabBarController) in - UITabBarController.tabBar.isHidden = false - } - .navigationBarBackButtonHidden(false) - .environmentObject(videoPlayerItem) - } - .navigationBarHidden(false) - } - } -} diff --git a/JellyfinPlayer/ItemView/ItemLandscapeBodyView.swift b/JellyfinPlayer/ItemView/ItemLandscapeBodyView.swift new file mode 100644 index 00000000..6e0cdc9a --- /dev/null +++ b/JellyfinPlayer/ItemView/ItemLandscapeBodyView.swift @@ -0,0 +1,16 @@ +// + /* + * 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 ItemLandscapeBodyView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} diff --git a/JellyfinPlayer/ItemView/ItemPortraitBodyView.swift b/JellyfinPlayer/ItemView/ItemPortraitBodyView.swift new file mode 100644 index 00000000..619f8b88 --- /dev/null +++ b/JellyfinPlayer/ItemView/ItemPortraitBodyView.swift @@ -0,0 +1,103 @@ +// + /* + * 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 ItemPortraitBodyView: View { + + @Binding var videoIsLoading: Bool + @EnvironmentObject var viewModel: MovieItemViewModel + @EnvironmentObject var videoPlayerItem: VideoPlayerItem + + private let item: BaseItemDto + private let portraitHeaderView: (BaseItemDto) -> PortraitHeaderView + private let portraitStaticOverlayView: (BaseItemDto) -> PortraitStaticOverlayView + + init(item: BaseItemDto, videoIsLoading: Binding, portraitHeaderView: @escaping (BaseItemDto) -> PortraitHeaderView, portraitStaticOverlayView: @escaping (BaseItemDto) -> PortraitStaticOverlayView) { + self.item = item + self._videoIsLoading = videoIsLoading + self.portraitHeaderView = portraitHeaderView + self.portraitStaticOverlayView = portraitStaticOverlayView + } + + var body: some View { + VStack(alignment: .leading) { + NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) { + VLCPlayerWithControls(item: videoPlayerItem.itemToPlay, + loadBinding: $videoIsLoading, + pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .statusBar(hidden: true) + .edgesIgnoringSafeArea(.all) + .prefersHomeIndicatorAutoHidden(true) + }, isActive: $videoPlayerItem.shouldShowPlayer) { + EmptyView() + } + + // MARK: Body + ParallaxHeaderScrollView(header: portraitHeaderView(item), + staticOverlayView: portraitStaticOverlayView(item), + overlayAlignment: .bottomLeading, + headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) { + VStack { + Spacer() + .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24) + + // MARK: Overview + Text(item.overview ?? "") + .font(.footnote) + .padding(.top, 3) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 3) + .padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + + // MARK: Genres + PillHStackView(title: "Genres", + items: item.genreItems ?? []) { genre in + LibraryView(viewModel: .init(genre: genre), title: genre.title) + } + + // MARK: Studios + if let studios = item.studios { + PillHStackView(title: "Studios", + items: studios) { studio in + LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") + } + } + + // MARK: Cast + PortraitImageHStackView(items: item.people?.filter({ $0.type == "Actor" }) ?? [], + maxWidth: 150) { + Text("Cast") + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) + } navigationView: { person in + LibraryView(viewModel: .init(person: person), title: person.title) + } + + // MARK: More Like This + + + Spacer(minLength: 10) + } +// .introspectTabBarController { (UITabBarController) in +// UITabBarController.tabBar.isHidden = false +// } +// .navigationBarBackButtonHidden(false) + .environmentObject(videoPlayerItem) + } + } + } +} diff --git a/JellyfinPlayer/Components/PortraitHeaderOverlayView.swift b/JellyfinPlayer/ItemView/ItemPortraitHeaderOverlayView.swift similarity index 61% rename from JellyfinPlayer/Components/PortraitHeaderOverlayView.swift rename to JellyfinPlayer/ItemView/ItemPortraitHeaderOverlayView.swift index dfae5798..d9ee3fd8 100644 --- a/JellyfinPlayer/Components/PortraitHeaderOverlayView.swift +++ b/JellyfinPlayer/ItemView/ItemPortraitHeaderOverlayView.swift @@ -12,7 +12,9 @@ import JellyfinAPI struct PortraitHeaderOverlayView: View { - @EnvironmentObject var viewModel: DetailItemViewModel + @EnvironmentObject private var viewModel: DetailItemViewModel + @EnvironmentObject private var videoPlayerItem: VideoPlayerItem + let item: BaseItemDto var body: some View { @@ -64,16 +66,20 @@ struct PortraitHeaderOverlayView: View { } HStack { - // Play button + + // MARK: Play Button { - () -// self.playbackInfo.itemToPlay = item -// self.playbackInfo.shouldShowPlayer = true + self.videoPlayerItem.itemToPlay = item + self.videoPlayerItem.shouldShowPlayer = true } label: { HStack { - Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) + Image(systemName: "play.fill") + .foregroundColor(Color.white) + .font(.system(size: 20)) Text(item.getItemProgressString() == "" ? "Play" : item.getItemProgressString()) - .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) + .foregroundColor(Color.white) + .font(.callout) + .fontWeight(.semibold) } .frame(width: 130, height: 40) .background(Color.jellyfinPurple) @@ -82,48 +88,37 @@ struct PortraitHeaderOverlayView: View { Spacer() + // MARK: Favorite Button { - print("Heart") + viewModel.updateFavoriteState() } label: { - Image(systemName: "heart").foregroundColor(.primary) - .font(.system(size: 20)) + if viewModel.isFavorited { + Image(systemName: "heart.fill") + .foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) + } else { + Image(systemName: "heart") + .foregroundColor(Color.primary) + .font(.system(size: 20)) + } } + .disabled(viewModel.isLoading) + // MARK: Watched Button { - print("Check") + viewModel.updateWatchState() } label: { - Image(systemName: "checkmark.circle").foregroundColor(.primary) - .font(.system(size: 20)) + if viewModel.isWatched { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color.jellyfinPurple) + .font(.system(size: 20)) + } else { + Image(systemName: "checkmark.circle") + .foregroundColor(Color.primary) + .font(.system(size: 20)) + } } - - -// Button { -// updateFavoriteState() -// () -// } label: { -// if viewModel.isFavorited { -// Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) -// .font(.system(size: 20)) -// } else { -// Image(systemName: "heart").foregroundColor(Color.primary) -// .font(.system(size: 20)) -// } -// } -// .disabled(viewModel.isLoading) -// Button { -// viewModel.updateWatchState() -// () -// } label: { -// if viewModel.isWatched { -// Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) -// .font(.system(size: 20)) -// } else { -// Image(systemName: "checkmark.circle").foregroundColor(Color.primary) -// .font(.system(size: 20)) -// } -// } -// .disabled(viewModel.isLoading) -// } + .disabled(viewModel.isLoading) }.padding(.top, 8) } .padding(.horizontal, 16) diff --git a/JellyfinPlayer/ItemView/ItemView.swift b/JellyfinPlayer/ItemView/ItemView.swift new file mode 100644 index 00000000..877f6d06 --- /dev/null +++ b/JellyfinPlayer/ItemView/ItemView.swift @@ -0,0 +1,50 @@ +/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI +import Introspect +import JellyfinAPI + +class VideoPlayerItem: ObservableObject { + @Published var shouldShowPlayer: Bool = false + @Published var itemToPlay: BaseItemDto = BaseItemDto() +} + +struct ItemView: View { + + @State private var videoIsLoading: Bool = false; // This variable is only changed by the underlying VLC view. + @State private var viewDidLoad: Bool = false + @State private var orientation: UIDeviceOrientation = .unknown + @StateObject private var videoPlayerItem: VideoPlayerItem = VideoPlayerItem() + @Environment(\.horizontalSizeClass) var hSizeClass + @Environment(\.verticalSizeClass) var vSizeClass + + private let item: BaseItemDto + + init(item: BaseItemDto) { + self.item = item + } + + var body: some View { + if hSizeClass == .compact && vSizeClass == .regular { + ItemPortraitBodyView(item: item, + videoIsLoading: $videoIsLoading, + portraitHeaderView: { item in + ImageView(src: item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), + bh: item.getBackdropImageBlurHash()) + .opacity(0.4) + .blur(radius: 2.0) + }, + portraitStaticOverlayView: { item in + PortraitHeaderOverlayView(item: item) + .environmentObject(DetailItemViewModel(item: item)) + }).environmentObject(videoPlayerItem) + } else { + Text("Hello there") + } + } +} From b349258086fd7495bcb4897857b3b7e35c39af18 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 30 Aug 2021 11:09:26 -0600 Subject: [PATCH 4/9] Some work --- JellyfinPlayer.xcodeproj/project.pbxproj | 2 +- .../ItemView/ItemPortraitBodyView.swift | 28 ++--- .../ItemPortraitHeaderOverlayView.swift | 118 +++++++++--------- JellyfinPlayer/ItemView/ItemView.swift | 23 ++-- Shared/ViewModels/DetailItemViewModel.swift | 8 +- 5 files changed, 92 insertions(+), 87 deletions(-) diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 348ea06d..a9f66ee5 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -993,8 +993,8 @@ isa = PBXGroup; children = ( 535BAE9E2649E569005FA86D /* ItemView.swift */, - E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */, E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */, + E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */, E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */, ); path = ItemView; diff --git a/JellyfinPlayer/ItemView/ItemPortraitBodyView.swift b/JellyfinPlayer/ItemView/ItemPortraitBodyView.swift index 619f8b88..8d9f822b 100644 --- a/JellyfinPlayer/ItemView/ItemPortraitBodyView.swift +++ b/JellyfinPlayer/ItemView/ItemPortraitBodyView.swift @@ -12,16 +12,16 @@ import JellyfinAPI struct ItemPortraitBodyView: View { - @Binding var videoIsLoading: Bool - @EnvironmentObject var viewModel: MovieItemViewModel - @EnvironmentObject var videoPlayerItem: VideoPlayerItem + @Binding private var videoIsLoading: Bool + @EnvironmentObject private var viewModel: DetailItemViewModel + @EnvironmentObject private var videoPlayerItem: VideoPlayerItem - private let item: BaseItemDto - private let portraitHeaderView: (BaseItemDto) -> PortraitHeaderView - private let portraitStaticOverlayView: (BaseItemDto) -> PortraitStaticOverlayView + private let portraitHeaderView: (DetailItemViewModel) -> PortraitHeaderView + private let portraitStaticOverlayView: (DetailItemViewModel) -> PortraitStaticOverlayView - init(item: BaseItemDto, videoIsLoading: Binding, portraitHeaderView: @escaping (BaseItemDto) -> PortraitHeaderView, portraitStaticOverlayView: @escaping (BaseItemDto) -> PortraitStaticOverlayView) { - self.item = item + init(videoIsLoading: Binding, + portraitHeaderView: @escaping (DetailItemViewModel) -> PortraitHeaderView, + portraitStaticOverlayView: @escaping (DetailItemViewModel) -> PortraitStaticOverlayView) { self._videoIsLoading = videoIsLoading self.portraitHeaderView = portraitHeaderView self.portraitStaticOverlayView = portraitStaticOverlayView @@ -43,8 +43,8 @@ struct ItemPortraitBodyView Date: Tue, 31 Aug 2021 23:12:09 -0600 Subject: [PATCH 5/9] Reduce item view complexity --- .../Components/MediaPlayButtonRowView.swift | 2 +- JellyfinPlayer.xcodeproj/project.pbxproj | 90 ++-- .../Components/CardVStackView.swift | 105 +++++ .../Components/PortraitHStackView.swift | 14 +- .../Components/PortraitItemView.swift | 2 +- JellyfinPlayer/ContinueWatchingView.swift | 4 +- JellyfinPlayer/EpisodeItemView.swift | 413 ----------------- JellyfinPlayer/HomeView.swift | 8 + .../ItemView/ItemLandscapeBodyView.swift | 16 - .../ItemView/ItemPortraitBodyView.swift | 103 ----- JellyfinPlayer/ItemView/ItemView.swift | 68 ++- JellyfinPlayer/ItemView/ItemViewBody.swift | 88 ++++ .../Landscape/ItemLandscapeMainView.swift | 114 +++++ .../Landscape/ItemLandscapeTopBarView.swift | 93 ++++ .../ItemPortraitHeaderOverlayView.swift | 78 ++-- .../Portrait/ItemPortraitMainView.swift | 76 ++++ JellyfinPlayer/JellyfinPlayerApp.swift | 3 +- JellyfinPlayer/MovieItemView.swift | 430 ------------------ JellyfinPlayer/SeasonItemView.swift | 257 ----------- JellyfinPlayer/SeriesItemView.swift | 243 ---------- JellyfinPlayer/SettingsView.swift | 3 +- Shared/Extensions/DefaultsExtension.swift | 2 +- .../BaseItemDto+Stackable.swift | 44 ++ .../BaseItemDtoExtensions.swift | 13 +- .../BaseItemPersonExtensions.swift | 52 ++- Shared/ViewModels/EpisodeItemViewModel.swift | 11 +- ...temViewModel.swift => ItemViewModel.swift} | 31 +- Shared/ViewModels/MovieItemViewModel.swift | 2 +- Shared/ViewModels/SeasonItemViewModel.swift | 38 +- Shared/ViewModels/SeriesItemViewModel.swift | 27 +- Shared/ViewModels/SettingsViewModel.swift | 3 +- Shared/{Objects => }/Views/ImageView.swift | 22 +- Shared/{Objects => }/Views/LazyView.swift | 0 .../Views/MultiSelectorView.swift | 0 .../{Objects => }/Views/ParallaxHeader.swift | 0 .../{Objects => }/Views/SearchBarView.swift | 0 .../Views/SearchablePickerView.swift | 0 37 files changed, 860 insertions(+), 1595 deletions(-) create mode 100644 JellyfinPlayer/Components/CardVStackView.swift delete mode 100644 JellyfinPlayer/EpisodeItemView.swift delete mode 100644 JellyfinPlayer/ItemView/ItemLandscapeBodyView.swift delete mode 100644 JellyfinPlayer/ItemView/ItemPortraitBodyView.swift create mode 100644 JellyfinPlayer/ItemView/ItemViewBody.swift create mode 100644 JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift create mode 100644 JellyfinPlayer/ItemView/Landscape/ItemLandscapeTopBarView.swift rename JellyfinPlayer/ItemView/{ => Portrait}/ItemPortraitHeaderOverlayView.swift (65%) create mode 100644 JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift delete mode 100644 JellyfinPlayer/MovieItemView.swift delete mode 100644 JellyfinPlayer/SeasonItemView.swift delete mode 100644 JellyfinPlayer/SeriesItemView.swift create mode 100644 Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift rename Shared/ViewModels/{DetailItemViewModel.swift => ItemViewModel.swift} (85%) rename Shared/{Objects => }/Views/ImageView.swift (58%) rename Shared/{Objects => }/Views/LazyView.swift (100%) rename Shared/{Objects => }/Views/MultiSelectorView.swift (100%) rename Shared/{Objects => }/Views/ParallaxHeader.swift (100%) rename Shared/{Objects => }/Views/SearchBarView.swift (100%) rename Shared/{Objects => }/Views/SearchablePickerView.swift (100%) diff --git a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift index 6613fec0..4b6987a0 100644 --- a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift +++ b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift @@ -11,7 +11,7 @@ import SwiftUI struct MediaPlayButtonRowView: View { - @ObservedObject var viewModel: DetailItemViewModel + @ObservedObject var viewModel: ItemViewModel @State var wrappedScrollView: UIScrollView? var body: some View { diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index a9f66ee5..64d95b43 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -141,7 +141,6 @@ 5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624C21742685CF60007F1390 /* SearchablePickerView.swift */; }; 5398514726B64E4100101B49 /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; }; 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */; }; - 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A089CF264DA9DA00D57806 /* MovieItemView.swift */; }; 53A431BD266B0FF20016769F /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 53A431BC266B0FF20016769F /* JellyfinAPI */; }; 53A431BF266B0FFE0016769F /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 53A431BE266B0FFE0016769F /* JellyfinAPI */; }; 53A83C33268A309300DF3D92 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A83C32268A309300DF3D92 /* LibraryView.swift */; }; @@ -156,11 +155,8 @@ 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ABFDEA2679753200886593 /* ConnectToServerView.swift */; }; 53ABFDED26799D7700886593 /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 53ABFDEC26799D7700886593 /* ActivityIndicator */; }; 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; - 53AD124D267029D60094A276 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53987CA526572F0700E7EA70 /* SeriesItemView.swift */; }; - 53AD124E26702B8A0094A276 /* SeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53987CA326572C1300E7EA70 /* SeasonItemView.swift */; }; 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CD2A3F268A49C2002ABD4E /* ItemView.swift */; }; 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CD2A41268A4B38002ABD4E /* MovieItemView.swift */; }; - 53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */; }; 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; }; 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; }; 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; }; @@ -224,8 +220,8 @@ 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */; }; 62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */; }; 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */; }; - 62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* DetailItemViewModel.swift */; }; - 62E632F4267D54030063E547 /* DetailItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* DetailItemViewModel.swift */; }; + 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; + 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; 62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352B26766675000E9F2D /* ServerEnvironment.swift */; }; 62EC352D26766675000E9F2D /* ServerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352B26766675000E9F2D /* ServerEnvironment.swift */; }; 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352E267666A5000E9F2D /* SessionManager.swift */; }; @@ -238,11 +234,17 @@ E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; E131691926C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; - E14F7D0726DB36EF007C3AE6 /* ItemPortraitBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */; }; - E14F7D0926DB36F7007C3AE6 /* ItemLandscapeBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */; }; + E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */; }; + E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */; }; E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; + E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; }; + E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; }; + E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */; }; + E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */; }; + E188460426DEF04800B0C5B7 /* CardVStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E188460326DEF04800B0C5B7 /* CardVStackView.swift */; }; + E188460526DEF04800B0C5B7 /* CardVStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E188460326DEF04800B0C5B7 /* CardVStackView.swift */; }; E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104926D94822003E4A08 /* DetailItem.swift */; }; E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104926D94822003E4A08 /* DetailItem.swift */; }; E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; @@ -393,11 +395,7 @@ 53913BEB26D323FE00EB3286 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = Localizable.strings; sourceTree = ""; }; 53913BEE26D323FE00EB3286 /* kk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kk; path = Localizable.strings; sourceTree = ""; }; 5398514426B64DA100101B49 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 53987CA326572C1300E7EA70 /* SeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemView.swift; sourceTree = ""; }; - 53987CA526572F0700E7EA70 /* SeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = ""; }; - 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = ""; }; 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 53A089CF264DA9DA00D57806 /* MovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemView.swift; sourceTree = ""; }; 53A83C32268A309300DF3D92 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 53ABFDDA267972BF00886593 /* JellyfinPlayer tvOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "JellyfinPlayer tvOS.entitlements"; sourceTree = ""; }; 53ABFDDB267972BF00886593 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.0.sdk/System/Library/Frameworks/TVServices.framework; sourceTree = DEVELOPER_DIR; }; @@ -448,7 +446,7 @@ 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemViewModel.swift; sourceTree = ""; }; 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemViewModel.swift; sourceTree = ""; }; 62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterViewModel.swift; sourceTree = ""; }; - 62E632F2267D54030063E547 /* DetailItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItemViewModel.swift; sourceTree = ""; }; + 62E632F2267D54030063E547 /* ItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewModel.swift; sourceTree = ""; }; 62EC352B26766675000E9F2D /* ServerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEnvironment.swift; sourceTree = ""; }; 62EC352E267666A5000E9F2D /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; @@ -458,11 +456,15 @@ DE5004F745B19E28744A7DE7 /* Pods-JellyfinPlayer tvOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.debug.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.debug.xcconfig"; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; E131691626C583BC0074BFEE /* LogConstructor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogConstructor.swift; sourceTree = ""; }; - E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitBodyView.swift; sourceTree = ""; }; - E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeBodyView.swift; sourceTree = ""; }; + E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitMainView.swift; sourceTree = ""; }; + E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeMainView.swift; sourceTree = ""; }; E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; + E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Stackable.swift"; sourceTree = ""; }; + E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewBody.swift; sourceTree = ""; }; + E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = ""; }; + E188460326DEF04800B0C5B7 /* CardVStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVStackView.swift; sourceTree = ""; }; E1AD104926D94822003E4A08 /* DetailItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItem.swift; sourceTree = ""; }; E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = ""; }; E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHStackView.swift; sourceTree = ""; }; @@ -557,7 +559,7 @@ isa = PBXGroup; children = ( 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, - 62E632F2267D54030063E547 /* DetailItemViewModel.swift */, + 62E632F2267D54030063E547 /* ItemViewModel.swift */, 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */, @@ -668,12 +670,13 @@ isa = PBXGroup; children = ( E1FCD08E26C466F3007C8DCF /* Errors */, + 621338912660106C00A81A2A /* Extensions */, + 535870AB2669D8D300D05A09 /* Objects */, + AE8C3157265D6F5E008AA076 /* Resources */, 091B5A852683142E00D78B61 /* ServerLocator */, 62EC352A26766657000E9F2D /* Singleton */, 532175392671BCED005491E6 /* ViewModels */, - 621338912660106C00A81A2A /* Extensions */, - AE8C3157265D6F5E008AA076 /* Resources */, - 535870AB2669D8D300D05A09 /* Objects */, + E1AD105326D96F5A003E4A08 /* Views */, ); path = Shared; sourceTree = ""; @@ -681,7 +684,6 @@ 535870AB2669D8D300D05A09 /* Objects */ = { isa = PBXGroup; children = ( - E1AD105326D96F5A003E4A08 /* Views */, 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */, 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, 535870AC2669D8DD00D05A09 /* Typings.swift */, @@ -736,7 +738,6 @@ 5377CBF8263B596B003A4E83 /* Assets.xcassets */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, 5389276D263C25100035E14B /* ContinueWatchingView.swift */, - 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */, 5377CC02263B596B003A4E83 /* Info.plist */, E14F7D0A26DB3714007C3AE6 /* ItemView */, 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */, @@ -746,12 +747,9 @@ 53EE24E5265060780068F029 /* LibrarySearchView.swift */, 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, 53892771263C8C6F0035E14B /* LoadingView.swift */, - 53A089CF264DA9DA00D57806 /* MovieItemView.swift */, 5389276F263C25230035E14B /* NextUpView.swift */, 5377CBFD263B596B003A4E83 /* PersistenceController.swift */, 5377CBFA263B596B003A4E83 /* Preview Content */, - 53987CA326572C1300E7EA70 /* SeasonItemView.swift */, - 53987CA526572F0700E7EA70 /* SeriesItemView.swift */, 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, 535BAEA4264A151C005FA86D /* VideoPlayer.swift */, @@ -928,6 +926,7 @@ E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */, E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */, 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */, + E188460326DEF04800B0C5B7 /* CardVStackView.swift */, ); path = Components; sourceTree = ""; @@ -993,18 +992,37 @@ isa = PBXGroup; children = ( 535BAE9E2649E569005FA86D /* ItemView.swift */, - E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */, - E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */, - E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */, + E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */, + E18845FB26DEACC400B0C5B7 /* Landscape */, + E18845FA26DEACBE00B0C5B7 /* Portrait */, ); path = ItemView; sourceTree = ""; }; + E18845FA26DEACBE00B0C5B7 /* Portrait */ = { + isa = PBXGroup; + children = ( + E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */, + E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */, + ); + path = Portrait; + sourceTree = ""; + }; + E18845FB26DEACC400B0C5B7 /* Landscape */ = { + isa = PBXGroup; + children = ( + E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */, + E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */, + ); + path = Landscape; + sourceTree = ""; + }; E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */ = { isa = PBXGroup; children = ( 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */, E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */, + E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */, E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */, ); path = JellyfinAPIExtensions; @@ -1381,6 +1399,7 @@ buildActionMask = 2147483647; files = ( 531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */, + E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, 6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */, 531069592684E7EE00CFFDBA /* SubtitlesView.swift in Sources */, 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */, @@ -1412,7 +1431,7 @@ 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, 5310695A2684E7EE00CFFDBA /* VideoPlayer.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, - 62E632F4267D54030063E547 /* DetailItemViewModel.swift in Sources */, + 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1AD106326D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, @@ -1452,6 +1471,7 @@ 53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, E1AD105D26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, + E188460526DEF04800B0C5B7 /* CardVStackView.swift in Sources */, 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */, E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */, E1AD105726D981CE003E4A08 /* PortraitHStackView.swift in Sources */, @@ -1465,6 +1485,7 @@ buildActionMask = 2147483647; files = ( 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, + E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, 621338932660107500A81A2A /* StringExtensions.swift in Sources */, 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, @@ -1474,15 +1495,15 @@ 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, 53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */, E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, - 53AD124E26702B8A0094A276 /* SeasonItemView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, 62CB3F4B2685BB77003D0A6F /* DefaultsExtension.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, + E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, - 53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */, + E188460426DEF04800B0C5B7 /* CardVStackView.swift in Sources */, 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */, @@ -1498,20 +1519,20 @@ E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, 532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */, - E14F7D0926DB36F7007C3AE6 /* ItemLandscapeBodyView.swift in Sources */, + E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, 5377CC01263B596B003A4E83 /* Model.xcdatamodeld in Sources */, 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, + E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */, 091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */, 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, - 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */, 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */, - 62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */, + 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, - E14F7D0726DB36EF007C3AE6 /* ItemPortraitBodyView.swift in Sources */, + E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */, E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, @@ -1535,7 +1556,6 @@ 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, - 53AD124D267029D60094A276 /* SeriesItemView.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, diff --git a/JellyfinPlayer/Components/CardVStackView.swift b/JellyfinPlayer/Components/CardVStackView.swift new file mode 100644 index 00000000..84583dd9 --- /dev/null +++ b/JellyfinPlayer/Components/CardVStackView.swift @@ -0,0 +1,105 @@ +// + /* + * 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 CardVStackView: View { + + let items: [BaseItemDto] + + private func buildCardOverlayView(item: BaseItemDto) -> some View { + HStack { + ZStack { + if item.userData?.isFavorite ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + .opacity(0.6) + Image(systemName: "heart.fill") + .foregroundColor(Color(.systemRed)) + .font(.system(size: 10)) + } + } + .padding(.leading, 2) + .padding(.bottom, item.userData?.playedPercentage == nil ? 2 : 9) + .opacity(1) + + ZStack { + if item.userData?.played ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.jellyfinPurple) + } + }.padding(2) + .opacity(1) + } + } + + var body: some View { + VStack { + ForEach(items, id: \.id) { item in + NavigationLink(destination: ItemNavigationView(item: item)) { + + HStack { + + // MARK: Image + ImageView(src: item.getPrimaryImage(maxWidth: 150), + bh: item.getPrimaryImageBlurHash(), + failureInitials: item.failureInitials) + .frame(width: 150, height: 100) + .cornerRadius(10) + .overlay( + Rectangle() + .fill(Color.jellyfinPurple) + .mask(ProgressBar()) + .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 1.5), height: 7) + .padding(0), alignment: .bottomLeading + ) + .overlay(buildCardOverlayView(item: item), alignment: .topTrailing) + + VStack(alignment: .leading) { + + // MARK: Title + Text(item.title) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + .lineLimit(2) + + HStack { + Text(item.getEpisodeLocator() ?? "") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + + Text(item.getItemRuntime()) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + + Spacer() + } + + // MARK: Overview + Text(item.overview ?? "") + .font(.footnote) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(4) + + Spacer() + } + } + .padding(.horizontal, 16) + } + } + } + } +} diff --git a/JellyfinPlayer/Components/PortraitHStackView.swift b/JellyfinPlayer/Components/PortraitHStackView.swift index b6668419..96e32645 100644 --- a/JellyfinPlayer/Components/PortraitHStackView.swift +++ b/JellyfinPlayer/Components/PortraitHStackView.swift @@ -14,6 +14,7 @@ public protocol PortraitImageStackable { var title: String { get } var description: String? { get } var blurHash: String { get } + var failureInitials: String { get } } struct PortraitImageHStackView: View { @@ -39,8 +40,10 @@ struct PortraitImageHStackView: View { - - @Binding private var videoIsLoading: Bool - @EnvironmentObject private var viewModel: DetailItemViewModel - @EnvironmentObject private var videoPlayerItem: VideoPlayerItem - - private let portraitHeaderView: (DetailItemViewModel) -> PortraitHeaderView - private let portraitStaticOverlayView: (DetailItemViewModel) -> PortraitStaticOverlayView - - init(videoIsLoading: Binding, - portraitHeaderView: @escaping (DetailItemViewModel) -> PortraitHeaderView, - portraitStaticOverlayView: @escaping (DetailItemViewModel) -> PortraitStaticOverlayView) { - self._videoIsLoading = videoIsLoading - self.portraitHeaderView = portraitHeaderView - self.portraitStaticOverlayView = portraitStaticOverlayView - } - - var body: some View { - VStack(alignment: .leading) { - NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) { - VLCPlayerWithControls(item: videoPlayerItem.itemToPlay, - loadBinding: $videoIsLoading, - pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer) - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) - .statusBar(hidden: true) - .edgesIgnoringSafeArea(.all) - .prefersHomeIndicatorAutoHidden(true) - }, isActive: $videoPlayerItem.shouldShowPlayer) { - EmptyView() - } - - // MARK: Body - ParallaxHeaderScrollView(header: portraitHeaderView(viewModel), - staticOverlayView: portraitStaticOverlayView(viewModel), - overlayAlignment: .bottomLeading, - headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) { - VStack { - Spacer() - .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24) - - // MARK: Overview - Text(viewModel.item.overview ?? "") - .font(.footnote) - .padding(.top, 3) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 3) - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - - // MARK: Genres - PillHStackView(title: "Genres", - items: viewModel.item.genreItems ?? []) { genre in - LibraryView(viewModel: .init(genre: genre), title: genre.title) - } - - // MARK: Studios - if let studios = viewModel.item.studios { - PillHStackView(title: "Studios", - items: studios) { studio in - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - } - } - - // MARK: Cast - PortraitImageHStackView(items: viewModel.item.people?.filter({ $0.type == "Actor" }) ?? [], - maxWidth: 150) { - Text("Cast") - .font(.callout) - .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) - } navigationView: { person in - LibraryView(viewModel: .init(person: person), title: person.title) - } - - // MARK: More Like This - - - Spacer(minLength: 10) - } -// .introspectTabBarController { (UITabBarController) in -// UITabBarController.tabBar.isHidden = false -// } -// .navigationBarBackButtonHidden(false) - .environmentObject(videoPlayerItem) - } - } - } -} diff --git a/JellyfinPlayer/ItemView/ItemView.swift b/JellyfinPlayer/ItemView/ItemView.swift index 1366f6a1..7e14fb9f 100644 --- a/JellyfinPlayer/ItemView/ItemView.swift +++ b/JellyfinPlayer/ItemView/ItemView.swift @@ -14,38 +14,72 @@ class VideoPlayerItem: ObservableObject { @Published var itemToPlay: BaseItemDto = BaseItemDto() } -struct ItemView: View { +// Intermediary view for ItemView to set navigation bar settings +struct ItemNavigationView: View { + + private let item: BaseItemDto + + init(item: BaseItemDto) { + self.item = item + } + + var body: some View { + ItemView(item: item) + .navigationBarTitle("", displayMode: .large) + } +} + +fileprivate struct ItemView: View { @State private var videoIsLoading: Bool = false; // This variable is only changed by the underlying VLC view. @State private var viewDidLoad: Bool = false @State private var orientation: UIDeviceOrientation = .unknown @StateObject private var videoPlayerItem: VideoPlayerItem = VideoPlayerItem() - @Environment(\.horizontalSizeClass) var hSizeClass - @Environment(\.verticalSizeClass) var vSizeClass + @Environment(\.horizontalSizeClass) private var hSizeClass + @Environment(\.verticalSizeClass) private var vSizeClass - private let viewModel: DetailItemViewModel + private let viewModel: ItemViewModel init(item: BaseItemDto) { - self.viewModel = DetailItemViewModel(item: item) + switch item.itemType { + case .movie: + self.viewModel = MovieItemViewModel(item: item) + case .season: + self.viewModel = SeasonItemViewModel(item: item) + case .episode: + self.viewModel = EpisodeItemViewModel(item: item) + case .series: + self.viewModel = SeriesItemViewModel(item: item) + default: + self.viewModel = ItemViewModel(item: item) + } } var body: some View { if hSizeClass == .compact && vSizeClass == .regular { - ItemPortraitBodyView(videoIsLoading: $videoIsLoading, - portraitHeaderView: { viewModel in - ImageView(src: viewModel.item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - }, - portraitStaticOverlayView: { viewModel in - PortraitHeaderOverlayView() - .environmentObject(viewModel) - }) + ItemPortraitMainView(videoIsLoading: $videoIsLoading) .environmentObject(videoPlayerItem) .environmentObject(viewModel) } else { - Text("Hello there") + ItemLandscapeMainView(videoIsLoading: $videoIsLoading) + .environmentObject(videoPlayerItem) + .environmentObject(viewModel) } } } + +extension UINavigationBar { + static func changeAppearance(clear: Bool) { + let appearance = UINavigationBarAppearance() + + if clear { + appearance.configureWithTransparentBackground() + } else { + appearance.configureWithDefaultBackground() + } + + UINavigationBar.appearance().standardAppearance = appearance + UINavigationBar.appearance().compactAppearance = appearance + UINavigationBar.appearance().scrollEdgeAppearance = appearance + } +} diff --git a/JellyfinPlayer/ItemView/ItemViewBody.swift b/JellyfinPlayer/ItemView/ItemViewBody.swift new file mode 100644 index 00000000..0050b01c --- /dev/null +++ b/JellyfinPlayer/ItemView/ItemViewBody.swift @@ -0,0 +1,88 @@ +// + /* + * 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 ItemViewBody: View { + + @EnvironmentObject private var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .leading) { + + // MARK: Overview + Text(viewModel.item.overview ?? "") + .font(.footnote) + .padding(.horizontal, 16) + .padding(.vertical, 3) + + // MARK: Seasons + if let seriesViewModel = viewModel as? SeriesItemViewModel { + PortraitImageHStackView(items: seriesViewModel.seasons, + maxWidth: 150, + topBarView: { + Text("Seasons") + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) + }, navigationView: { season in + ItemNavigationView(item: season) + }) + } + + // MARK: Genres + PillHStackView(title: "Genres", + items: viewModel.item.genreItems ?? []) { genre in + LibraryView(viewModel: .init(genre: genre), title: genre.title) + } + + // MARK: Studios + if let studios = viewModel.item.studios { + PillHStackView(title: "Studios", + items: studios) { studio in + LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") + } + } + + // MARK: Cast & Crew + if let castAndCrew = viewModel.item.people { + PortraitImageHStackView(items: castAndCrew.filter({ BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }), + maxWidth: 150, + topBarView: { + Text("Cast & Crew") + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) + }, + navigationView: { person in + LibraryView(viewModel: .init(person: person), title: person.title) + }) + } + + // MARK: More Like This + if !viewModel.similarItems.isEmpty { + PortraitImageHStackView(items: viewModel.similarItems, + maxWidth: 150, + topBarView: { + Text("More Like This") + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) + }, + navigationView: { item in + ItemNavigationView(item: item) + }) + } + } + } +} diff --git a/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift new file mode 100644 index 00000000..4ebb9061 --- /dev/null +++ b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift @@ -0,0 +1,114 @@ +// + /* + * 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 ItemLandscapeMainView: View { + + @Binding private var videoIsLoading: Bool + @EnvironmentObject private var viewModel: ItemViewModel + @EnvironmentObject private var videoPlayerItem: VideoPlayerItem + + init(videoIsLoading: Binding) { + self._videoIsLoading = videoIsLoading + } + + // MARK: innerBody + private var innerBody: some View { + HStack { + + // MARK: Sidebar Image + VStack { + ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 130), + bh: viewModel.item.getPrimaryImageBlurHash()) + .frame(width: 130, height: 195) + .cornerRadius(10) + + Spacer().frame(height: 15) + + Button { + if let playButtonItem = viewModel.playButtonItem { + self.videoPlayerItem.itemToPlay = playButtonItem + self.videoPlayerItem.shouldShowPlayer = true + } + } label: { + + // MARK: Play + HStack { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.system(size: 20)) + Text(viewModel.playButtonText()) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.callout) + .fontWeight(.semibold) + } + .frame(width: 130, height: 40) + .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) + .cornerRadius(10) + }.disabled(viewModel.playButtonItem == nil) + + Spacer() + } + + ScrollView { + VStack(alignment: .leading) { + + // MARK: ItemLandscapeTopBarView + ItemLandscapeTopBarView() + .environmentObject(viewModel) + + // MARK: ItemViewBody + if let episodeViewModel = viewModel as? SeasonItemViewModel { + CardVStackView(items: episodeViewModel.episodes) + } else { + ItemViewBody() + .environmentObject(viewModel) + } + } + } + } + } + + // MARK: body + var body: some View { + VStack { + NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) { + VLCPlayerWithControls(item: videoPlayerItem.itemToPlay, + loadBinding: $videoIsLoading, + pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .statusBar(hidden: true) + .edgesIgnoringSafeArea(.all) + .prefersHomeIndicatorAutoHidden(true) + }, isActive: $videoPlayerItem.shouldShowPlayer) { + EmptyView() + } + + ZStack { + + // MARK: Backdrop + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), + bh: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.3) + .edgesIgnoringSafeArea(.all) + .blur(radius: 4) + + // iPadOS is making the view go all the way to the edge. + // We have to accomodate this here + if UIDevice.current.userInterfaceIdiom == .pad { + innerBody.padding(.horizontal, 25) + } else { + innerBody + } + } + } + } +} diff --git a/JellyfinPlayer/ItemView/Landscape/ItemLandscapeTopBarView.swift b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeTopBarView.swift new file mode 100644 index 00000000..24a9e15a --- /dev/null +++ b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeTopBarView.swift @@ -0,0 +1,93 @@ +// + /* + * 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 ItemLandscapeTopBarView: View { + + @EnvironmentObject private var viewModel: ItemViewModel + + var body: some View { + HStack { + VStack(alignment: .leading) { + + // MARK: Name + + Text(viewModel.getItemDisplayName()) + .font(.title) + .fontWeight(.semibold) + .foregroundColor(.primary) + .padding(.leading, 16) + .padding(.bottom, 10) + + if viewModel.item.itemType.showDetails { + // MARK: Runtime + Text(viewModel.item.getItemRuntime()) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .padding(.leading, 16) + } + + // MARK: Details + HStack { + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear ?? 0)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + } + + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + + Spacer() + + if viewModel.item.itemType.showDetails { + // MARK: Favorite + Button { + viewModel.updateFavoriteState() + } label: { + if viewModel.isFavorited { + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) + } else { + Image(systemName: "heart").foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) + + // MARK: Watched + Button { + viewModel.updateWatchState() + } label: { + if viewModel.isWatched { + Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) + .font(.system(size: 20)) + } else { + Image(systemName: "checkmark.circle").foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) + } + } + .padding(.leading, 16) + } + } + } +} diff --git a/JellyfinPlayer/ItemView/ItemPortraitHeaderOverlayView.swift b/JellyfinPlayer/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift similarity index 65% rename from JellyfinPlayer/ItemView/ItemPortraitHeaderOverlayView.swift rename to JellyfinPlayer/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift index a0b30180..cb7c28a5 100644 --- a/JellyfinPlayer/ItemView/ItemPortraitHeaderOverlayView.swift +++ b/JellyfinPlayer/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift @@ -12,12 +12,14 @@ import JellyfinAPI struct PortraitHeaderOverlayView: View { - @EnvironmentObject private var viewModel: DetailItemViewModel + @EnvironmentObject private var viewModel: ItemViewModel @EnvironmentObject private var videoPlayerItem: VideoPlayerItem var body: some View { VStack(alignment: .leading) { HStack(alignment: .bottom, spacing: 12) { + + // MARK: Portrait Image ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 130)) .frame(width: 130, height: 195) .cornerRadius(10) @@ -25,20 +27,26 @@ struct PortraitHeaderOverlayView: View { VStack(alignment: .leading, spacing: 1) { Spacer() - Text(viewModel.item.name ?? "") - .font(.headline) + // MARK: Name + Text(viewModel.getItemDisplayName()) + .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) .fixedSize(horizontal: false, vertical: true) - .offset(y: 5) + .padding(.bottom, 10) - Text(viewModel.item.getItemRuntime()) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(.top, 10) + if viewModel.item.itemType.showDetails { + // MARK: Runtime + if viewModel.shouldDisplayRuntime() { + Text(viewModel.item.getItemRuntime()) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + // MARK: Details HStack { if let productionYear = viewModel.item.productionYear { Text(String(productionYear)) @@ -63,30 +71,32 @@ struct PortraitHeaderOverlayView: View { .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) } - if viewModel.item.itemType != .series { - HStack { - - // MARK: Play - Button { - self.videoPlayerItem.itemToPlay = viewModel.item + HStack { + + // MARK: Play + Button { + if let playButtonItem = viewModel.playButtonItem { + self.videoPlayerItem.itemToPlay = playButtonItem self.videoPlayerItem.shouldShowPlayer = true - } label: { - HStack { - Image(systemName: "play.fill") - .foregroundColor(Color.white) - .font(.system(size: 20)) - Text(viewModel.item.getItemProgressString() == "" ? "Play" : viewModel.item.getItemProgressString()) - .foregroundColor(Color.white) - .font(.callout) - .fontWeight(.semibold) - } - .frame(width: 130, height: 40) - .background(Color.jellyfinPurple) - .cornerRadius(10) } - - Spacer() - + } label: { + HStack { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.system(size: 20)) + Text(viewModel.playButtonText()) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.callout) + .fontWeight(.semibold) + } + .frame(width: 130, height: 40) + .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) + .cornerRadius(10) + }.disabled(viewModel.playButtonItem == nil) + + Spacer() + + if viewModel.item.itemType.showDetails { // MARK: Favorite Button { viewModel.updateFavoriteState() @@ -118,8 +128,8 @@ struct PortraitHeaderOverlayView: View { } } .disabled(viewModel.isLoading) - }.padding(.top, 8) - } + } + }.padding(.top, 8) } .padding(.horizontal, 16) .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) diff --git a/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift b/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift new file mode 100644 index 00000000..a6a79a28 --- /dev/null +++ b/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift @@ -0,0 +1,76 @@ +// + /* + * 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 ItemPortraitMainView: View { + + @Binding private var videoIsLoading: Bool + @EnvironmentObject private var viewModel: ItemViewModel + @EnvironmentObject private var videoPlayerItem: VideoPlayerItem + + init(videoIsLoading: Binding) { + self._videoIsLoading = videoIsLoading + } + + // MARK: portraitHeaderView + var portraitHeaderView: some View { + ImageView(src: viewModel.item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), + bh: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.4) + .blur(radius: 2.0) + } + + // MARK: portraitStaticOverlayView + var portraitStaticOverlayView: some View { + PortraitHeaderOverlayView() + .environmentObject(viewModel) + } + + // MARK: body + var body: some View { + VStack(alignment: .leading) { + NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) { + VLCPlayerWithControls(item: videoPlayerItem.itemToPlay, + loadBinding: $videoIsLoading, + pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .statusBar(hidden: true) + .edgesIgnoringSafeArea(.all) + .prefersHomeIndicatorAutoHidden(true) + }, isActive: $videoPlayerItem.shouldShowPlayer) { + EmptyView() + } + + // MARK: ParallaxScrollView + ParallaxHeaderScrollView(header: portraitHeaderView, + staticOverlayView: portraitStaticOverlayView, + overlayAlignment: .bottomLeading, + headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) { + + VStack { + Spacer() + .frame(height: 70) + + if let episodeViewModel = viewModel as? SeasonItemViewModel { + Spacer() + CardVStackView(items: episodeViewModel.episodes) + .padding(.top, 5) + .frame(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 400 : .infinity) + } else { + ItemViewBody() + .environmentObject(viewModel) + } + } + } + } + } +} diff --git a/JellyfinPlayer/JellyfinPlayerApp.swift b/JellyfinPlayer/JellyfinPlayerApp.swift index fd5bcf3e..3a08efc1 100644 --- a/JellyfinPlayer/JellyfinPlayerApp.swift +++ b/JellyfinPlayer/JellyfinPlayerApp.swift @@ -233,8 +233,7 @@ struct JellyfinPlayerApp: App { } private func setupAppearance() { - guard let storedAppearance = AppAppearance(rawValue: appAppearance) else { return } - UIApplication.shared.windows.first?.overrideUserInterfaceStyle = storedAppearance.style + UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style } } diff --git a/JellyfinPlayer/MovieItemView.swift b/JellyfinPlayer/MovieItemView.swift deleted file mode 100644 index 8dfad20d..00000000 --- a/JellyfinPlayer/MovieItemView.swift +++ /dev/null @@ -1,430 +0,0 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI - -struct MovieItemView: View { - @StateObject var viewModel: MovieItemViewModel - @State private var orientation = UIDeviceOrientation.unknown - @Environment(\.horizontalSizeClass) var hSizeClass - @Environment(\.verticalSizeClass) var vSizeClass - @EnvironmentObject - private var playbackInfo: VideoPlayerItem - - var portraitHeaderView: some View { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - } - - var portraitHeaderOverlayView: some View { - VStack(alignment: .leading) { - HStack(alignment: .bottom, spacing: 12) { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120)) - .frame(width: 120, height: 180) - .cornerRadius(10) - VStack(alignment: .leading) { - Spacer() - Text(viewModel.item.name ?? "").font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - .offset(y: 5) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - Text(viewModel.item.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } - .padding(.top, 1) - } - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) - } - HStack { - // Play button - Button { - self.playbackInfo.itemToPlay = viewModel.item - self.playbackInfo.shouldShowPlayer = true - } label: { - HStack { - Text(viewModel.item.getItemProgressString() == "" ? "Play" : viewModel.item.getItemProgressString()) - .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) - Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) - } - .frame(width: 120, height: 35) - .background(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) - .cornerRadius(10) - }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 35) - Spacer() - HStack { - Button { - viewModel.updateFavoriteState() - } label: { - if viewModel.isFavorited { - Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) - .font(.system(size: 20)) - } else { - Image(systemName: "heart").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - Button { - viewModel.updateWatchState() - } label: { - if viewModel.isWatched { - Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) - .font(.system(size: 20)) - } else { - Image(systemName: "checkmark.circle").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - } - }.padding(.top, 8) - } - .padding(.horizontal, 16) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) - } - - var body: some View { - VStack(alignment: .leading) { - if hSizeClass == .compact && vSizeClass == .regular { - ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitHeaderOverlayView, - overlayAlignment: .bottomLeading, - headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds - .width * 0.5625) { - VStack(alignment: .leading) { - Spacer() - .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24) - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 7) - .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) - .padding(.trailing, 16) - } - Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) - .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) - .padding(.trailing, 16) - if !(viewModel.item.genreItems ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.genreItems!, id: \.id) { genre in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(genre: genre), title: genre.name ?? "") - }) { - Text(genre.name ?? "").font(.footnote) - } - } - }.padding(.leading, 16).padding(.trailing, 16) - } - } - if !(viewModel.item.people ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - VStack { - Spacer().frame(height: 8) - HStack { - Spacer().frame(width: 16) - ForEach(viewModel.item.people!, id: \.self) { person in - if person.type ?? "" == "Actor" { - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(person: person), title: person.name ?? "") - }) { - VStack { - ImageView(src: person - .getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), - bh: person.getBlurHash()) - .frame(width: 100, height: 100) - .cornerRadius(10) - Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1) - .frame(width: 100).foregroundColor(Color.primary) - if person.role != nil { - Text(person.role!).font(.caption).fontWeight(.medium).lineLimit(1) - .foregroundColor(Color.secondary).frame(width: 100) - } - } - } - Spacer().frame(width: 10) - } - } - Spacer().frame(width: 16) - } - } - }.padding(.top, -3) - } - if !(viewModel.item.studios ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.studios!, id: \.id) { studio in - NavigationLink(destination: LazyView { - - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - }.padding(.leading, 16).padding(.trailing, 16) - } - } - if !(viewModel.similarItems).isEmpty { - Text("More Like This") - .font(.callout).fontWeight(.semibold).padding(.top, 3).padding(.leading, 16) - ScrollView(.horizontal, showsIndicators: false) { - VStack { - Spacer().frame(height: 8) - HStack { - Spacer().frame(width: 16) - ForEach(viewModel.similarItems, id: \.self) { similarItem in - NavigationLink(destination: LazyView { ItemView(item: similarItem) }) { - PortraitItemView(item: similarItem) - } - Spacer().frame(width: 10) - } - Spacer().frame(width: 16) - } - } - }.padding(.top, -5) - } - Spacer().frame(height: 16) - } - } - } else { - GeometryReader { geometry in - ZStack { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.3) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, - height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) - .edgesIgnoringSafeArea(.all) - .blur(radius: 4) - HStack { - VStack { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), - bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 120, height: 180) - .cornerRadius(10) - Spacer().frame(height: 15) - Button { - self.playbackInfo.itemToPlay = viewModel.item - self.playbackInfo.shouldShowPlayer = true - } label: { - HStack { - Text(viewModel.item.getItemProgressString() == "" ? "Play" : "\(viewModel.item.getItemProgressString()) left") - .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) - Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) - } - .frame(width: 120, height: 35) - .background(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) - .cornerRadius(10) - }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 35) - Spacer() - } - ScrollView { - VStack(alignment: .leading) { - HStack { - VStack(alignment: .leading) { - Text(viewModel.item.name ?? "").font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .offset(x: 14, y: 0) - Spacer().frame(height: 1) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - Text(viewModel.item.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - if viewModel.item.communityRating != nil { - HStack { - Image(systemName: "star").foregroundColor(.secondary) - Text(String(viewModel.item.communityRating!)).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .offset(x: -7, y: 0.7) - } - } - Spacer() - }.frame(maxWidth: .infinity, alignment: .leading) - .offset(x: 14) - .padding(.top, 1) - }.frame(maxWidth: .infinity, alignment: .leading) - Spacer() - HStack { - Button { - viewModel.updateFavoriteState() - } label: { - if viewModel.isFavorited { - Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) - .font(.system(size: 20)) - } else { - Image(systemName: "heart").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - Button { - viewModel.updateWatchState() - } label: { - if viewModel.isWatched { - Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) - .font(.system(size: 20)) - } else { - Image(systemName: "checkmark.circle").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - } - }.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 3) - .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) - .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if !(viewModel.item.genreItems ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.genreItems!, id: \.id) { genre in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(genre: genre), title: genre.name ?? "") - }) { - Text(genre.name ?? "").font(.footnote) - } - } - } - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - } - if !(viewModel.item.people ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - VStack { - Spacer().frame(height: 8) - HStack { - Spacer().frame(width: 16) - ForEach(viewModel.item.people!, id: \.self) { person in - if person.type! == "Actor" { - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(person: person), title: person.name ?? "") - }) { - VStack { - ImageView(src: person - .getImage(baseURL: ServerEnvironment.current.server.baseURI!, - maxWidth: 100), - bh: person.getBlurHash()) - .frame(width: 100, height: 100) - .cornerRadius(10) - Text(person.name ?? "").font(.footnote).fontWeight(.regular) - .lineLimit(1) - .frame(width: 100).foregroundColor(Color.primary) - if person.role != "" { - Text(person.role!).font(.caption).fontWeight(.medium).lineLimit(1) - .foregroundColor(Color.secondary).frame(width: 100) - } - } - } - Spacer().frame(width: 10) - } - } - Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - } - }.padding(.top, -3) - } - if !(viewModel.item.studios ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.studios!, id: \.id) { studio in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - } - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - } - if !(viewModel.similarItems).isEmpty { - Text("More Like This") - .font(.callout).fontWeight(.semibold).padding(.top, 3).padding(.leading, 16) - ScrollView(.horizontal, showsIndicators: false) { - VStack { - Spacer().frame(height: 8) - HStack { - Spacer().frame(width: 16) - ForEach(viewModel.similarItems, id: \.self) { similarItem in - NavigationLink(destination: LazyView { ItemView(item: similarItem) }) { - PortraitItemView(item: similarItem) - } - Spacer().frame(width: 10) - } - Spacer().frame(width: 16) - } - } - }.padding(.top, -5) - } - Spacer().frame(height: 105) - }.frame(maxHeight: .infinity) - } - }.padding(.top, 16).padding(.leading, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - .edgesIgnoringSafeArea(.leading) - } - } - } - } - .onRotate { - orientation = $0 - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle(viewModel.item.name ?? "") - } -} diff --git a/JellyfinPlayer/SeasonItemView.swift b/JellyfinPlayer/SeasonItemView.swift deleted file mode 100644 index 6d3758be..00000000 --- a/JellyfinPlayer/SeasonItemView.swift +++ /dev/null @@ -1,257 +0,0 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI - -struct SeasonItemView: View { - @StateObject var viewModel: SeasonItemViewModel - @State private var orientation = UIDeviceOrientation.unknown - @Environment(\.horizontalSizeClass) var hSizeClass - @Environment(\.verticalSizeClass) var vSizeClass - - @ViewBuilder - var portraitHeaderView: some View { - if viewModel.isLoading { - EmptyView() - } else { - ImageView(src: viewModel.item.getSeriesBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: viewModel.item.getSeriesBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - } - } - - var portraitHeaderOverlayView: some View { - HStack(alignment: .bottom, spacing: 12) { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 120, height: 180) - .cornerRadius(10) - VStack(alignment: .leading) { - Text(viewModel.item.name ?? "").font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .offset(y: -4) - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - }.offset(y: -32) - }.padding(.horizontal, 16) - .offset(y: 22) - } - - @ViewBuilder - var innerBody: some View { - if hSizeClass == .compact && vSizeClass == .regular { - ParallaxHeaderScrollView(header: portraitHeaderView, - staticOverlayView: portraitHeaderOverlayView, - overlayAlignment: .bottomLeading, - headerHeight: UIScreen.main.bounds.width * 0.5625) { - LazyVStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 7) - .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) - .padding(.trailing, 16) - } - Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) - .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) - .padding(.trailing, 16) - ForEach(viewModel.episodes, id: \.id) { episode in - NavigationLink(destination: ItemView(item: episode)) { - HStack { - ImageView(src: episode.getPrimaryImage(maxWidth: 150), bh: episode.getPrimaryImageBlurHash()) - .shadow(radius: 5) - .frame(width: 150, height: 90) - .cornerRadius(10) - .overlay( - Rectangle() - .fill(Color(red: 172/255, green: 92/255, blue: 195/255)) - .mask(ProgressBar()) - .frame(width: CGFloat(episode.userData?.playedPercentage ?? 0 * 1.5), height: 7) - .padding(0), alignment: .bottomLeading - ) - .overlay( - ZStack { - if episode.userData?.isFavorite ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - .opacity(0.6) - Image(systemName: "heart.fill") - .foregroundColor(Color(.systemRed)) - .font(.system(size: 10)) - } - } - .padding(.leading, 2) - .padding(.bottom, episode.userData?.playedPercentage == nil ? 2 : 9) - .opacity(1), alignment: .bottomLeading) - .overlay( - ZStack { - if episode.userData?.played ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color(.systemBlue)) - } - }.padding(2) - .opacity(1), alignment: .topTrailing).opacity(1) - VStack(alignment: .leading) { - HStack { - Text(episode.getEpisodeLocator()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - Spacer() - Text(episode.name ?? "").font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(1) - Spacer() - Text(episode.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - Spacer() - Text(episode.overview ?? "").font(.footnote).foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true).lineLimit(4) - Spacer() - }.padding(.trailing, 20).offset(y: 2) - }.offset(x: 12, y: 0) - } - } - if !(viewModel.item.studios ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.studios!, id: \.id) { studio in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - }.padding(.leading, 16).padding(.trailing, 16) - } - } - Spacer().frame(height: 10) - } - .padding(.leading, 2) - .padding(.top, 20) - } - } else { - GeometryReader { geometry in - ZStack { - ImageView(src: viewModel.item.getSeriesBackdropImage(maxWidth: 200), bh: viewModel.item.getSeriesBackdropImageBlurHash()) - .opacity(0.4) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, - height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) - .edgesIgnoringSafeArea(.all) - .blur(radius: 4) - HStack { - VStack(alignment: .leading) { - Spacer().frame(height: 16) - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 120, height: 180) - .cornerRadius(10) - Spacer().frame(height: 4) - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - } - Spacer() - } - ScrollView { - Spacer().frame(height: 16) - LazyVStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 7) - .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) - .padding(.trailing, 16) - } - Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) - .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) - .padding(.trailing, 16) - ForEach(viewModel.episodes, id: \.id) { episode in - NavigationLink(destination: ItemView(item: episode)) { - HStack { - ImageView(src: episode.getPrimaryImage(maxWidth: 150), bh: episode.getPrimaryImageBlurHash()) - .shadow(radius: 5) - .frame(width: 150, height: 90) - .cornerRadius(10) - .overlay( - Rectangle() - .fill(Color(red: 172/255, green: 92/255, blue: 195/255)) - .mask(ProgressBar()) - .frame(width: CGFloat(episode.userData!.playedPercentage ?? 0 * 1.5), height: 7) - .padding(0), alignment: .bottomLeading - ) - VStack(alignment: .leading) { - HStack { - Text("S\(String(episode.parentIndexNumber ?? 0)):E\(String(episode.indexNumber ?? 0))").font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - Spacer() - Text(episode.name ?? "").font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(1) - Spacer() - Text(episode.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - Spacer() - Text(episode.overview ?? "").font(.footnote).foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true).lineLimit(4) - Spacer() - }.padding(.trailing, 20).offset(y: 2) - }.offset(x: 12, y: 0) - } - } - if !(viewModel.item.studios ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.studios!, id: \.id) { studio in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - }.padding(.leading, 16).padding(.trailing, 16) - } - } - Spacer().frame(height: 95) - }.frame(maxHeight: .infinity) - }.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - }.padding(.leading, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 0) - } - } - } - } - - var body: some View { - if viewModel.isLoading { - ProgressView() - } else { - innerBody - .onRotate { - orientation = $0 - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("\(viewModel.item.name ?? "") - \(viewModel.item.seriesName ?? "")") - } - } -} diff --git a/JellyfinPlayer/SeriesItemView.swift b/JellyfinPlayer/SeriesItemView.swift deleted file mode 100644 index 325bd684..00000000 --- a/JellyfinPlayer/SeriesItemView.swift +++ /dev/null @@ -1,243 +0,0 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI - -struct SeriesItemView: View { - @StateObject var viewModel: SeriesItemViewModel - @State private var orientation = UIDeviceOrientation.unknown - @Environment(\.horizontalSizeClass) var hSizeClass - @Environment(\.verticalSizeClass) var vSizeClass - - @State private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - - @ViewBuilder - var portraitHeaderView: some View { - ImageView(src: viewModel.item - .getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - } - - var portraitHeaderOverlayView: some View { - HStack(alignment: .bottom, spacing: 12) { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 120, height: 180) - .cornerRadius(10) - VStack(alignment: .leading) { - Text(viewModel.item.name ?? "").font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .offset(y: -4) - HStack { - Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - if let officialRating = viewModel.item.officialRating { - Text(officialRating).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } - }.offset(y: -32) - }.padding(.horizontal, 16) - .offset(y: 22) - } - - func recalcTracks() { - tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - } - - var innerBody: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - if let firstTagline = viewModel.item.taglines?.first { - Text(firstTagline).font(.body).italic() - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 8) - .padding(.horizontal, 16) - } - if let genreItems = viewModel.item.genreItems, - !genreItems.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: 8) { - Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(genreItems, id: \.id) { genre in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(genre: genre), title: genre.name ?? "") - }) { - Text(genre.name ?? "").font(.footnote) - } - } - } - .padding(.horizontal, 16) - } - .padding(.bottom, 8) - } - Text(viewModel.item.overview ?? "") - .font(.footnote) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 16) - .padding(.horizontal, 16) - Text("Seasons") - .font(.callout).fontWeight(.semibold) - .padding(.horizontal, 16) - } - .padding(.top, 24) - LazyVGrid(columns: tracks) { - ForEach(viewModel.seasons, id: \.id) { season in - PortraitItemView(item: season) - } - } - .padding(.bottom, 16) - .padding(.horizontal, 8) - LazyVStack(alignment: .leading, spacing: 0) { - if let people = viewModel.item.people, - !people.isEmpty { - Text("CAST") - .font(.callout).fontWeight(.semibold) - .padding(.bottom, 8) - .padding(.horizontal, 16) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: 16) { - ForEach(people, id: \.self) { person in - if person.type == "Actor" { - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(person: person), title: person.name ?? "") - }) { - VStack { - ImageView(src: person - .getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), - bh: person.getBlurHash()) - .frame(width: 100, height: 100) - .cornerRadius(10) - Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1) - .frame(width: 100).foregroundColor(Color.primary) - if let role = person.role, - !role.isEmpty { - Text(role).font(.caption).fontWeight(.medium).lineLimit(1) - .foregroundColor(Color.secondary).frame(width: 100) - } - } - } - } - } - } - .padding(.horizontal, 16) - } - .padding(.bottom, 16) - } - if let studios = viewModel.item.studios, - !studios.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: 16) { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(studios, id: \.id) { studio in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - } - .padding(.horizontal, 16) - } - .padding(.bottom, 16) - } - if !viewModel.similarItems.isEmpty { - Text("More Like This") - .font(.callout).fontWeight(.semibold) - .padding(.bottom, 8) - .padding(.horizontal, 16) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: 16) { - ForEach(viewModel.similarItems, id: \.self) { similarItem in - NavigationLink(destination: LazyView { ItemView(item: similarItem) }) { - PortraitItemView(item: similarItem) - } - } - } - .padding(.horizontal, 16) - } - .padding(.bottom, 16) - } - } - } - } - - var landscapeView: some View { - GeometryReader { geometry in - ZStack { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, - height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) - .edgesIgnoringSafeArea(.all) - .blur(radius: 4) - HStack(alignment: .top, spacing: 16) { - VStack(alignment: .leading) { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), - bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 120, height: 180) - .cornerRadius(10) - HStack { - Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - if let officialRating = viewModel.item.officialRating { - Text(officialRating).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } - } - .padding([.top, .leading], 16) - innerBody - } - } - } - } - - var body: some View { - if viewModel.isLoading { - ProgressView() - } else { - Group { - if hSizeClass == .compact && vSizeClass == .regular { - ParallaxHeaderScrollView(header: portraitHeaderView, - staticOverlayView: portraitHeaderOverlayView, - overlayAlignment: .bottomLeading, - headerHeight: UIScreen.main.bounds.width * 0.5625) { - innerBody - } - } else { - landscapeView - } - } - .onRotate { - orientation = $0 - recalcTracks() - } - .overrideViewPreference(.unspecified) - .navigationTitle(viewModel.item.name ?? "") - .navigationBarTitleDisplayMode(.inline) - } - } -} diff --git a/JellyfinPlayer/SettingsView.swift b/JellyfinPlayer/SettingsView.swift index 3189790c..47c98512 100644 --- a/JellyfinPlayer/SettingsView.swift +++ b/JellyfinPlayer/SettingsView.swift @@ -107,8 +107,7 @@ struct SettingsView: View { Text(appearance.localizedName).tag(appearance.rawValue) } }.onChange(of: appAppearance, perform: { value in - guard let appearance = AppAppearance(rawValue: value) else { return } - UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appearance.style + UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style }) } } diff --git a/Shared/Extensions/DefaultsExtension.swift b/Shared/Extensions/DefaultsExtension.swift index a3c30e46..30cb48c9 100644 --- a/Shared/Extensions/DefaultsExtension.swift +++ b/Shared/Extensions/DefaultsExtension.swift @@ -16,7 +16,7 @@ extension Defaults.Keys { static let isAutoSelectSubtitles = Key("isAutoSelectSubtitles", default: false) static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", default: "Auto") static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto") - static let appAppearance = Key("appAppearance", default: AppAppearance.system.rawValue) + static let appAppearance = Key("appAppearance", default: .system) static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .thirty) static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .thirty) } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift new file mode 100644 index 00000000..6ca62ffd --- /dev/null +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift @@ -0,0 +1,44 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import JellyfinAPI + +// MARK: PortraitImageStackable +extension BaseItemDto: PortraitImageStackable { + public func imageURLContsructor(maxWidth: Int) -> URL { + return self.getPrimaryImage(maxWidth: maxWidth) + } + + public var title: String { + return self.name ?? "" + } + + public var description: String? { + switch self.itemType { + case .season: + guard let productionYear = productionYear else { return nil } + return "\(productionYear)" + case .episode: + return getEpisodeLocator() + default: + return nil + } + } + + public var blurHash: String { + return self.getPrimaryImageBlurHash() + } + + public var failureInitials: String { + guard let name = self.name else { return "" } + let initials = name.split(separator: " ").compactMap({ String($0).first }) + return String(initials) + } +} diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 44dd8ab0..83ea48eb 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -78,11 +78,11 @@ public extension BaseItemDto { return URL(string: urlString)! } - func getEpisodeLocator() -> String { + func getEpisodeLocator() -> String? { if let seasonNo = parentIndexNumber, let episodeNo = indexNumber { return "S\(seasonNo):E\(episodeNo)" } - return "" + return nil } func getSeriesBackdropImage(maxWidth: Int) -> URL { @@ -162,6 +162,15 @@ public extension BaseItemDto { case series = "Series" case unknown + + var showDetails: Bool { + switch self { + case .season, .series: + return false + default: + return true + } + } } var itemType: ItemType { diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift index ff1cf99d..a3a9c042 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift @@ -10,6 +10,8 @@ import JellyfinAPI import UIKit extension BaseItemPerson { + + // MARK: Get Image func getImage(baseURL: String, maxWidth: Int) -> URL { let imageType = "Primary" let imageTag = primaryImageTag ?? "" @@ -26,8 +28,33 @@ extension BaseItemPerson { return imageBlurHashes?.primary?[imgTag] ?? "001fC^" } + + + // MARK: First Role + + // Jellyfin will grab all roles the person played in the show which makes the role + // text too long. This will grab the first role which: + // - assumes that the most important role is the first + // - will also grab the last "()" instance, like "(voice)" + func firstRole() -> String? { + guard let role = self.role else { return nil } + let split = role.split(separator: "/") + guard split.count > 1 else { return role } + + guard let firstRole = split.first?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), let lastRole = split.last?.trimmingCharacters(in: CharacterSet(charactersIn: " ")) else { return role } + + var final = firstRole + + if let lastOpenIndex = lastRole.lastIndex(of: "("), let lastClosingIndex = lastRole.lastIndex(of: ")") { + let roleText = lastRole[lastOpenIndex...lastClosingIndex] + final.append(" \(roleText)") + } + + return final + } } +// MARK: PortraitImageStackable extension BaseItemPerson: PortraitImageStackable { public func imageURLContsructor(maxWidth: Int) -> URL { return self.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: maxWidth) @@ -38,10 +65,33 @@ extension BaseItemPerson: PortraitImageStackable { } public var description: String? { - return self.role + return self.firstRole() } public var blurHash: String { return self.getBlurHash() } + + public var failureInitials: String { + guard let name = self.name else { return "" } + let initials = name.split(separator: " ").compactMap({ String($0).first }) + return String(initials) + } +} + +// MARK: DiplayedType +extension BaseItemPerson { + + // Only displayed person types. + // Will ignore people like "GuestStar" + enum DisplayedType: String, CaseIterable { + case actor = "Actor" + case director = "Director" + case writer = "Writer" + case producer = "Producer" + + static var allCasesRaw: [String] { + return self.allCases.map({ $0.rawValue }) + } + } } diff --git a/Shared/ViewModels/EpisodeItemViewModel.swift b/Shared/ViewModels/EpisodeItemViewModel.swift index b43b99ef..86e3d8f8 100644 --- a/Shared/ViewModels/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/EpisodeItemViewModel.swift @@ -11,5 +11,14 @@ import Combine import Foundation import JellyfinAPI -final class EpisodeItemViewModel: DetailItemViewModel { +final class EpisodeItemViewModel: ItemViewModel { + + override func getItemDisplayName() -> String { + guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" } + return "\(episodeLocator)\n\(item.name ?? "")" + } + + override func shouldDisplayRuntime() -> Bool { + return false + } } diff --git a/Shared/ViewModels/DetailItemViewModel.swift b/Shared/ViewModels/ItemViewModel.swift similarity index 85% rename from Shared/ViewModels/DetailItemViewModel.swift rename to Shared/ViewModels/ItemViewModel.swift index d6a03f90..49ba6707 100644 --- a/Shared/ViewModels/DetailItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel.swift @@ -14,21 +14,40 @@ import JellyfinAPI class ItemViewModel: ViewModel { @Published var item: BaseItemDto + @Published var playButtonItem: BaseItemDto? @Published var similarItems: [BaseItemDto] = [] - @Published var isWatched = false @Published var isFavorited = false init(item: BaseItemDto) { self.item = item + + switch item.itemType { + case .episode, .movie: + self.playButtonItem = item + default: () + } + isFavorited = item.userData?.isFavorite ?? false isWatched = item.userData?.played ?? false super.init() - getRelatedItems() + getSimilarItems() } - - func getRelatedItems() { + + func playButtonText() -> String { + return item.getItemProgressString() == "" ? "Play" : item.getItemProgressString() + } + + func getItemDisplayName() -> String { + return item.name ?? "" + } + + func shouldDisplayRuntime() -> Bool { + return true + } + + func getSimilarItems() { LibraryAPI.getSimilarItems(itemId: item.id!, userId: SessionManager.current.user.user_id!, limit: 20, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in @@ -83,7 +102,3 @@ class ItemViewModel: ViewModel { } } } - -class DetailItemViewModel: ItemViewModel { - -} diff --git a/Shared/ViewModels/MovieItemViewModel.swift b/Shared/ViewModels/MovieItemViewModel.swift index d37a9281..31ad3862 100644 --- a/Shared/ViewModels/MovieItemViewModel.swift +++ b/Shared/ViewModels/MovieItemViewModel.swift @@ -11,5 +11,5 @@ import Combine import Foundation import JellyfinAPI -final class MovieItemViewModel: DetailItemViewModel { +final class MovieItemViewModel: ItemViewModel { } diff --git a/Shared/ViewModels/SeasonItemViewModel.swift b/Shared/ViewModels/SeasonItemViewModel.swift index 60a742a6..e5e73ec8 100644 --- a/Shared/ViewModels/SeasonItemViewModel.swift +++ b/Shared/ViewModels/SeasonItemViewModel.swift @@ -11,9 +11,9 @@ import Combine import Foundation import JellyfinAPI -final class SeasonItemViewModel: DetailItemViewModel { +final class SeasonItemViewModel: ItemViewModel { - @Published var episodes = [BaseItemDto]() + @Published private(set) var episodes: [BaseItemDto] = [] override init(item: BaseItemDto) { super.init(item: item) @@ -21,8 +21,14 @@ final class SeasonItemViewModel: DetailItemViewModel { requestEpisodes() } + + override func playButtonText() -> String { + guard let playButtonItem = playButtonItem else { return "Play" } + guard let episodeLocator = playButtonItem.getEpisodeLocator() else { return "Play" } + return episodeLocator + } - func requestEpisodes() { + private func requestEpisodes() { LogManager.shared.log.debug("Getting episodes in season \(self.item.id!) (\(self.item.name!)) of show \(self.item.seriesId!) (\(self.item.seriesName!))") TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], @@ -33,7 +39,33 @@ final class SeasonItemViewModel: DetailItemViewModel { }, receiveValue: { [weak self] response in self?.episodes = response.items ?? [] LogManager.shared.log.debug("Retrieved \(String(self?.episodes.count ?? 0)) episodes") + + self?.setNextUpInSeason() }) .store(in: &cancellables) } + + // Sets the play button item to the "Next up" in the season based upon + // the watched status of episodes in the season. + // Default to the first episode of the season if all have been watched. + private func setNextUpInSeason() { + guard episodes.count > 0 else { return } + + var firstUnwatchedSearch: BaseItemDto? + + for episode in episodes { + guard let played = episode.userData?.played else { continue } + if !played { + firstUnwatchedSearch = episode + break + } + } + + if let firstUnwatched = firstUnwatchedSearch { + playButtonItem = firstUnwatched + } else { + guard let firstEpisode = episodes.first else { return } + playButtonItem = firstEpisode + } + } } diff --git a/Shared/ViewModels/SeriesItemViewModel.swift b/Shared/ViewModels/SeriesItemViewModel.swift index 24b42084..81a9e8e1 100644 --- a/Shared/ViewModels/SeriesItemViewModel.swift +++ b/Shared/ViewModels/SeriesItemViewModel.swift @@ -11,32 +11,43 @@ import Combine import Foundation import JellyfinAPI -final class SeriesItemViewModel: DetailItemViewModel { +final class SeriesItemViewModel: ItemViewModel { - @Published var seasons = [BaseItemDto]() - @Published var nextUpItem: BaseItemDto? + @Published var seasons: [BaseItemDto] = [] override init(item: BaseItemDto) { super.init(item: item) - self.item = item requestSeasons() getNextUp() } + + override func playButtonText() -> String { + guard let playButtonItem = playButtonItem else { return "Play" } + guard let episodeLocator = playButtonItem.getEpisodeLocator() else { return "Play" } + return episodeLocator + } + + override func shouldDisplayRuntime() -> Bool { + return false + } - func getNextUp() { + private func getNextUp() { + LogManager.shared.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))") TvShowsAPI.getNextUp(userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seriesId: self.item.id!, enableUserData: true) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in - self?.nextUpItem = response.items?.first ?? nil + if let nextUpItem = response.items?.first { + self?.playButtonItem = nextUpItem + } }) .store(in: &cancellables) } - func getRunYears() -> String { + private func getRunYears() -> String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy" @@ -54,7 +65,7 @@ final class SeriesItemViewModel: DetailItemViewModel { return "\(startYear ?? "Unknown") - \(endYear ?? "Present")" } - func requestSeasons() { + private func requestSeasons() { LogManager.shared.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))") TvShowsAPI.getSeasons(seriesId: item.id ?? "", userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true) .trackActivity(loading) diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index bdb0755d..86f6a56a 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +import Defaults struct UserSettings: Decodable { var LocalMaxBitrate: Int @@ -31,7 +32,7 @@ struct TrackLanguage: Hashable { static let auto = TrackLanguage(name: "Auto", isoCode: "Auto") } -enum AppAppearance: String, CaseIterable { +enum AppAppearance: String, CaseIterable, Defaults.Serializable { case system case dark case light diff --git a/Shared/Objects/Views/ImageView.swift b/Shared/Views/ImageView.swift similarity index 58% rename from Shared/Objects/Views/ImageView.swift rename to Shared/Views/ImageView.swift index e1e45141..8f41eedf 100644 --- a/Shared/Objects/Views/ImageView.swift +++ b/Shared/Views/ImageView.swift @@ -11,16 +11,14 @@ import SwiftUI import NukeUI struct ImageView: View { - private var source: URL = URL(string: "https://example.com")! - private var blurhash: String = "001fC^" + private let source: URL + private let blurhash: String + private let failureInitials: String - init(src: URL) { - self.source = src - } - - init(src: URL, bh: String) { + init(src: URL, bh: String = "001fC^", failureInitials: String = "") { self.source = src self.blurhash = bh + self.failureInitials = failureInitials } var body: some View { @@ -30,8 +28,14 @@ struct ImageView: View { .resizable() } .failure { - Rectangle() - .fill(Color.gray) + ZStack { + Rectangle() + .foregroundColor(Color(UIColor.systemFill)) + + Text(failureInitials) + .font(.largeTitle) + .foregroundColor(.secondary) + } } } } diff --git a/Shared/Objects/Views/LazyView.swift b/Shared/Views/LazyView.swift similarity index 100% rename from Shared/Objects/Views/LazyView.swift rename to Shared/Views/LazyView.swift diff --git a/Shared/Objects/Views/MultiSelectorView.swift b/Shared/Views/MultiSelectorView.swift similarity index 100% rename from Shared/Objects/Views/MultiSelectorView.swift rename to Shared/Views/MultiSelectorView.swift diff --git a/Shared/Objects/Views/ParallaxHeader.swift b/Shared/Views/ParallaxHeader.swift similarity index 100% rename from Shared/Objects/Views/ParallaxHeader.swift rename to Shared/Views/ParallaxHeader.swift diff --git a/Shared/Objects/Views/SearchBarView.swift b/Shared/Views/SearchBarView.swift similarity index 100% rename from Shared/Objects/Views/SearchBarView.swift rename to Shared/Views/SearchBarView.swift diff --git a/Shared/Objects/Views/SearchablePickerView.swift b/Shared/Views/SearchablePickerView.swift similarity index 100% rename from Shared/Objects/Views/SearchablePickerView.swift rename to Shared/Views/SearchablePickerView.swift From 2006353c60ef073beb3ac3c691a3f8ac2acc347a Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 31 Aug 2021 23:12:20 -0600 Subject: [PATCH 6/9] Add debug localhost server check --- Shared/ViewModels/ConnectToServerViewModel.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index 33a7e893..d31bbfdc 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -56,6 +56,13 @@ final class ConnectToServerViewModel: ViewModel { } func connectToServer() { + + #if targetEnvironment(simulator) + if uriSubject.value == "localhost" { + uriSubject.value = "http://localhost:8096" + } + #endif + LogManager.shared.log.debug("Attempting to connect to server at \"\(uriSubject.value)\"", tag: "connectToServer") ServerEnvironment.current.create(with: uriSubject.value) .trackActivity(loading) From 0393347358f3652feec379d738d9ed56da730a8a Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 31 Aug 2021 23:19:23 -0600 Subject: [PATCH 7/9] Some cleanup --- .../ItemView/Landscape/ItemLandscapeMainView.swift | 1 + JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift index 4ebb9061..630f4f00 100644 --- a/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift @@ -67,6 +67,7 @@ struct ItemLandscapeMainView: View { // MARK: ItemViewBody if let episodeViewModel = viewModel as? SeasonItemViewModel { CardVStackView(items: episodeViewModel.episodes) + .frame(maxWidth: 400) } else { ItemViewBody() .environmentObject(viewModel) diff --git a/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift b/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift index a6a79a28..7ccac0ee 100644 --- a/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift +++ b/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift @@ -22,7 +22,7 @@ struct ItemPortraitMainView: View { // MARK: portraitHeaderView var portraitHeaderView: some View { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), + ImageView(src: viewModel.item.getBackdropImage(maxWidth: Int(UIScreen.main.bounds.width)), bh: viewModel.item.getBackdropImageBlurHash()) .opacity(0.4) .blur(radius: 2.0) @@ -54,7 +54,7 @@ struct ItemPortraitMainView: View { ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitStaticOverlayView, overlayAlignment: .bottomLeading, - headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) { + headerHeight: UIScreen.main.bounds.width * 0.5625) { VStack { Spacer() @@ -64,7 +64,6 @@ struct ItemPortraitMainView: View { Spacer() CardVStackView(items: episodeViewModel.episodes) .padding(.top, 5) - .frame(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 400 : .infinity) } else { ItemViewBody() .environmentObject(viewModel) From 2e38084708157701e285fb2f1fab4dea1920be92 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 31 Aug 2021 23:20:34 -0600 Subject: [PATCH 8/9] Update ItemLandscapeMainView.swift --- JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift index 630f4f00..4ebb9061 100644 --- a/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift @@ -67,7 +67,6 @@ struct ItemLandscapeMainView: View { // MARK: ItemViewBody if let episodeViewModel = viewModel as? SeasonItemViewModel { CardVStackView(items: episodeViewModel.episodes) - .frame(maxWidth: 400) } else { ItemViewBody() .environmentObject(viewModel) From 4b0bcb9190f5abc640a669593ceefbdf472468fc Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 31 Aug 2021 23:49:31 -0600 Subject: [PATCH 9/9] Cleanup --- JellyfinPlayer/ItemView/ItemView.swift | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/JellyfinPlayer/ItemView/ItemView.swift b/JellyfinPlayer/ItemView/ItemView.swift index 7e14fb9f..e3669681 100644 --- a/JellyfinPlayer/ItemView/ItemView.swift +++ b/JellyfinPlayer/ItemView/ItemView.swift @@ -25,7 +25,7 @@ struct ItemNavigationView: View { var body: some View { ItemView(item: item) - .navigationBarTitle("", displayMode: .large) + .navigationBarTitle("", displayMode: .inline) } } @@ -67,19 +67,3 @@ fileprivate struct ItemView: View { } } } - -extension UINavigationBar { - static func changeAppearance(clear: Bool) { - let appearance = UINavigationBarAppearance() - - if clear { - appearance.configureWithTransparentBackground() - } else { - appearance.configureWithDefaultBackground() - } - - UINavigationBar.appearance().standardAppearance = appearance - UINavigationBar.appearance().compactAppearance = appearance - UINavigationBar.appearance().scrollEdgeAppearance = appearance - } -}