Reduce item view complexity

This commit is contained in:
Ethan Pippin 2021-08-31 23:12:09 -06:00
parent b349258086
commit b6b78cc617
37 changed files with 860 additions and 1595 deletions

View File

@ -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 {

View File

@ -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 = "<group>"; };
53913BEE26D323FE00EB3286 /* kk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kk; path = Localizable.strings; sourceTree = "<group>"; };
5398514426B64DA100101B49 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
53987CA326572C1300E7EA70 /* SeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemView.swift; sourceTree = "<group>"; };
53987CA526572F0700E7EA70 /* SeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = "<group>"; };
53987CA72657424A00E7EA70 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = "<group>"; };
539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
53A089CF264DA9DA00D57806 /* MovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemView.swift; sourceTree = "<group>"; };
53A83C32268A309300DF3D92 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
53ABFDDA267972BF00886593 /* JellyfinPlayer tvOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "JellyfinPlayer tvOS.entitlements"; sourceTree = "<group>"; };
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 = "<group>"; };
62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemViewModel.swift; sourceTree = "<group>"; };
62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterViewModel.swift; sourceTree = "<group>"; };
62E632F2267D54030063E547 /* DetailItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItemViewModel.swift; sourceTree = "<group>"; };
62E632F2267D54030063E547 /* ItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewModel.swift; sourceTree = "<group>"; };
62EC352B26766675000E9F2D /* ServerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEnvironment.swift; sourceTree = "<group>"; };
62EC352E267666A5000E9F2D /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = "<group>"; };
62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = "<group>"; };
@ -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 = "<group>"; };
E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = "<group>"; };
E131691626C583BC0074BFEE /* LogConstructor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogConstructor.swift; sourceTree = "<group>"; };
E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitBodyView.swift; sourceTree = "<group>"; };
E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeBodyView.swift; sourceTree = "<group>"; };
E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitMainView.swift; sourceTree = "<group>"; };
E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeMainView.swift; sourceTree = "<group>"; };
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = "<group>"; };
E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = "<group>"; };
E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Stackable.swift"; sourceTree = "<group>"; };
E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewBody.swift; sourceTree = "<group>"; };
E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = "<group>"; };
E188460326DEF04800B0C5B7 /* CardVStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVStackView.swift; sourceTree = "<group>"; };
E1AD104926D94822003E4A08 /* DetailItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItem.swift; sourceTree = "<group>"; };
E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = "<group>"; };
E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHStackView.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 = "<group>";
@ -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 = "<group>";
};
E18845FA26DEACBE00B0C5B7 /* Portrait */ = {
isa = PBXGroup;
children = (
E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */,
E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */,
);
path = Portrait;
sourceTree = "<group>";
};
E18845FB26DEACC400B0C5B7 /* Landscape */ = {
isa = PBXGroup;
children = (
E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */,
E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */,
);
path = Landscape;
sourceTree = "<group>";
};
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 */,

View File

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

View File

