From b6b78cc617a667bc79415befdb72036d5ac7e6d8 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 31 Aug 2021 23:12:09 -0600 Subject: [PATCH] Reduce item view complexity --- .../Components/MediaPlayButtonRowView.swift | 2 +- JellyfinPlayer.xcodeproj/project.pbxproj | 90 ++-- .../Components/CardVStackView.swift | 105 +++++ .../Components/PortraitHStackView.swift | 14 +- .../Components/PortraitItemView.swift | 2 +- JellyfinPlayer/ContinueWatchingView.swift | 4 +- JellyfinPlayer/EpisodeItemView.swift | 413 ----------------- JellyfinPlayer/HomeView.swift | 8 + .../ItemView/ItemLandscapeBodyView.swift | 16 - .../ItemView/ItemPortraitBodyView.swift | 103 ----- JellyfinPlayer/ItemView/ItemView.swift | 68 ++- JellyfinPlayer/ItemView/ItemViewBody.swift | 88 ++++ .../Landscape/ItemLandscapeMainView.swift | 114 +++++ .../Landscape/ItemLandscapeTopBarView.swift | 93 ++++ .../ItemPortraitHeaderOverlayView.swift | 78 ++-- .../Portrait/ItemPortraitMainView.swift | 76 ++++ JellyfinPlayer/JellyfinPlayerApp.swift | 3 +- JellyfinPlayer/MovieItemView.swift | 430 ------------------ JellyfinPlayer/SeasonItemView.swift | 257 ----------- JellyfinPlayer/SeriesItemView.swift | 243 ---------- JellyfinPlayer/SettingsView.swift | 3 +- Shared/Extensions/DefaultsExtension.swift | 2 +- .../BaseItemDto+Stackable.swift | 44 ++ .../BaseItemDtoExtensions.swift | 13 +- .../BaseItemPersonExtensions.swift | 52 ++- Shared/ViewModels/EpisodeItemViewModel.swift | 11 +- ...temViewModel.swift => ItemViewModel.swift} | 31 +- Shared/ViewModels/MovieItemViewModel.swift | 2 +- Shared/ViewModels/SeasonItemViewModel.swift | 38 +- Shared/ViewModels/SeriesItemViewModel.swift | 27 +- Shared/ViewModels/SettingsViewModel.swift | 3 +- Shared/{Objects => }/Views/ImageView.swift | 22 +- Shared/{Objects => }/Views/LazyView.swift | 0 .../Views/MultiSelectorView.swift | 0 .../{Objects => }/Views/ParallaxHeader.swift | 0 .../{Objects => }/Views/SearchBarView.swift | 0 .../Views/SearchablePickerView.swift | 0 37 files changed, 860 insertions(+), 1595 deletions(-) create mode 100644 JellyfinPlayer/Components/CardVStackView.swift delete mode 100644 JellyfinPlayer/EpisodeItemView.swift delete mode 100644 JellyfinPlayer/ItemView/ItemLandscapeBodyView.swift delete mode 100644 JellyfinPlayer/ItemView/ItemPortraitBodyView.swift create mode 100644 JellyfinPlayer/ItemView/ItemViewBody.swift create mode 100644 JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift create mode 100644 JellyfinPlayer/ItemView/Landscape/ItemLandscapeTopBarView.swift rename JellyfinPlayer/ItemView/{ => Portrait}/ItemPortraitHeaderOverlayView.swift (65%) create mode 100644 JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift delete mode 100644 JellyfinPlayer/MovieItemView.swift delete mode 100644 JellyfinPlayer/SeasonItemView.swift delete mode 100644 JellyfinPlayer/SeriesItemView.swift create mode 100644 Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift rename Shared/ViewModels/{DetailItemViewModel.swift => ItemViewModel.swift} (85%) rename Shared/{Objects => }/Views/ImageView.swift (58%) rename Shared/{Objects => }/Views/LazyView.swift (100%) rename Shared/{Objects => }/Views/MultiSelectorView.swift (100%) rename Shared/{Objects => }/Views/ParallaxHeader.swift (100%) rename Shared/{Objects => }/Views/SearchBarView.swift (100%) rename Shared/{Objects => }/Views/SearchablePickerView.swift (100%) diff --git a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift index 6613fec0..4b6987a0 100644 --- a/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift +++ b/JellyfinPlayer tvOS/Components/MediaPlayButtonRowView.swift @@ -11,7 +11,7 @@ import SwiftUI struct MediaPlayButtonRowView: View { - @ObservedObject var viewModel: DetailItemViewModel + @ObservedObject var viewModel: ItemViewModel @State var wrappedScrollView: UIScrollView? var body: some View { diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index a9f66ee5..64d95b43 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -141,7 +141,6 @@ 5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624C21742685CF60007F1390 /* SearchablePickerView.swift */; }; 5398514726B64E4100101B49 /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; }; 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */; }; - 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A089CF264DA9DA00D57806 /* MovieItemView.swift */; }; 53A431BD266B0FF20016769F /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 53A431BC266B0FF20016769F /* JellyfinAPI */; }; 53A431BF266B0FFE0016769F /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 53A431BE266B0FFE0016769F /* JellyfinAPI */; }; 53A83C33268A309300DF3D92 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A83C32268A309300DF3D92 /* LibraryView.swift */; }; @@ -156,11 +155,8 @@ 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ABFDEA2679753200886593 /* ConnectToServerView.swift */; }; 53ABFDED26799D7700886593 /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 53ABFDEC26799D7700886593 /* ActivityIndicator */; }; 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; - 53AD124D267029D60094A276 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53987CA526572F0700E7EA70 /* SeriesItemView.swift */; }; - 53AD124E26702B8A0094A276 /* SeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53987CA326572C1300E7EA70 /* SeasonItemView.swift */; }; 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CD2A3F268A49C2002ABD4E /* ItemView.swift */; }; 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CD2A41268A4B38002ABD4E /* MovieItemView.swift */; }; - 53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */; }; 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; }; 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; }; 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; }; @@ -224,8 +220,8 @@ 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */; }; 62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */; }; 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */; }; - 62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* DetailItemViewModel.swift */; }; - 62E632F4267D54030063E547 /* DetailItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* DetailItemViewModel.swift */; }; + 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; + 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; 62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352B26766675000E9F2D /* ServerEnvironment.swift */; }; 62EC352D26766675000E9F2D /* ServerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352B26766675000E9F2D /* ServerEnvironment.swift */; }; 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352E267666A5000E9F2D /* SessionManager.swift */; }; @@ -238,11 +234,17 @@ E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; E131691926C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; }; - E14F7D0726DB36EF007C3AE6 /* ItemPortraitBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */; }; - E14F7D0926DB36F7007C3AE6 /* ItemLandscapeBodyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */; }; + E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */; }; + E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */; }; E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; + E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; }; + E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; }; + E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */; }; + E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */; }; + E188460426DEF04800B0C5B7 /* CardVStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E188460326DEF04800B0C5B7 /* CardVStackView.swift */; }; + E188460526DEF04800B0C5B7 /* CardVStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E188460326DEF04800B0C5B7 /* CardVStackView.swift */; }; E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104926D94822003E4A08 /* DetailItem.swift */; }; E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104926D94822003E4A08 /* DetailItem.swift */; }; E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; @@ -393,11 +395,7 @@ 53913BEB26D323FE00EB3286 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = Localizable.strings; sourceTree = ""; }; 53913BEE26D323FE00EB3286 /* kk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kk; path = Localizable.strings; sourceTree = ""; }; 5398514426B64DA100101B49 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 53987CA326572C1300E7EA70 /* SeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemView.swift; sourceTree = ""; }; - 53987CA526572F0700E7EA70 /* SeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = ""; }; - 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = ""; }; 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 53A089CF264DA9DA00D57806 /* MovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemView.swift; sourceTree = ""; }; 53A83C32268A309300DF3D92 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 53ABFDDA267972BF00886593 /* JellyfinPlayer tvOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "JellyfinPlayer tvOS.entitlements"; sourceTree = ""; }; 53ABFDDB267972BF00886593 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.0.sdk/System/Library/Frameworks/TVServices.framework; sourceTree = DEVELOPER_DIR; }; @@ -448,7 +446,7 @@ 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemViewModel.swift; sourceTree = ""; }; 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemViewModel.swift; sourceTree = ""; }; 62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterViewModel.swift; sourceTree = ""; }; - 62E632F2267D54030063E547 /* DetailItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItemViewModel.swift; sourceTree = ""; }; + 62E632F2267D54030063E547 /* ItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewModel.swift; sourceTree = ""; }; 62EC352B26766675000E9F2D /* ServerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEnvironment.swift; sourceTree = ""; }; 62EC352E267666A5000E9F2D /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; @@ -458,11 +456,15 @@ DE5004F745B19E28744A7DE7 /* Pods-JellyfinPlayer tvOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.debug.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.debug.xcconfig"; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; E131691626C583BC0074BFEE /* LogConstructor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogConstructor.swift; sourceTree = ""; }; - E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitBodyView.swift; sourceTree = ""; }; - E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeBodyView.swift; sourceTree = ""; }; + E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitMainView.swift; sourceTree = ""; }; + E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeMainView.swift; sourceTree = ""; }; E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; + E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Stackable.swift"; sourceTree = ""; }; + E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewBody.swift; sourceTree = ""; }; + E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = ""; }; + E188460326DEF04800B0C5B7 /* CardVStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVStackView.swift; sourceTree = ""; }; E1AD104926D94822003E4A08 /* DetailItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItem.swift; sourceTree = ""; }; E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = ""; }; E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHStackView.swift; sourceTree = ""; }; @@ -557,7 +559,7 @@ isa = PBXGroup; children = ( 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, - 62E632F2267D54030063E547 /* DetailItemViewModel.swift */, + 62E632F2267D54030063E547 /* ItemViewModel.swift */, 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */, @@ -668,12 +670,13 @@ isa = PBXGroup; children = ( E1FCD08E26C466F3007C8DCF /* Errors */, + 621338912660106C00A81A2A /* Extensions */, + 535870AB2669D8D300D05A09 /* Objects */, + AE8C3157265D6F5E008AA076 /* Resources */, 091B5A852683142E00D78B61 /* ServerLocator */, 62EC352A26766657000E9F2D /* Singleton */, 532175392671BCED005491E6 /* ViewModels */, - 621338912660106C00A81A2A /* Extensions */, - AE8C3157265D6F5E008AA076 /* Resources */, - 535870AB2669D8D300D05A09 /* Objects */, + E1AD105326D96F5A003E4A08 /* Views */, ); path = Shared; sourceTree = ""; @@ -681,7 +684,6 @@ 535870AB2669D8D300D05A09 /* Objects */ = { isa = PBXGroup; children = ( - E1AD105326D96F5A003E4A08 /* Views */, 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */, 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, 535870AC2669D8DD00D05A09 /* Typings.swift */, @@ -736,7 +738,6 @@ 5377CBF8263B596B003A4E83 /* Assets.xcassets */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, 5389276D263C25100035E14B /* ContinueWatchingView.swift */, - 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */, 5377CC02263B596B003A4E83 /* Info.plist */, E14F7D0A26DB3714007C3AE6 /* ItemView */, 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */, @@ -746,12 +747,9 @@ 53EE24E5265060780068F029 /* LibrarySearchView.swift */, 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, 53892771263C8C6F0035E14B /* LoadingView.swift */, - 53A089CF264DA9DA00D57806 /* MovieItemView.swift */, 5389276F263C25230035E14B /* NextUpView.swift */, 5377CBFD263B596B003A4E83 /* PersistenceController.swift */, 5377CBFA263B596B003A4E83 /* Preview Content */, - 53987CA326572C1300E7EA70 /* SeasonItemView.swift */, - 53987CA526572F0700E7EA70 /* SeriesItemView.swift */, 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, 535BAEA4264A151C005FA86D /* VideoPlayer.swift */, @@ -928,6 +926,7 @@ E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */, E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */, 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */, + E188460326DEF04800B0C5B7 /* CardVStackView.swift */, ); path = Components; sourceTree = ""; @@ -993,18 +992,37 @@ isa = PBXGroup; children = ( 535BAE9E2649E569005FA86D /* ItemView.swift */, - E14F7D0826DB36F7007C3AE6 /* ItemLandscapeBodyView.swift */, - E14F7D0626DB36EF007C3AE6 /* ItemPortraitBodyView.swift */, - E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */, + E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */, + E18845FB26DEACC400B0C5B7 /* Landscape */, + E18845FA26DEACBE00B0C5B7 /* Portrait */, ); path = ItemView; sourceTree = ""; }; + E18845FA26DEACBE00B0C5B7 /* Portrait */ = { + isa = PBXGroup; + children = ( + E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */, + E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */, + ); + path = Portrait; + sourceTree = ""; + }; + E18845FB26DEACC400B0C5B7 /* Landscape */ = { + isa = PBXGroup; + children = ( + E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */, + E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */, + ); + path = Landscape; + sourceTree = ""; + }; E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */ = { isa = PBXGroup; children = ( 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */, E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */, + E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */, E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */, ); path = JellyfinAPIExtensions; @@ -1381,6 +1399,7 @@ buildActionMask = 2147483647; files = ( 531069572684E7EE00CFFDBA /* InfoTabBarViewController.swift in Sources */, + E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, 6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */, 531069592684E7EE00CFFDBA /* SubtitlesView.swift in Sources */, 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */, @@ -1412,7 +1431,7 @@ 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, 5310695A2684E7EE00CFFDBA /* VideoPlayer.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, - 62E632F4267D54030063E547 /* DetailItemViewModel.swift in Sources */, + 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1AD106326D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, @@ -1452,6 +1471,7 @@ 53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, E1AD105D26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, + E188460526DEF04800B0C5B7 /* CardVStackView.swift in Sources */, 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */, E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */, E1AD105726D981CE003E4A08 /* PortraitHStackView.swift in Sources */, @@ -1465,6 +1485,7 @@ buildActionMask = 2147483647; files = ( 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, + E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, 621338932660107500A81A2A /* StringExtensions.swift in Sources */, 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, @@ -1474,15 +1495,15 @@ 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, 53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */, E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, - 53AD124E26702B8A0094A276 /* SeasonItemView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, 62CB3F4B2685BB77003D0A6F /* DefaultsExtension.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, + E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, - 53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */, + E188460426DEF04800B0C5B7 /* CardVStackView.swift in Sources */, 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */, @@ -1498,20 +1519,20 @@ E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, 532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift in Sources */, - E14F7D0926DB36F7007C3AE6 /* ItemLandscapeBodyView.swift in Sources */, + E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, 5377CC01263B596B003A4E83 /* Model.xcdatamodeld in Sources */, 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, + E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */, 091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */, 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, - 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */, 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */, - 62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */, + 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, - E14F7D0726DB36EF007C3AE6 /* ItemPortraitBodyView.swift in Sources */, + E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */, E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, @@ -1535,7 +1556,6 @@ 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, - 53AD124D267029D60094A276 /* SeriesItemView.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, diff --git a/JellyfinPlayer/Components/CardVStackView.swift b/JellyfinPlayer/Components/CardVStackView.swift new file mode 100644 index 00000000..84583dd9 --- /dev/null +++ b/JellyfinPlayer/Components/CardVStackView.swift @@ -0,0 +1,105 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI +import JellyfinAPI + +struct CardVStackView: View { + + let items: [BaseItemDto] + + private func buildCardOverlayView(item: BaseItemDto) -> some View { + HStack { + ZStack { + if item.userData?.isFavorite ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + .opacity(0.6) + Image(systemName: "heart.fill") + .foregroundColor(Color(.systemRed)) + .font(.system(size: 10)) + } + } + .padding(.leading, 2) + .padding(.bottom, item.userData?.playedPercentage == nil ? 2 : 9) + .opacity(1) + + ZStack { + if item.userData?.played ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.jellyfinPurple) + } + }.padding(2) + .opacity(1) + } + } + + var body: some View { + VStack { + ForEach(items, id: \.id) { item in + NavigationLink(destination: ItemNavigationView(item: item)) { + + HStack { + + // MARK: Image + ImageView(src: item.getPrimaryImage(maxWidth: 150), + bh: item.getPrimaryImageBlurHash(), + failureInitials: item.failureInitials) + .frame(width: 150, height: 100) + .cornerRadius(10) + .overlay( + Rectangle() + .fill(Color.jellyfinPurple) + .mask(ProgressBar()) + .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 1.5), height: 7) + .padding(0), alignment: .bottomLeading + ) + .overlay(buildCardOverlayView(item: item), alignment: .topTrailing) + + VStack(alignment: .leading) { + + // MARK: Title + Text(item.title) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + .lineLimit(2) + + HStack { + Text(item.getEpisodeLocator() ?? "") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + + Text(item.getItemRuntime()) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + + Spacer() + } + + // MARK: Overview + Text(item.overview ?? "") + .font(.footnote) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(4) + + Spacer() + } + } + .padding(.horizontal, 16) + } + } + } + } +} diff --git a/JellyfinPlayer/Components/PortraitHStackView.swift b/JellyfinPlayer/Components/PortraitHStackView.swift index b6668419..96e32645 100644 --- a/JellyfinPlayer/Components/PortraitHStackView.swift +++ b/JellyfinPlayer/Components/PortraitHStackView.swift @@ -14,6 +14,7 @@ public protocol PortraitImageStackable { var title: String { get } var description: String? { get } var blurHash: String { get } + var failureInitials: String { get } } struct PortraitImageHStackView: View { @@ -39,8 +40,10 @@ struct PortraitImageHStackView: View { - - @Binding private var videoIsLoading: Bool - @EnvironmentObject private var viewModel: DetailItemViewModel - @EnvironmentObject private var videoPlayerItem: VideoPlayerItem - - private let portraitHeaderView: (DetailItemViewModel) -> PortraitHeaderView - private let portraitStaticOverlayView: (DetailItemViewModel) -> PortraitStaticOverlayView - - init(videoIsLoading: Binding, - portraitHeaderView: @escaping (DetailItemViewModel) -> PortraitHeaderView, - portraitStaticOverlayView: @escaping (DetailItemViewModel) -> PortraitStaticOverlayView) { - self._videoIsLoading = videoIsLoading - self.portraitHeaderView = portraitHeaderView - self.portraitStaticOverlayView = portraitStaticOverlayView - } - - var body: some View { - VStack(alignment: .leading) { - NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) { - VLCPlayerWithControls(item: videoPlayerItem.itemToPlay, - loadBinding: $videoIsLoading, - pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer) - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) - .statusBar(hidden: true) - .edgesIgnoringSafeArea(.all) - .prefersHomeIndicatorAutoHidden(true) - }, isActive: $videoPlayerItem.shouldShowPlayer) { - EmptyView() - } - - // MARK: Body - ParallaxHeaderScrollView(header: portraitHeaderView(viewModel), - staticOverlayView: portraitStaticOverlayView(viewModel), - overlayAlignment: .bottomLeading, - headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) { - VStack { - Spacer() - .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24) - - // MARK: Overview - Text(viewModel.item.overview ?? "") - .font(.footnote) - .padding(.top, 3) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 3) - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - - // MARK: Genres - PillHStackView(title: "Genres", - items: viewModel.item.genreItems ?? []) { genre in - LibraryView(viewModel: .init(genre: genre), title: genre.title) - } - - // MARK: Studios - if let studios = viewModel.item.studios { - PillHStackView(title: "Studios", - items: studios) { studio in - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - } - } - - // MARK: Cast - PortraitImageHStackView(items: viewModel.item.people?.filter({ $0.type == "Actor" }) ?? [], - maxWidth: 150) { - Text("Cast") - .font(.callout) - .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) - } navigationView: { person in - LibraryView(viewModel: .init(person: person), title: person.title) - } - - // MARK: More Like This - - - Spacer(minLength: 10) - } -// .introspectTabBarController { (UITabBarController) in -// UITabBarController.tabBar.isHidden = false -// } -// .navigationBarBackButtonHidden(false) - .environmentObject(videoPlayerItem) - } - } - } -} diff --git a/JellyfinPlayer/ItemView/ItemView.swift b/JellyfinPlayer/ItemView/ItemView.swift index 1366f6a1..7e14fb9f 100644 --- a/JellyfinPlayer/ItemView/ItemView.swift +++ b/JellyfinPlayer/ItemView/ItemView.swift @@ -14,38 +14,72 @@ class VideoPlayerItem: ObservableObject { @Published var itemToPlay: BaseItemDto = BaseItemDto() } -struct ItemView: View { +// Intermediary view for ItemView to set navigation bar settings +struct ItemNavigationView: View { + + private let item: BaseItemDto + + init(item: BaseItemDto) { + self.item = item + } + + var body: some View { + ItemView(item: item) + .navigationBarTitle("", displayMode: .large) + } +} + +fileprivate struct ItemView: View { @State private var videoIsLoading: Bool = false; // This variable is only changed by the underlying VLC view. @State private var viewDidLoad: Bool = false @State private var orientation: UIDeviceOrientation = .unknown @StateObject private var videoPlayerItem: VideoPlayerItem = VideoPlayerItem() - @Environment(\.horizontalSizeClass) var hSizeClass - @Environment(\.verticalSizeClass) var vSizeClass + @Environment(\.horizontalSizeClass) private var hSizeClass + @Environment(\.verticalSizeClass) private var vSizeClass - private let viewModel: DetailItemViewModel + private let viewModel: ItemViewModel init(item: BaseItemDto) { - self.viewModel = DetailItemViewModel(item: item) + switch item.itemType { + case .movie: + self.viewModel = MovieItemViewModel(item: item) + case .season: + self.viewModel = SeasonItemViewModel(item: item) + case .episode: + self.viewModel = EpisodeItemViewModel(item: item) + case .series: + self.viewModel = SeriesItemViewModel(item: item) + default: + self.viewModel = ItemViewModel(item: item) + } } var body: some View { if hSizeClass == .compact && vSizeClass == .regular { - ItemPortraitBodyView(videoIsLoading: $videoIsLoading, - portraitHeaderView: { viewModel in - ImageView(src: viewModel.item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - }, - portraitStaticOverlayView: { viewModel in - PortraitHeaderOverlayView() - .environmentObject(viewModel) - }) + ItemPortraitMainView(videoIsLoading: $videoIsLoading) .environmentObject(videoPlayerItem) .environmentObject(viewModel) } else { - Text("Hello there") + ItemLandscapeMainView(videoIsLoading: $videoIsLoading) + .environmentObject(videoPlayerItem) + .environmentObject(viewModel) } } } + +extension UINavigationBar { + static func changeAppearance(clear: Bool) { + let appearance = UINavigationBarAppearance() + + if clear { + appearance.configureWithTransparentBackground() + } else { + appearance.configureWithDefaultBackground() + } + + UINavigationBar.appearance().standardAppearance = appearance + UINavigationBar.appearance().compactAppearance = appearance + UINavigationBar.appearance().scrollEdgeAppearance = appearance + } +} diff --git a/JellyfinPlayer/ItemView/ItemViewBody.swift b/JellyfinPlayer/ItemView/ItemViewBody.swift new file mode 100644 index 00000000..0050b01c --- /dev/null +++ b/JellyfinPlayer/ItemView/ItemViewBody.swift @@ -0,0 +1,88 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI +import JellyfinAPI + +struct ItemViewBody: View { + + @EnvironmentObject private var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .leading) { + + // MARK: Overview + Text(viewModel.item.overview ?? "") + .font(.footnote) + .padding(.horizontal, 16) + .padding(.vertical, 3) + + // MARK: Seasons + if let seriesViewModel = viewModel as? SeriesItemViewModel { + PortraitImageHStackView(items: seriesViewModel.seasons, + maxWidth: 150, + topBarView: { + Text("Seasons") + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) + }, navigationView: { season in + ItemNavigationView(item: season) + }) + } + + // MARK: Genres + PillHStackView(title: "Genres", + items: viewModel.item.genreItems ?? []) { genre in + LibraryView(viewModel: .init(genre: genre), title: genre.title) + } + + // MARK: Studios + if let studios = viewModel.item.studios { + PillHStackView(title: "Studios", + items: studios) { studio in + LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") + } + } + + // MARK: Cast & Crew + if let castAndCrew = viewModel.item.people { + PortraitImageHStackView(items: castAndCrew.filter({ BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }), + maxWidth: 150, + topBarView: { + Text("Cast & Crew") + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) + }, + navigationView: { person in + LibraryView(viewModel: .init(person: person), title: person.title) + }) + } + + // MARK: More Like This + if !viewModel.similarItems.isEmpty { + PortraitImageHStackView(items: viewModel.similarItems, + maxWidth: 150, + topBarView: { + Text("More Like This") + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) + }, + navigationView: { item in + ItemNavigationView(item: item) + }) + } + } + } +} diff --git a/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift new file mode 100644 index 00000000..4ebb9061 --- /dev/null +++ b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift @@ -0,0 +1,114 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI + +struct ItemLandscapeMainView: View { + + @Binding private var videoIsLoading: Bool + @EnvironmentObject private var viewModel: ItemViewModel + @EnvironmentObject private var videoPlayerItem: VideoPlayerItem + + init(videoIsLoading: Binding) { + self._videoIsLoading = videoIsLoading + } + + // MARK: innerBody + private var innerBody: some View { + HStack { + + // MARK: Sidebar Image + VStack { + ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 130), + bh: viewModel.item.getPrimaryImageBlurHash()) + .frame(width: 130, height: 195) + .cornerRadius(10) + + Spacer().frame(height: 15) + + Button { + if let playButtonItem = viewModel.playButtonItem { + self.videoPlayerItem.itemToPlay = playButtonItem + self.videoPlayerItem.shouldShowPlayer = true + } + } label: { + + // MARK: Play + HStack { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.system(size: 20)) + Text(viewModel.playButtonText()) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.callout) + .fontWeight(.semibold) + } + .frame(width: 130, height: 40) + .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) + .cornerRadius(10) + }.disabled(viewModel.playButtonItem == nil) + + Spacer() + } + + ScrollView { + VStack(alignment: .leading) { + + // MARK: ItemLandscapeTopBarView + ItemLandscapeTopBarView() + .environmentObject(viewModel) + + // MARK: ItemViewBody + if let episodeViewModel = viewModel as? SeasonItemViewModel { + CardVStackView(items: episodeViewModel.episodes) + } else { + ItemViewBody() + .environmentObject(viewModel) + } + } + } + } + } + + // MARK: body + var body: some View { + VStack { + NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) { + VLCPlayerWithControls(item: videoPlayerItem.itemToPlay, + loadBinding: $videoIsLoading, + pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .statusBar(hidden: true) + .edgesIgnoringSafeArea(.all) + .prefersHomeIndicatorAutoHidden(true) + }, isActive: $videoPlayerItem.shouldShowPlayer) { + EmptyView() + } + + ZStack { + + // MARK: Backdrop + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), + bh: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.3) + .edgesIgnoringSafeArea(.all) + .blur(radius: 4) + + // iPadOS is making the view go all the way to the edge. + // We have to accomodate this here + if UIDevice.current.userInterfaceIdiom == .pad { + innerBody.padding(.horizontal, 25) + } else { + innerBody + } + } + } + } +} diff --git a/JellyfinPlayer/ItemView/Landscape/ItemLandscapeTopBarView.swift b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeTopBarView.swift new file mode 100644 index 00000000..24a9e15a --- /dev/null +++ b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeTopBarView.swift @@ -0,0 +1,93 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI + +struct ItemLandscapeTopBarView: View { + + @EnvironmentObject private var viewModel: ItemViewModel + + var body: some View { + HStack { + VStack(alignment: .leading) { + + // MARK: Name + + Text(viewModel.getItemDisplayName()) + .font(.title) + .fontWeight(.semibold) + .foregroundColor(.primary) + .padding(.leading, 16) + .padding(.bottom, 10) + + if viewModel.item.itemType.showDetails { + // MARK: Runtime + Text(viewModel.item.getItemRuntime()) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .padding(.leading, 16) + } + + // MARK: Details + HStack { + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear ?? 0)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + } + + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + + Spacer() + + if viewModel.item.itemType.showDetails { + // MARK: Favorite + Button { + viewModel.updateFavoriteState() + } label: { + if viewModel.isFavorited { + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) + } else { + Image(systemName: "heart").foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) + + // MARK: Watched + Button { + viewModel.updateWatchState() + } label: { + if viewModel.isWatched { + Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) + .font(.system(size: 20)) + } else { + Image(systemName: "checkmark.circle").foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) + } + } + .padding(.leading, 16) + } + } + } +} diff --git a/JellyfinPlayer/ItemView/ItemPortraitHeaderOverlayView.swift b/JellyfinPlayer/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift similarity index 65% rename from JellyfinPlayer/ItemView/ItemPortraitHeaderOverlayView.swift rename to JellyfinPlayer/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift index a0b30180..cb7c28a5 100644 --- a/JellyfinPlayer/ItemView/ItemPortraitHeaderOverlayView.swift +++ b/JellyfinPlayer/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift @@ -12,12 +12,14 @@ import JellyfinAPI struct PortraitHeaderOverlayView: View { - @EnvironmentObject private var viewModel: DetailItemViewModel + @EnvironmentObject private var viewModel: ItemViewModel @EnvironmentObject private var videoPlayerItem: VideoPlayerItem var body: some View { VStack(alignment: .leading) { HStack(alignment: .bottom, spacing: 12) { + + // MARK: Portrait Image ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 130)) .frame(width: 130, height: 195) .cornerRadius(10) @@ -25,20 +27,26 @@ struct PortraitHeaderOverlayView: View { VStack(alignment: .leading, spacing: 1) { Spacer() - Text(viewModel.item.name ?? "") - .font(.headline) + // MARK: Name + Text(viewModel.getItemDisplayName()) + .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) .fixedSize(horizontal: false, vertical: true) - .offset(y: 5) + .padding(.bottom, 10) - Text(viewModel.item.getItemRuntime()) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(.top, 10) + if viewModel.item.itemType.showDetails { + // MARK: Runtime + if viewModel.shouldDisplayRuntime() { + Text(viewModel.item.getItemRuntime()) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + // MARK: Details HStack { if let productionYear = viewModel.item.productionYear { Text(String(productionYear)) @@ -63,30 +71,32 @@ struct PortraitHeaderOverlayView: View { .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) } - if viewModel.item.itemType != .series { - HStack { - - // MARK: Play - Button { - self.videoPlayerItem.itemToPlay = viewModel.item + HStack { + + // MARK: Play + Button { + if let playButtonItem = viewModel.playButtonItem { + self.videoPlayerItem.itemToPlay = playButtonItem self.videoPlayerItem.shouldShowPlayer = true - } label: { - HStack { - Image(systemName: "play.fill") - .foregroundColor(Color.white) - .font(.system(size: 20)) - Text(viewModel.item.getItemProgressString() == "" ? "Play" : viewModel.item.getItemProgressString()) - .foregroundColor(Color.white) - .font(.callout) - .fontWeight(.semibold) - } - .frame(width: 130, height: 40) - .background(Color.jellyfinPurple) - .cornerRadius(10) } - - Spacer() - + } label: { + HStack { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.system(size: 20)) + Text(viewModel.playButtonText()) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.callout) + .fontWeight(.semibold) + } + .frame(width: 130, height: 40) + .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) + .cornerRadius(10) + }.disabled(viewModel.playButtonItem == nil) + + Spacer() + + if viewModel.item.itemType.showDetails { // MARK: Favorite Button { viewModel.updateFavoriteState() @@ -118,8 +128,8 @@ struct PortraitHeaderOverlayView: View { } } .disabled(viewModel.isLoading) - }.padding(.top, 8) - } + } + }.padding(.top, 8) } .padding(.horizontal, 16) .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) diff --git a/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift b/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift new file mode 100644 index 00000000..a6a79a28 --- /dev/null +++ b/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift @@ -0,0 +1,76 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI +import JellyfinAPI + +struct ItemPortraitMainView: View { + + @Binding private var videoIsLoading: Bool + @EnvironmentObject private var viewModel: ItemViewModel + @EnvironmentObject private var videoPlayerItem: VideoPlayerItem + + init(videoIsLoading: Binding) { + self._videoIsLoading = videoIsLoading + } + + // MARK: portraitHeaderView + var portraitHeaderView: some View { + ImageView(src: viewModel.item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), + bh: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.4) + .blur(radius: 2.0) + } + + // MARK: portraitStaticOverlayView + var portraitStaticOverlayView: some View { + PortraitHeaderOverlayView() + .environmentObject(viewModel) + } + + // MARK: body + var body: some View { + VStack(alignment: .leading) { + NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) { + VLCPlayerWithControls(item: videoPlayerItem.itemToPlay, + loadBinding: $videoIsLoading, + pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .statusBar(hidden: true) + .edgesIgnoringSafeArea(.all) + .prefersHomeIndicatorAutoHidden(true) + }, isActive: $videoPlayerItem.shouldShowPlayer) { + EmptyView() + } + + // MARK: ParallaxScrollView + ParallaxHeaderScrollView(header: portraitHeaderView, + staticOverlayView: portraitStaticOverlayView, + overlayAlignment: .bottomLeading, + headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) { + + VStack { + Spacer() + .frame(height: 70) + + if let episodeViewModel = viewModel as? SeasonItemViewModel { + Spacer() + CardVStackView(items: episodeViewModel.episodes) + .padding(.top, 5) + .frame(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 400 : .infinity) + } else { + ItemViewBody() + .environmentObject(viewModel) + } + } + } + } + } +} diff --git a/JellyfinPlayer/JellyfinPlayerApp.swift b/JellyfinPlayer/JellyfinPlayerApp.swift index fd5bcf3e..3a08efc1 100644 --- a/JellyfinPlayer/JellyfinPlayerApp.swift +++ b/JellyfinPlayer/JellyfinPlayerApp.swift @@ -233,8 +233,7 @@ struct JellyfinPlayerApp: App { } private func setupAppearance() { - guard let storedAppearance = AppAppearance(rawValue: appAppearance) else { return } - UIApplication.shared.windows.first?.overrideUserInterfaceStyle = storedAppearance.style + UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style } } diff --git a/JellyfinPlayer/MovieItemView.swift b/JellyfinPlayer/MovieItemView.swift deleted file mode 100644 index 8dfad20d..00000000 --- a/JellyfinPlayer/MovieItemView.swift +++ /dev/null @@ -1,430 +0,0 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI - -struct MovieItemView: View { - @StateObject var viewModel: MovieItemViewModel - @State private var orientation = UIDeviceOrientation.unknown - @Environment(\.horizontalSizeClass) var hSizeClass - @Environment(\.verticalSizeClass) var vSizeClass - @EnvironmentObject - private var playbackInfo: VideoPlayerItem - - var portraitHeaderView: some View { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - } - - var portraitHeaderOverlayView: some View { - VStack(alignment: .leading) { - HStack(alignment: .bottom, spacing: 12) { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120)) - .frame(width: 120, height: 180) - .cornerRadius(10) - VStack(alignment: .leading) { - Spacer() - Text(viewModel.item.name ?? "").font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - .offset(y: 5) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - Text(viewModel.item.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } - .padding(.top, 1) - } - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) - } - HStack { - // Play button - Button { - self.playbackInfo.itemToPlay = viewModel.item - self.playbackInfo.shouldShowPlayer = true - } label: { - HStack { - Text(viewModel.item.getItemProgressString() == "" ? "Play" : viewModel.item.getItemProgressString()) - .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) - Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) - } - .frame(width: 120, height: 35) - .background(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) - .cornerRadius(10) - }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 35) - Spacer() - HStack { - Button { - viewModel.updateFavoriteState() - } label: { - if viewModel.isFavorited { - Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) - .font(.system(size: 20)) - } else { - Image(systemName: "heart").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - Button { - viewModel.updateWatchState() - } label: { - if viewModel.isWatched { - Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) - .font(.system(size: 20)) - } else { - Image(systemName: "checkmark.circle").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - } - }.padding(.top, 8) - } - .padding(.horizontal, 16) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) - } - - var body: some View { - VStack(alignment: .leading) { - if hSizeClass == .compact && vSizeClass == .regular { - ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitHeaderOverlayView, - overlayAlignment: .bottomLeading, - headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds - .width * 0.5625) { - VStack(alignment: .leading) { - Spacer() - .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24) - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 7) - .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) - .padding(.trailing, 16) - } - Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) - .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) - .padding(.trailing, 16) - if !(viewModel.item.genreItems ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.genreItems!, id: \.id) { genre in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(genre: genre), title: genre.name ?? "") - }) { - Text(genre.name ?? "").font(.footnote) - } - } - }.padding(.leading, 16).padding(.trailing, 16) - } - } - if !(viewModel.item.people ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - VStack { - Spacer().frame(height: 8) - HStack { - Spacer().frame(width: 16) - ForEach(viewModel.item.people!, id: \.self) { person in - if person.type ?? "" == "Actor" { - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(person: person), title: person.name ?? "") - }) { - VStack { - ImageView(src: person - .getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), - bh: person.getBlurHash()) - .frame(width: 100, height: 100) - .cornerRadius(10) - Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1) - .frame(width: 100).foregroundColor(Color.primary) - if person.role != nil { - Text(person.role!).font(.caption).fontWeight(.medium).lineLimit(1) - .foregroundColor(Color.secondary).frame(width: 100) - } - } - } - Spacer().frame(width: 10) - } - } - Spacer().frame(width: 16) - } - } - }.padding(.top, -3) - } - if !(viewModel.item.studios ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.studios!, id: \.id) { studio in - NavigationLink(destination: LazyView { - - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - }.padding(.leading, 16).padding(.trailing, 16) - } - } - if !(viewModel.similarItems).isEmpty { - Text("More Like This") - .font(.callout).fontWeight(.semibold).padding(.top, 3).padding(.leading, 16) - ScrollView(.horizontal, showsIndicators: false) { - VStack { - Spacer().frame(height: 8) - HStack { - Spacer().frame(width: 16) - ForEach(viewModel.similarItems, id: \.self) { similarItem in - NavigationLink(destination: LazyView { ItemView(item: similarItem) }) { - PortraitItemView(item: similarItem) - } - Spacer().frame(width: 10) - } - Spacer().frame(width: 16) - } - } - }.padding(.top, -5) - } - Spacer().frame(height: 16) - } - } - } else { - GeometryReader { geometry in - ZStack { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.3) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, - height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) - .edgesIgnoringSafeArea(.all) - .blur(radius: 4) - HStack { - VStack { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), - bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 120, height: 180) - .cornerRadius(10) - Spacer().frame(height: 15) - Button { - self.playbackInfo.itemToPlay = viewModel.item - self.playbackInfo.shouldShowPlayer = true - } label: { - HStack { - Text(viewModel.item.getItemProgressString() == "" ? "Play" : "\(viewModel.item.getItemProgressString()) left") - .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) - Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) - } - .frame(width: 120, height: 35) - .background(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) - .cornerRadius(10) - }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 35) - Spacer() - } - ScrollView { - VStack(alignment: .leading) { - HStack { - VStack(alignment: .leading) { - Text(viewModel.item.name ?? "").font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .offset(x: 14, y: 0) - Spacer().frame(height: 1) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - Text(viewModel.item.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - if viewModel.item.communityRating != nil { - HStack { - Image(systemName: "star").foregroundColor(.secondary) - Text(String(viewModel.item.communityRating!)).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .offset(x: -7, y: 0.7) - } - } - Spacer() - }.frame(maxWidth: .infinity, alignment: .leading) - .offset(x: 14) - .padding(.top, 1) - }.frame(maxWidth: .infinity, alignment: .leading) - Spacer() - HStack { - Button { - viewModel.updateFavoriteState() - } label: { - if viewModel.isFavorited { - Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) - .font(.system(size: 20)) - } else { - Image(systemName: "heart").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - Button { - viewModel.updateWatchState() - } label: { - if viewModel.isWatched { - Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) - .font(.system(size: 20)) - } else { - Image(systemName: "checkmark.circle").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - } - }.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 3) - .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) - .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if !(viewModel.item.genreItems ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.genreItems!, id: \.id) { genre in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(genre: genre), title: genre.name ?? "") - }) { - Text(genre.name ?? "").font(.footnote) - } - } - } - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - } - if !(viewModel.item.people ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - VStack { - Spacer().frame(height: 8) - HStack { - Spacer().frame(width: 16) - ForEach(viewModel.item.people!, id: \.self) { person in - if person.type! == "Actor" { - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(person: person), title: person.name ?? "") - }) { - VStack { - ImageView(src: person - .getImage(baseURL: ServerEnvironment.current.server.baseURI!, - maxWidth: 100), - bh: person.getBlurHash()) - .frame(width: 100, height: 100) - .cornerRadius(10) - Text(person.name ?? "").font(.footnote).fontWeight(.regular) - .lineLimit(1) - .frame(width: 100).foregroundColor(Color.primary) - if person.role != "" { - Text(person.role!).font(.caption).fontWeight(.medium).lineLimit(1) - .foregroundColor(Color.secondary).frame(width: 100) - } - } - } - Spacer().frame(width: 10) - } - } - Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - } - }.padding(.top, -3) - } - if !(viewModel.item.studios ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.studios!, id: \.id) { studio in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - } - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - } - if !(viewModel.similarItems).isEmpty { - Text("More Like This") - .font(.callout).fontWeight(.semibold).padding(.top, 3).padding(.leading, 16) - ScrollView(.horizontal, showsIndicators: false) { - VStack { - Spacer().frame(height: 8) - HStack { - Spacer().frame(width: 16) - ForEach(viewModel.similarItems, id: \.self) { similarItem in - NavigationLink(destination: LazyView { ItemView(item: similarItem) }) { - PortraitItemView(item: similarItem) - } - Spacer().frame(width: 10) - } - Spacer().frame(width: 16) - } - } - }.padding(.top, -5) - } - Spacer().frame(height: 105) - }.frame(maxHeight: .infinity) - } - }.padding(.top, 16).padding(.leading, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - .edgesIgnoringSafeArea(.leading) - } - } - } - } - .onRotate { - orientation = $0 - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle(viewModel.item.name ?? "") - } -} diff --git a/JellyfinPlayer/SeasonItemView.swift b/JellyfinPlayer/SeasonItemView.swift deleted file mode 100644 index 6d3758be..00000000 --- a/JellyfinPlayer/SeasonItemView.swift +++ /dev/null @@ -1,257 +0,0 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI - -struct SeasonItemView: View { - @StateObject var viewModel: SeasonItemViewModel - @State private var orientation = UIDeviceOrientation.unknown - @Environment(\.horizontalSizeClass) var hSizeClass - @Environment(\.verticalSizeClass) var vSizeClass - - @ViewBuilder - var portraitHeaderView: some View { - if viewModel.isLoading { - EmptyView() - } else { - ImageView(src: viewModel.item.getSeriesBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: viewModel.item.getSeriesBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - } - } - - var portraitHeaderOverlayView: some View { - HStack(alignment: .bottom, spacing: 12) { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 120, height: 180) - .cornerRadius(10) - VStack(alignment: .leading) { - Text(viewModel.item.name ?? "").font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .offset(y: -4) - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - }.offset(y: -32) - }.padding(.horizontal, 16) - .offset(y: 22) - } - - @ViewBuilder - var innerBody: some View { - if hSizeClass == .compact && vSizeClass == .regular { - ParallaxHeaderScrollView(header: portraitHeaderView, - staticOverlayView: portraitHeaderOverlayView, - overlayAlignment: .bottomLeading, - headerHeight: UIScreen.main.bounds.width * 0.5625) { - LazyVStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 7) - .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) - .padding(.trailing, 16) - } - Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) - .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) - .padding(.trailing, 16) - ForEach(viewModel.episodes, id: \.id) { episode in - NavigationLink(destination: ItemView(item: episode)) { - HStack { - ImageView(src: episode.getPrimaryImage(maxWidth: 150), bh: episode.getPrimaryImageBlurHash()) - .shadow(radius: 5) - .frame(width: 150, height: 90) - .cornerRadius(10) - .overlay( - Rectangle() - .fill(Color(red: 172/255, green: 92/255, blue: 195/255)) - .mask(ProgressBar()) - .frame(width: CGFloat(episode.userData?.playedPercentage ?? 0 * 1.5), height: 7) - .padding(0), alignment: .bottomLeading - ) - .overlay( - ZStack { - if episode.userData?.isFavorite ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - .opacity(0.6) - Image(systemName: "heart.fill") - .foregroundColor(Color(.systemRed)) - .font(.system(size: 10)) - } - } - .padding(.leading, 2) - .padding(.bottom, episode.userData?.playedPercentage == nil ? 2 : 9) - .opacity(1), alignment: .bottomLeading) - .overlay( - ZStack { - if episode.userData?.played ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color(.systemBlue)) - } - }.padding(2) - .opacity(1), alignment: .topTrailing).opacity(1) - VStack(alignment: .leading) { - HStack { - Text(episode.getEpisodeLocator()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - Spacer() - Text(episode.name ?? "").font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(1) - Spacer() - Text(episode.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - Spacer() - Text(episode.overview ?? "").font(.footnote).foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true).lineLimit(4) - Spacer() - }.padding(.trailing, 20).offset(y: 2) - }.offset(x: 12, y: 0) - } - } - if !(viewModel.item.studios ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.studios!, id: \.id) { studio in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - }.padding(.leading, 16).padding(.trailing, 16) - } - } - Spacer().frame(height: 10) - } - .padding(.leading, 2) - .padding(.top, 20) - } - } else { - GeometryReader { geometry in - ZStack { - ImageView(src: viewModel.item.getSeriesBackdropImage(maxWidth: 200), bh: viewModel.item.getSeriesBackdropImageBlurHash()) - .opacity(0.4) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, - height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) - .edgesIgnoringSafeArea(.all) - .blur(radius: 4) - HStack { - VStack(alignment: .leading) { - Spacer().frame(height: 16) - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 120, height: 180) - .cornerRadius(10) - Spacer().frame(height: 4) - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - } - Spacer() - } - ScrollView { - Spacer().frame(height: 16) - LazyVStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 7) - .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) - .padding(.trailing, 16) - } - Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) - .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) - .padding(.trailing, 16) - ForEach(viewModel.episodes, id: \.id) { episode in - NavigationLink(destination: ItemView(item: episode)) { - HStack { - ImageView(src: episode.getPrimaryImage(maxWidth: 150), bh: episode.getPrimaryImageBlurHash()) - .shadow(radius: 5) - .frame(width: 150, height: 90) - .cornerRadius(10) - .overlay( - Rectangle() - .fill(Color(red: 172/255, green: 92/255, blue: 195/255)) - .mask(ProgressBar()) - .frame(width: CGFloat(episode.userData!.playedPercentage ?? 0 * 1.5), height: 7) - .padding(0), alignment: .bottomLeading - ) - VStack(alignment: .leading) { - HStack { - Text("S\(String(episode.parentIndexNumber ?? 0)):E\(String(episode.indexNumber ?? 0))").font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - Spacer() - Text(episode.name ?? "").font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(1) - Spacer() - Text(episode.getItemRuntime()).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - Spacer() - Text(episode.overview ?? "").font(.footnote).foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true).lineLimit(4) - Spacer() - }.padding(.trailing, 20).offset(y: 2) - }.offset(x: 12, y: 0) - } - } - if !(viewModel.item.studios ?? []).isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(viewModel.item.studios!, id: \.id) { studio in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - }.padding(.leading, 16).padding(.trailing, 16) - } - } - Spacer().frame(height: 95) - }.frame(maxHeight: .infinity) - }.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - }.padding(.leading, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 0) - } - } - } - } - - var body: some View { - if viewModel.isLoading { - ProgressView() - } else { - innerBody - .onRotate { - orientation = $0 - } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("\(viewModel.item.name ?? "") - \(viewModel.item.seriesName ?? "")") - } - } -} diff --git a/JellyfinPlayer/SeriesItemView.swift b/JellyfinPlayer/SeriesItemView.swift deleted file mode 100644 index 325bd684..00000000 --- a/JellyfinPlayer/SeriesItemView.swift +++ /dev/null @@ -1,243 +0,0 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI - -struct SeriesItemView: View { - @StateObject var viewModel: SeriesItemViewModel - @State private var orientation = UIDeviceOrientation.unknown - @Environment(\.horizontalSizeClass) var hSizeClass - @Environment(\.verticalSizeClass) var vSizeClass - - @State private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - - @ViewBuilder - var portraitHeaderView: some View { - ImageView(src: viewModel.item - .getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - } - - var portraitHeaderOverlayView: some View { - HStack(alignment: .bottom, spacing: 12) { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 120, height: 180) - .cornerRadius(10) - VStack(alignment: .leading) { - Text(viewModel.item.name ?? "").font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .offset(y: -4) - HStack { - Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - if let officialRating = viewModel.item.officialRating { - Text(officialRating).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } - }.offset(y: -32) - }.padding(.horizontal, 16) - .offset(y: 22) - } - - func recalcTracks() { - tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - } - - var innerBody: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - if let firstTagline = viewModel.item.taglines?.first { - Text(firstTagline).font(.body).italic() - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 8) - .padding(.horizontal, 16) - } - if let genreItems = viewModel.item.genreItems, - !genreItems.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: 8) { - Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(genreItems, id: \.id) { genre in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(genre: genre), title: genre.name ?? "") - }) { - Text(genre.name ?? "").font(.footnote) - } - } - } - .padding(.horizontal, 16) - } - .padding(.bottom, 8) - } - Text(viewModel.item.overview ?? "") - .font(.footnote) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 16) - .padding(.horizontal, 16) - Text("Seasons") - .font(.callout).fontWeight(.semibold) - .padding(.horizontal, 16) - } - .padding(.top, 24) - LazyVGrid(columns: tracks) { - ForEach(viewModel.seasons, id: \.id) { season in - PortraitItemView(item: season) - } - } - .padding(.bottom, 16) - .padding(.horizontal, 8) - LazyVStack(alignment: .leading, spacing: 0) { - if let people = viewModel.item.people, - !people.isEmpty { - Text("CAST") - .font(.callout).fontWeight(.semibold) - .padding(.bottom, 8) - .padding(.horizontal, 16) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: 16) { - ForEach(people, id: \.self) { person in - if person.type == "Actor" { - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(person: person), title: person.name ?? "") - }) { - VStack { - ImageView(src: person - .getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), - bh: person.getBlurHash()) - .frame(width: 100, height: 100) - .cornerRadius(10) - Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1) - .frame(width: 100).foregroundColor(Color.primary) - if let role = person.role, - !role.isEmpty { - Text(role).font(.caption).fontWeight(.medium).lineLimit(1) - .foregroundColor(Color.secondary).frame(width: 100) - } - } - } - } - } - } - .padding(.horizontal, 16) - } - .padding(.bottom, 16) - } - if let studios = viewModel.item.studios, - !studios.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: 16) { - Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(studios, id: \.id) { studio in - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") - }) { - Text(studio.name ?? "").font(.footnote) - } - } - } - .padding(.horizontal, 16) - } - .padding(.bottom, 16) - } - if !viewModel.similarItems.isEmpty { - Text("More Like This") - .font(.callout).fontWeight(.semibold) - .padding(.bottom, 8) - .padding(.horizontal, 16) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(spacing: 16) { - ForEach(viewModel.similarItems, id: \.self) { similarItem in - NavigationLink(destination: LazyView { ItemView(item: similarItem) }) { - PortraitItemView(item: similarItem) - } - } - } - .padding(.horizontal, 16) - } - .padding(.bottom, 16) - } - } - } - } - - var landscapeView: some View { - GeometryReader { geometry in - ZStack { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, - height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) - .edgesIgnoringSafeArea(.all) - .blur(radius: 4) - HStack(alignment: .top, spacing: 16) { - VStack(alignment: .leading) { - ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), - bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 120, height: 180) - .cornerRadius(10) - HStack { - Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - if let officialRating = viewModel.item.officialRating { - Text(officialRating).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } - } - .padding([.top, .leading], 16) - innerBody - } - } - } - } - - var body: some View { - if viewModel.isLoading { - ProgressView() - } else { - Group { - if hSizeClass == .compact && vSizeClass == .regular { - ParallaxHeaderScrollView(header: portraitHeaderView, - staticOverlayView: portraitHeaderOverlayView, - overlayAlignment: .bottomLeading, - headerHeight: UIScreen.main.bounds.width * 0.5625) { - innerBody - } - } else { - landscapeView - } - } - .onRotate { - orientation = $0 - recalcTracks() - } - .overrideViewPreference(.unspecified) - .navigationTitle(viewModel.item.name ?? "") - .navigationBarTitleDisplayMode(.inline) - } - } -} diff --git a/JellyfinPlayer/SettingsView.swift b/JellyfinPlayer/SettingsView.swift index 3189790c..47c98512 100644 --- a/JellyfinPlayer/SettingsView.swift +++ b/JellyfinPlayer/SettingsView.swift @@ -107,8 +107,7 @@ struct SettingsView: View { Text(appearance.localizedName).tag(appearance.rawValue) } }.onChange(of: appAppearance, perform: { value in - guard let appearance = AppAppearance(rawValue: value) else { return } - UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appearance.style + UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style }) } } diff --git a/Shared/Extensions/DefaultsExtension.swift b/Shared/Extensions/DefaultsExtension.swift index a3c30e46..30cb48c9 100644 --- a/Shared/Extensions/DefaultsExtension.swift +++ b/Shared/Extensions/DefaultsExtension.swift @@ -16,7 +16,7 @@ extension Defaults.Keys { static let isAutoSelectSubtitles = Key("isAutoSelectSubtitles", default: false) static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", default: "Auto") static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto") - static let appAppearance = Key("appAppearance", default: AppAppearance.system.rawValue) + static let appAppearance = Key("appAppearance", default: .system) static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .thirty) static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .thirty) } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift new file mode 100644 index 00000000..6ca62ffd --- /dev/null +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift @@ -0,0 +1,44 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import JellyfinAPI + +// MARK: PortraitImageStackable +extension BaseItemDto: PortraitImageStackable { + public func imageURLContsructor(maxWidth: Int) -> URL { + return self.getPrimaryImage(maxWidth: maxWidth) + } + + public var title: String { + return self.name ?? "" + } + + public var description: String? { + switch self.itemType { + case .season: + guard let productionYear = productionYear else { return nil } + return "\(productionYear)" + case .episode: + return getEpisodeLocator() + default: + return nil + } + } + + public var blurHash: String { + return self.getPrimaryImageBlurHash() + } + + public var failureInitials: String { + guard let name = self.name else { return "" } + let initials = name.split(separator: " ").compactMap({ String($0).first }) + return String(initials) + } +} diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 44dd8ab0..83ea48eb 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -78,11 +78,11 @@ public extension BaseItemDto { return URL(string: urlString)! } - func getEpisodeLocator() -> String { + func getEpisodeLocator() -> String? { if let seasonNo = parentIndexNumber, let episodeNo = indexNumber { return "S\(seasonNo):E\(episodeNo)" } - return "" + return nil } func getSeriesBackdropImage(maxWidth: Int) -> URL { @@ -162,6 +162,15 @@ public extension BaseItemDto { case series = "Series" case unknown + + var showDetails: Bool { + switch self { + case .season, .series: + return false + default: + return true + } + } } var itemType: ItemType { diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift index ff1cf99d..a3a9c042 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift @@ -10,6 +10,8 @@ import JellyfinAPI import UIKit extension BaseItemPerson { + + // MARK: Get Image func getImage(baseURL: String, maxWidth: Int) -> URL { let imageType = "Primary" let imageTag = primaryImageTag ?? "" @@ -26,8 +28,33 @@ extension BaseItemPerson { return imageBlurHashes?.primary?[imgTag] ?? "001fC^" } + + + // MARK: First Role + + // Jellyfin will grab all roles the person played in the show which makes the role + // text too long. This will grab the first role which: + // - assumes that the most important role is the first + // - will also grab the last "()" instance, like "(voice)" + func firstRole() -> String? { + guard let role = self.role else { return nil } + let split = role.split(separator: "/") + guard split.count > 1 else { return role } + + guard let firstRole = split.first?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), let lastRole = split.last?.trimmingCharacters(in: CharacterSet(charactersIn: " ")) else { return role } + + var final = firstRole + + if let lastOpenIndex = lastRole.lastIndex(of: "("), let lastClosingIndex = lastRole.lastIndex(of: ")") { + let roleText = lastRole[lastOpenIndex...lastClosingIndex] + final.append(" \(roleText)") + } + + return final + } } +// MARK: PortraitImageStackable extension BaseItemPerson: PortraitImageStackable { public func imageURLContsructor(maxWidth: Int) -> URL { return self.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: maxWidth) @@ -38,10 +65,33 @@ extension BaseItemPerson: PortraitImageStackable { } public var description: String? { - return self.role + return self.firstRole() } public var blurHash: String { return self.getBlurHash() } + + public var failureInitials: String { + guard let name = self.name else { return "" } + let initials = name.split(separator: " ").compactMap({ String($0).first }) + return String(initials) + } +} + +// MARK: DiplayedType +extension BaseItemPerson { + + // Only displayed person types. + // Will ignore people like "GuestStar" + enum DisplayedType: String, CaseIterable { + case actor = "Actor" + case director = "Director" + case writer = "Writer" + case producer = "Producer" + + static var allCasesRaw: [String] { + return self.allCases.map({ $0.rawValue }) + } + } } diff --git a/Shared/ViewModels/EpisodeItemViewModel.swift b/Shared/ViewModels/EpisodeItemViewModel.swift index b43b99ef..86e3d8f8 100644 --- a/Shared/ViewModels/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/EpisodeItemViewModel.swift @@ -11,5 +11,14 @@ import Combine import Foundation import JellyfinAPI -final class EpisodeItemViewModel: DetailItemViewModel { +final class EpisodeItemViewModel: ItemViewModel { + + override func getItemDisplayName() -> String { + guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" } + return "\(episodeLocator)\n\(item.name ?? "")" + } + + override func shouldDisplayRuntime() -> Bool { + return false + } } diff --git a/Shared/ViewModels/DetailItemViewModel.swift b/Shared/ViewModels/ItemViewModel.swift similarity index 85% rename from Shared/ViewModels/DetailItemViewModel.swift rename to Shared/ViewModels/ItemViewModel.swift index d6a03f90..49ba6707 100644 --- a/Shared/ViewModels/DetailItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel.swift @@ -14,21 +14,40 @@ import JellyfinAPI class ItemViewModel: ViewModel { @Published var item: BaseItemDto + @Published var playButtonItem: BaseItemDto? @Published var similarItems: [BaseItemDto] = [] - @Published var isWatched = false @Published var isFavorited = false init(item: BaseItemDto) { self.item = item + + switch item.itemType { + case .episode, .movie: + self.playButtonItem = item + default: () + } + isFavorited = item.userData?.isFavorite ?? false isWatched = item.userData?.played ?? false super.init() - getRelatedItems() + getSimilarItems() } - - func getRelatedItems() { + + func playButtonText() -> String { + return item.getItemProgressString() == "" ? "Play" : item.getItemProgressString() + } + + func getItemDisplayName() -> String { + return item.name ?? "" + } + + func shouldDisplayRuntime() -> Bool { + return true + } + + func getSimilarItems() { LibraryAPI.getSimilarItems(itemId: item.id!, userId: SessionManager.current.user.user_id!, limit: 20, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in @@ -83,7 +102,3 @@ class ItemViewModel: ViewModel { } } } - -class DetailItemViewModel: ItemViewModel { - -} diff --git a/Shared/ViewModels/MovieItemViewModel.swift b/Shared/ViewModels/MovieItemViewModel.swift index d37a9281..31ad3862 100644 --- a/Shared/ViewModels/MovieItemViewModel.swift +++ b/Shared/ViewModels/MovieItemViewModel.swift @@ -11,5 +11,5 @@ import Combine import Foundation import JellyfinAPI -final class MovieItemViewModel: DetailItemViewModel { +final class MovieItemViewModel: ItemViewModel { } diff --git a/Shared/ViewModels/SeasonItemViewModel.swift b/Shared/ViewModels/SeasonItemViewModel.swift index 60a742a6..e5e73ec8 100644 --- a/Shared/ViewModels/SeasonItemViewModel.swift +++ b/Shared/ViewModels/SeasonItemViewModel.swift @@ -11,9 +11,9 @@ import Combine import Foundation import JellyfinAPI -final class SeasonItemViewModel: DetailItemViewModel { +final class SeasonItemViewModel: ItemViewModel { - @Published var episodes = [BaseItemDto]() + @Published private(set) var episodes: [BaseItemDto] = [] override init(item: BaseItemDto) { super.init(item: item) @@ -21,8 +21,14 @@ final class SeasonItemViewModel: DetailItemViewModel { requestEpisodes() } + + override func playButtonText() -> String { + guard let playButtonItem = playButtonItem else { return "Play" } + guard let episodeLocator = playButtonItem.getEpisodeLocator() else { return "Play" } + return episodeLocator + } - func requestEpisodes() { + private func requestEpisodes() { LogManager.shared.log.debug("Getting episodes in season \(self.item.id!) (\(self.item.name!)) of show \(self.item.seriesId!) (\(self.item.seriesName!))") TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], @@ -33,7 +39,33 @@ final class SeasonItemViewModel: DetailItemViewModel { }, receiveValue: { [weak self] response in self?.episodes = response.items ?? [] LogManager.shared.log.debug("Retrieved \(String(self?.episodes.count ?? 0)) episodes") + + self?.setNextUpInSeason() }) .store(in: &cancellables) } + + // Sets the play button item to the "Next up" in the season based upon + // the watched status of episodes in the season. + // Default to the first episode of the season if all have been watched. + private func setNextUpInSeason() { + guard episodes.count > 0 else { return } + + var firstUnwatchedSearch: BaseItemDto? + + for episode in episodes { + guard let played = episode.userData?.played else { continue } + if !played { + firstUnwatchedSearch = episode + break + } + } + + if let firstUnwatched = firstUnwatchedSearch { + playButtonItem = firstUnwatched + } else { + guard let firstEpisode = episodes.first else { return } + playButtonItem = firstEpisode + } + } } diff --git a/Shared/ViewModels/SeriesItemViewModel.swift b/Shared/ViewModels/SeriesItemViewModel.swift index 24b42084..81a9e8e1 100644 --- a/Shared/ViewModels/SeriesItemViewModel.swift +++ b/Shared/ViewModels/SeriesItemViewModel.swift @@ -11,32 +11,43 @@ import Combine import Foundation import JellyfinAPI -final class SeriesItemViewModel: DetailItemViewModel { +final class SeriesItemViewModel: ItemViewModel { - @Published var seasons = [BaseItemDto]() - @Published var nextUpItem: BaseItemDto? + @Published var seasons: [BaseItemDto] = [] override init(item: BaseItemDto) { super.init(item: item) - self.item = item requestSeasons() getNextUp() } + + override func playButtonText() -> String { + guard let playButtonItem = playButtonItem else { return "Play" } + guard let episodeLocator = playButtonItem.getEpisodeLocator() else { return "Play" } + return episodeLocator + } + + override func shouldDisplayRuntime() -> Bool { + return false + } - func getNextUp() { + private func getNextUp() { + LogManager.shared.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))") TvShowsAPI.getNextUp(userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seriesId: self.item.id!, enableUserData: true) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in - self?.nextUpItem = response.items?.first ?? nil + if let nextUpItem = response.items?.first { + self?.playButtonItem = nextUpItem + } }) .store(in: &cancellables) } - func getRunYears() -> String { + private func getRunYears() -> String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy" @@ -54,7 +65,7 @@ final class SeriesItemViewModel: DetailItemViewModel { return "\(startYear ?? "Unknown") - \(endYear ?? "Present")" } - func requestSeasons() { + private func requestSeasons() { LogManager.shared.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))") TvShowsAPI.getSeasons(seriesId: item.id ?? "", userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true) .trackActivity(loading) diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index bdb0755d..86f6a56a 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +import Defaults struct UserSettings: Decodable { var LocalMaxBitrate: Int @@ -31,7 +32,7 @@ struct TrackLanguage: Hashable { static let auto = TrackLanguage(name: "Auto", isoCode: "Auto") } -enum AppAppearance: String, CaseIterable { +enum AppAppearance: String, CaseIterable, Defaults.Serializable { case system case dark case light diff --git a/Shared/Objects/Views/ImageView.swift b/Shared/Views/ImageView.swift similarity index 58% rename from Shared/Objects/Views/ImageView.swift rename to Shared/Views/ImageView.swift index e1e45141..8f41eedf 100644 --- a/Shared/Objects/Views/ImageView.swift +++ b/Shared/Views/ImageView.swift @@ -11,16 +11,14 @@ import SwiftUI import NukeUI struct ImageView: View { - private var source: URL = URL(string: "https://example.com")! - private var blurhash: String = "001fC^" + private let source: URL + private let blurhash: String + private let failureInitials: String - init(src: URL) { - self.source = src - } - - init(src: URL, bh: String) { + init(src: URL, bh: String = "001fC^", failureInitials: String = "") { self.source = src self.blurhash = bh + self.failureInitials = failureInitials } var body: some View { @@ -30,8 +28,14 @@ struct ImageView: View { .resizable() } .failure { - Rectangle() - .fill(Color.gray) + ZStack { + Rectangle() + .foregroundColor(Color(UIColor.systemFill)) + + Text(failureInitials) + .font(.largeTitle) + .foregroundColor(.secondary) + } } } } diff --git a/Shared/Objects/Views/LazyView.swift b/Shared/Views/LazyView.swift similarity index 100% rename from Shared/Objects/Views/LazyView.swift rename to Shared/Views/LazyView.swift diff --git a/Shared/Objects/Views/MultiSelectorView.swift b/Shared/Views/MultiSelectorView.swift similarity index 100% rename from Shared/Objects/Views/MultiSelectorView.swift rename to Shared/Views/MultiSelectorView.swift diff --git a/Shared/Objects/Views/ParallaxHeader.swift b/Shared/Views/ParallaxHeader.swift similarity index 100% rename from Shared/Objects/Views/ParallaxHeader.swift rename to Shared/Views/ParallaxHeader.swift diff --git a/Shared/Objects/Views/SearchBarView.swift b/Shared/Views/SearchBarView.swift similarity index 100% rename from Shared/Objects/Views/SearchBarView.swift rename to Shared/Views/SearchBarView.swift diff --git a/Shared/Objects/Views/SearchablePickerView.swift b/Shared/Views/SearchablePickerView.swift similarity index 100% rename from Shared/Objects/Views/SearchablePickerView.swift rename to Shared/Views/SearchablePickerView.swift