@ -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<TopBarView: View, NavigationView: View, ItemType: PortraitImageStackable>: View {
@ -39,8 +40,10 @@ struct PortraitImageHStackView<TopBarView: View, NavigationView: View, ItemType:
ScrollView(.horizontal, showsIndicators: false) {
VStack {
Spacer().frame(height: 8)
HStack {
HStack(alignment: .top) {
Spacer().frame(width: 16)
ForEach(items, id: \.title) { item in
NavigationLink(
destination: LazyView {
@ -49,7 +52,8 @@ struct PortraitImageHStackView<TopBarView: View, NavigationView: View, ItemType:
label: {
VStack {
ImageView(src: item.imageURLContsructor(maxWidth: maxWidth),
bh: item.blurHash)
bh: item.blurHash,
failureInitials: item.failureInitials)
.frame(width: 100, height: CGFloat(maxWidth))
.cornerRadius(10)
.shadow(radius: 4, y: 2)
@ -57,17 +61,19 @@ struct PortraitImageHStackView<TopBarView: View, NavigationView: View, ItemType:
Text(item.title)
.font(.footnote)
.fontWeight(.regular)
.lineLimit(1)
.frame(width: 100)
.foregroundColor(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
if let description = item.description {
Text(description)
.font(.caption)
.fontWeight(.medium)
.lineLimit(1)
.frame(width: 100)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
}
}
})

View File

@ -15,7 +15,7 @@ struct PortraitItemView: View {
var item: BaseItemDto
var body: some View {
NavigationLink(destination: LazyView { ItemView(item: item) }) {
NavigationLink(destination: LazyView { ItemNavigationView(item: item) }) {
VStack(alignment: .leading) {
ImageView(src: item.type != "Episode" ? item.getPrimaryImage(maxWidth: 100) : item.getSeriesPrimaryImage(maxWidth: 100), bh: item.type != "Episode" ? item.getPrimaryImageBlurHash() : item.getSeriesPrimaryImageBlurHash())
.frame(width: 100, height: 150)

View File

@ -37,7 +37,7 @@ struct ContinueWatchingView: View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(items, id: \.id) { item in
NavigationLink(destination: LazyView { ItemView(item: item) }) {
NavigationLink(destination: LazyView { ItemNavigationView(item: item) }) {
VStack(alignment: .leading) {
ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash())
.frame(width: 320, height: 180)
@ -58,7 +58,7 @@ struct ContinueWatchingView: View {
.foregroundColor(.primary)
.lineLimit(1)
if item.type == "Episode" {
Text("\(item.getEpisodeLocator()) - \(item.name ?? "")")
Text("\(item.getEpisodeLocator() ?? "") - \(item.name ?? "")")
.font(.callout)
.fontWeight(.semibold)
.foregroundColor(.secondary)

View File

@ -1,413 +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 EpisodeItemView: View {
@StateObject var viewModel: EpisodeItemViewModel
@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.getSeriesPrimaryImage(maxWidth: 120), bh: viewModel.item.getSeriesPrimaryImageBlurHash())
.frame(width: 120, height: 180)
.cornerRadius(10)
VStack(alignment: .leading) {
Spacer()
Text(viewModel.item.name ?? "").font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
.fixedSize(horizontal: false, vertical: true)
.offset(y: 5)
HStack {
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 != "" {
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: 3)
}
}
} 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.getSeriesPrimaryImage(maxWidth: 120), bh: viewModel.item.getSeriesPrimaryImageBlurHash())
.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())
.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 {
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: 195)
}.frame(maxHeight: .infinity)
}
}.padding(.top, 16).padding(.leading, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
.edgesIgnoringSafeArea(.leading)
}
}
}
}
.onRotate(perform: { orientation in
self.orientation = orientation
})
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("\(viewModel.item.seriesName ?? "") - S\(String(viewModel.item.parentIndexNumber ?? 0)):E\(String(viewModel.item.indexNumber ?? 0))")
}
}

View File

@ -13,6 +13,14 @@ import SwiftUI
struct HomeView: View {
@StateObject var viewModel = HomeViewModel()
@State var showingSettings = false
init() {
let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill")
let barAppearance = UINavigationBar.appearance()
barAppearance.backIndicatorImage = backButtonBackgroundImage
barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage
barAppearance.tintColor = UIColor(Color.jellyfinPurple)
}
@ViewBuilder
var innerBody: some View {

View File

@ -1,16 +0,0 @@
//
/*
* 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@*/)
}
}

View File

@ -1,103 +0,0 @@
//
/*
* 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<PortraitHeaderView: View, PortraitStaticOverlayView: View>: 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<Bool>,
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)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ extension Defaults.Keys {
static let isAutoSelectSubtitles = Key<Bool>("isAutoSelectSubtitles", default: false)
static let autoSelectSubtitlesLangCode = Key<String>("AutoSelectSubtitlesLangCode", default: "Auto")
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto")
static let appAppearance = Key<String>("appAppearance", default: AppAppearance.system.rawValue)
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system)
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward", default: .thirty)
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>("videoPlayerJumpBackward", default: .thirty)
}

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

@ -11,5 +11,5 @@ import Combine
import Foundation
import JellyfinAPI
final class MovieItemViewModel: DetailItemViewModel {
final class MovieItemViewModel: ItemViewModel {
}

View File

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

View File

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

View File

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

View File

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