Merge branch 'main' into PangMo5/coordinator-and-deep-link
# Conflicts: # JellyfinPlayer.xcodeproj/project.pbxproj # JellyfinPlayer/Components/PortraitItemView.swift # JellyfinPlayer/ContinueWatchingView.swift # JellyfinPlayer/EpisodeItemView.swift # JellyfinPlayer/HomeView.swift # JellyfinPlayer/ItemView.swift # JellyfinPlayer/MovieItemView.swift # JellyfinPlayer/SeasonItemView.swift # JellyfinPlayer/SeriesItemView.swift # JellyfinPlayer/SettingsView.swift # Shared/ViewModels/ItemViewModel.swift
This commit is contained in:
commit
1863d973a9
|
@ -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 {
|
||||
|
|
|
@ -78,8 +78,8 @@
|
|||
53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; };
|
||||
53649AB3269D3F5B00A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; };
|
||||
53649AB5269D423A00A2D8B7 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = 53649AB4269D423A00A2D8B7 /* Puppy */; };
|
||||
5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; };
|
||||
5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; };
|
||||
5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */; };
|
||||
5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */; };
|
||||
536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D73267BA8170004248C /* BackgroundManager.swift */; };
|
||||
536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */; };
|
||||
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; };
|
||||
|
@ -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 */; };
|
||||
|
@ -202,7 +198,6 @@
|
|||
625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 625CB5792678C4A400530A6E /* ActivityIndicator */; };
|
||||
6260FFF926A09754003FA968 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = 6260FFF826A09754003FA968 /* CombineExt */; };
|
||||
6261A0E026A0AB710072EF1C /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = 6261A0DF26A0AB710072EF1C /* CombineExt */; };
|
||||
6267B3D42671024A00A7371D /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; };
|
||||
6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; };
|
||||
6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; };
|
||||
6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; };
|
||||
|
@ -245,8 +240,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 */; };
|
||||
|
@ -259,9 +254,31 @@
|
|||
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 /* 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 */; };
|
||||
E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; };
|
||||
E1AD105426D97161003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; };
|
||||
E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */; };
|
||||
E1AD105726D981CE003E4A08 /* PortraitHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */; };
|
||||
E1AD105926D9A543003E4A08 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338B22660A07800A81A2A /* LazyView.swift */; };
|
||||
E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */; };
|
||||
E1AD105D26D9ABDD003E4A08 /* PillHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */; };
|
||||
E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; };
|
||||
E1AD106026D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; };
|
||||
E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */; };
|
||||
E1AD106326D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */; };
|
||||
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; };
|
||||
E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; };
|
||||
E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; };
|
||||
|
@ -367,7 +384,7 @@
|
|||
5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; };
|
||||
5362E4C8267D40F7000E2F71 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||
53649AB0269CFB1900A2D8B7 /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = "<group>"; };
|
||||
5364F454266CA0DC0026ECBA /* APIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIExtensions.swift; sourceTree = "<group>"; };
|
||||
5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemPersonExtensions.swift; sourceTree = "<group>"; };
|
||||
536D3D73267BA8170004248C /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; };
|
||||
536D3D75267BA9BB0004248C /* MainTabViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabViewModel.swift; sourceTree = "<group>"; };
|
||||
536D3D7E267BDF100004248C /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = "<group>"; };
|
||||
|
@ -398,11 +415,7 @@
|
|||
53913BEB26D323FE00EB3286 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
53913BEE26D323FE00EB3286 /* kk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kk; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
5398514426B64DA100101B49 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
53987CA326572C1300E7EA70 /* SeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemView.swift; sourceTree = "<group>"; };
|
||||
53987CA526572F0700E7EA70 /* SeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = "<group>"; };
|
||||
53987CA72657424A00E7EA70 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = "<group>"; };
|
||||
539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
53A089CF264DA9DA00D57806 /* MovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemView.swift; sourceTree = "<group>"; };
|
||||
53A83C32268A309300DF3D92 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
||||
53ABFDDA267972BF00886593 /* JellyfinPlayer tvOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "JellyfinPlayer tvOS.entitlements"; sourceTree = "<group>"; };
|
||||
53ABFDDB267972BF00886593 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.0.sdk/System/Library/Frameworks/TVServices.framework; sourceTree = DEVELOPER_DIR; };
|
||||
|
@ -466,7 +479,7 @@
|
|||
62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemViewModel.swift; sourceTree = "<group>"; };
|
||||
62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemViewModel.swift; sourceTree = "<group>"; };
|
||||
62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterViewModel.swift; sourceTree = "<group>"; };
|
||||
62E632F2267D54030063E547 /* DetailItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItemViewModel.swift; sourceTree = "<group>"; };
|
||||
62E632F2267D54030063E547 /* ItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewModel.swift; sourceTree = "<group>"; };
|
||||
62EC352B26766675000E9F2D /* ServerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEnvironment.swift; sourceTree = "<group>"; };
|
||||
62EC352E267666A5000E9F2D /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = "<group>"; };
|
||||
62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = "<group>"; };
|
||||
|
@ -476,9 +489,21 @@
|
|||
DE5004F745B19E28744A7DE7 /* Pods-JellyfinPlayer tvOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.debug.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = "<group>"; };
|
||||
E131691626C583BC0074BFEE /* LogConstructor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogConstructor.swift; sourceTree = "<group>"; };
|
||||
E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitMainView.swift; sourceTree = "<group>"; };
|
||||
E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeMainView.swift; sourceTree = "<group>"; };
|
||||
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = "<group>"; };
|
||||
E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
|
||||
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = "<group>"; };
|
||||
E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Stackable.swift"; sourceTree = "<group>"; };
|
||||
E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewBody.swift; sourceTree = "<group>"; };
|
||||
E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = "<group>"; };
|
||||
E188460326DEF04800B0C5B7 /* CardVStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVStackView.swift; sourceTree = "<group>"; };
|
||||
E1AD104926D94822003E4A08 /* DetailItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItem.swift; sourceTree = "<group>"; };
|
||||
E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = "<group>"; };
|
||||
E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHStackView.swift; sourceTree = "<group>"; };
|
||||
E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillHStackView.swift; sourceTree = "<group>"; };
|
||||
E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGUIDPairExtensions.swift; sourceTree = "<group>"; };
|
||||
E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitHeaderOverlayView.swift; sourceTree = "<group>"; };
|
||||
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = "<group>"; };
|
||||
E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; };
|
||||
E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = "<group>"; };
|
||||
|
@ -569,7 +594,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */,
|
||||
62E632F2267D54030063E547 /* DetailItemViewModel.swift */,
|
||||
62E632F2267D54030063E547 /* ItemViewModel.swift */,
|
||||
62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */,
|
||||
625CB5722678C32A00530A6E /* HomeViewModel.swift */,
|
||||
62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */,
|
||||
|
@ -681,12 +706,13 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
E1FCD08E26C466F3007C8DCF /* Errors */,
|
||||
621338912660106C00A81A2A /* Extensions */,
|
||||
535870AB2669D8D300D05A09 /* Objects */,
|
||||
AE8C3157265D6F5E008AA076 /* Resources */,
|
||||
091B5A852683142E00D78B61 /* ServerLocator */,
|
||||
62EC352A26766657000E9F2D /* Singleton */,
|
||||
532175392671BCED005491E6 /* ViewModels */,
|
||||
621338912660106C00A81A2A /* Extensions */,
|
||||
AE8C3157265D6F5E008AA076 /* Resources */,
|
||||
535870AB2669D8D300D05A09 /* Objects */,
|
||||
E1AD105326D96F5A003E4A08 /* Views */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
|
@ -694,7 +720,10 @@
|
|||
535870AB2669D8D300D05A09 /* Objects */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */,
|
||||
53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */,
|
||||
535870AC2669D8DD00D05A09 /* Typings.swift */,
|
||||
E1AD104926D94822003E4A08 /* DetailItem.swift */,
|
||||
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */,
|
||||
);
|
||||
path = Objects;
|
||||
|
@ -746,9 +775,8 @@
|
|||
5377CBF8263B596B003A4E83 /* Assets.xcassets */,
|
||||
5338F74D263B61370014BF09 /* ConnectToServerView.swift */,
|
||||
5389276D263C25100035E14B /* ContinueWatchingView.swift */,
|
||||
53987CA72657424A00E7EA70 /* EpisodeItemView.swift */,
|
||||
5377CC02263B596B003A4E83 /* Info.plist */,
|
||||
535BAE9E2649E569005FA86D /* ItemView.swift */,
|
||||
E14F7D0A26DB3714007C3AE6 /* ItemView */,
|
||||
5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */,
|
||||
53FF7F29263CF3F500585C35 /* LatestMediaView.swift */,
|
||||
53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */,
|
||||
|
@ -756,12 +784,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 */,
|
||||
|
@ -934,7 +959,10 @@
|
|||
53F866422687A45400DCD1D7 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */,
|
||||
E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */,
|
||||
53F866432687A45F00DCD1D7 /* PortraitItemView.swift */,
|
||||
E188460326DEF04800B0C5B7 /* CardVStackView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
|
@ -942,20 +970,13 @@
|
|||
621338912660106C00A81A2A /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
53DE4BD1267098F300739748 /* SearchBarView.swift */,
|
||||
E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */,
|
||||
53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */,
|
||||
531AC8BE26750DE20091C7EB /* ImageView.swift */,
|
||||
5364F454266CA0DC0026ECBA /* APIExtensions.swift */,
|
||||
5389277B263CC3DB0035E14B /* BlurHashDecode.swift */,
|
||||
621338B22660A07800A81A2A /* LazyView.swift */,
|
||||
53E4E648263F725B00F67C6B /* MultiSelectorView.swift */,
|
||||
6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */,
|
||||
621338922660107500A81A2A /* StringExtensions.swift */,
|
||||
6267B3D526710B8900A7371D /* CollectionExtensions.swift */,
|
||||
6267B3D92671138200A7371D /* ImageExtensions.swift */,
|
||||
62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */,
|
||||
E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */,
|
||||
62CB3F4A2685BB77003D0A6F /* DefaultsExtension.swift */,
|
||||
6267B3D92671138200A7371D /* ImageExtensions.swift */,
|
||||
E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */,
|
||||
621338922660107500A81A2A /* StringExtensions.swift */,
|
||||
624C21742685CF60007F1390 /* SearchablePickerView.swift */,
|
||||
6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */,
|
||||
);
|
||||
|
@ -995,10 +1016,10 @@
|
|||
62EC352A26766657000E9F2D /* Singleton */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
62EC352B26766675000E9F2D /* ServerEnvironment.swift */,
|
||||
62EC352E267666A5000E9F2D /* SessionManager.swift */,
|
||||
536D3D73267BA8170004248C /* BackgroundManager.swift */,
|
||||
53649AB0269CFB1900A2D8B7 /* LogManager.swift */,
|
||||
62EC352B26766675000E9F2D /* ServerEnvironment.swift */,
|
||||
62EC352E267666A5000E9F2D /* SessionManager.swift */,
|
||||
6220D0CB26D640C400B8E046 /* AppURLHandler.swift */,
|
||||
);
|
||||
path = Singleton;
|
||||
|
@ -1024,12 +1045,65 @@
|
|||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E14F7D0A26DB3714007C3AE6 /* ItemView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
535BAE9E2649E569005FA86D /* ItemView.swift */,
|
||||
E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */,
|
||||
E18845FB26DEACC400B0C5B7 /* Landscape */,
|
||||
E18845FA26DEACBE00B0C5B7 /* Portrait */,
|
||||
);
|
||||
path = ItemView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E18845FA26DEACBE00B0C5B7 /* Portrait */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */,
|
||||
E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */,
|
||||
);
|
||||
path = Portrait;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E18845FB26DEACC400B0C5B7 /* Landscape */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */,
|
||||
E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */,
|
||||
);
|
||||
path = Landscape;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */,
|
||||
E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */,
|
||||
E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */,
|
||||
E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */,
|
||||
);
|
||||
path = JellyfinAPIExtensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1AD105326D96F5A003E4A08 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
531AC8BE26750DE20091C7EB /* ImageView.swift */,
|
||||
621338B22660A07800A81A2A /* LazyView.swift */,
|
||||
53E4E648263F725B00F67C6B /* MultiSelectorView.swift */,
|
||||
6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */,
|
||||
624C21742685CF60007F1390 /* SearchablePickerView.swift */,
|
||||
53DE4BD1267098F300739748 /* SearchBarView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1FCD08E26C466F3007C8DCF /* Errors */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E1FCD08726C35A0D007C8DCF /* NetworkError.swift */,
|
||||
E131691626C583BC0074BFEE /* LogConstructor.swift */,
|
||||
E1FCD09526C47118007C8DCF /* ErrorMessage.swift */,
|
||||
E131691626C583BC0074BFEE /* LogConstructor.swift */,
|
||||
E1FCD08726C35A0D007C8DCF /* NetworkError.swift */,
|
||||
);
|
||||
path = Errors;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1385,6 +1459,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 */,
|
||||
|
@ -1406,6 +1481,7 @@
|
|||
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */,
|
||||
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */,
|
||||
62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */,
|
||||
E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */,
|
||||
53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */,
|
||||
531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */,
|
||||
62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */,
|
||||
|
@ -1416,18 +1492,21 @@
|
|||
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 */,
|
||||
E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */,
|
||||
535870A52669D8AE00D05A09 /* ParallaxHeader.swift in Sources */,
|
||||
53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */,
|
||||
531690F0267ABF72005D8AB9 /* NextUpView.swift in Sources */,
|
||||
535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */,
|
||||
E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */,
|
||||
62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */,
|
||||
536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */,
|
||||
531690E5267ABD5C005D8AB9 /* MainTabView.swift in Sources */,
|
||||
5310695B2684E7EE00CFFDBA /* AudioView.swift in Sources */,
|
||||
E1AD106026D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */,
|
||||
5398514726B64E4100101B49 /* SearchBarView.swift in Sources */,
|
||||
091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */,
|
||||
53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */,
|
||||
|
@ -1453,10 +1532,14 @@
|
|||
536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */,
|
||||
535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */,
|
||||
53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */,
|
||||
5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */,
|
||||
E1AD105D26D9ABDD003E4A08 /* PillHStackView.swift in Sources */,
|
||||
E188460526DEF04800B0C5B7 /* CardVStackView.swift in Sources */,
|
||||
5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */,
|
||||
6220D0BE26D60D6600B8E046 /* ItemViewModel.swift in Sources */,
|
||||
531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */,
|
||||
E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */,
|
||||
E1AD105726D981CE003E4A08 /* PortraitHStackView.swift in Sources */,
|
||||
535870A32669D89F00D05A09 /* Model.xcdatamodeld in Sources */,
|
||||
09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */,
|
||||
);
|
||||
|
@ -1466,6 +1549,8 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */,
|
||||
E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */,
|
||||
5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */,
|
||||
6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */,
|
||||
6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */,
|
||||
|
@ -1480,6 +1565,7 @@
|
|||
62C29E9F26D1016600C1D2E7 /* MainCoordinator.swift in Sources */,
|
||||
5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */,
|
||||
53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */,
|
||||
E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */,
|
||||
53AD124E26702B8A0094A276 /* SeasonItemView.swift in Sources */,
|
||||
62C29EA126D102A500C1D2E7 /* MainTabCoordinator.swift in Sources */,
|
||||
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */,
|
||||
|
@ -1489,14 +1575,16 @@
|
|||
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 */,
|
||||
62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */,
|
||||
0959A5FD2686D29800C7C9A9 /* VideoUpNextView.swift in Sources */,
|
||||
62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */,
|
||||
E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */,
|
||||
625CB56F2678C23300530A6E /* HomeView.swift in Sources */,
|
||||
E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */,
|
||||
53892770263C25230035E14B /* NextUpView.swift in Sources */,
|
||||
|
@ -1506,25 +1594,30 @@
|
|||
E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */,
|
||||
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */,
|
||||
532E68CF267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.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 /* ItemViewModel.swift in Sources */,
|
||||
6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */,
|
||||
62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */,
|
||||
53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */,
|
||||
E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */,
|
||||
E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */,
|
||||
53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */,
|
||||
6220D0C626D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */,
|
||||
621338B32660A07800A81A2A /* LazyView.swift in Sources */,
|
||||
6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */,
|
||||
62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */,
|
||||
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */,
|
||||
E1AD104A26D94822003E4A08 /* DetailItem.swift in Sources */,
|
||||
62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */,
|
||||
624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */,
|
||||
E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */,
|
||||
|
@ -1532,7 +1625,9 @@
|
|||
62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
|
||||
091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */,
|
||||
62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */,
|
||||
E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */,
|
||||
535870AD2669D8DD00D05A09 /* Typings.swift in Sources */,
|
||||
E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */,
|
||||
6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */,
|
||||
62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */,
|
||||
6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */,
|
||||
|
@ -1557,11 +1652,12 @@
|
|||
files = (
|
||||
53649AB3269D3F5B00A2D8B7 /* LogManager.swift in Sources */,
|
||||
62EC353126766848000E9F2D /* ServerEnvironment.swift in Sources */,
|
||||
6267B3D42671024A00A7371D /* APIExtensions.swift in Sources */,
|
||||
6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */,
|
||||
628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */,
|
||||
6267B3DB2671139400A7371D /* ImageExtensions.swift in Sources */,
|
||||
E1AD105926D9A543003E4A08 /* LazyView.swift in Sources */,
|
||||
628B95372670CB800091AF3B /* JellyfinWidget.swift in Sources */,
|
||||
E1AD105426D97161003E4A08 /* BaseItemDtoExtensions.swift in Sources */,
|
||||
6228B1C22670EB010067FD35 /* PersistenceController.swift in Sources */,
|
||||
E1FCD09A26C4F35A007C8DCF /* ErrorMessage.swift in Sources */,
|
||||
628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
protocol PillStackable {
|
||||
var title: String { get }
|
||||
}
|
||||
|
||||
struct PillHStackView<NavigationView: View, ItemType: PillStackable>: View {
|
||||
|
||||
let title: String
|
||||
let items: [ItemType]
|
||||
let navigationView: (ItemType) -> NavigationView
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.top, 3)
|
||||
.padding(.leading, 16)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(items, id: \.title) { item in
|
||||
NavigationLink(destination: LazyView {
|
||||
navigationView(item)
|
||||
}) {
|
||||
ZStack {
|
||||
Color(UIColor.systemFill)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.cornerRadius(10)
|
||||
|
||||
Text(item.title)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.fixedSize()
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 10)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public protocol PortraitImageStackable {
|
||||
func imageURLContsructor(maxWidth: Int) -> URL
|
||||
var title: String { get }
|
||||
var description: String? { get }
|
||||
var blurHash: String { get }
|
||||
var failureInitials: String { get }
|
||||
}
|
||||
|
||||
struct PortraitImageHStackView<TopBarView: View, NavigationView: View, ItemType: PortraitImageStackable>: View {
|
||||
|
||||
let items: [ItemType]
|
||||
let maxWidth: Int
|
||||
let horizontalAlignment: HorizontalAlignment
|
||||
let topBarView: () -> TopBarView
|
||||
let navigationView: (ItemType) -> NavigationView
|
||||
|
||||
init(items: [ItemType], maxWidth: Int, horizontalAlignment: HorizontalAlignment = .leading, topBarView: @escaping () -> TopBarView, navigationView: @escaping (ItemType) -> NavigationView) {
|
||||
self.items = items
|
||||
self.maxWidth = maxWidth
|
||||
self.horizontalAlignment = horizontalAlignment
|
||||
self.topBarView = topBarView
|
||||
self.navigationView = navigationView
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
topBarView()
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack {
|
||||
Spacer().frame(height: 8)
|
||||
HStack(alignment: .top) {
|
||||
|
||||
Spacer().frame(width: 16)
|
||||
|
||||
ForEach(items, id: \.title) { item in
|
||||
NavigationLink(
|
||||
destination: LazyView {
|
||||
navigationView(item)
|
||||
},
|
||||
label: {
|
||||
VStack {
|
||||
ImageView(src: item.imageURLContsructor(maxWidth: maxWidth),
|
||||
bh: item.blurHash,
|
||||
failureInitials: item.failureInitials)
|
||||
.frame(width: 100, height: CGFloat(maxWidth))
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: 4, y: 2)
|
||||
|
||||
Text(item.title)
|
||||
.font(.footnote)
|
||||
.fontWeight(.regular)
|
||||
.frame(width: 100)
|
||||
.foregroundColor(.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
|
||||
if let description = item.description {
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.frame(width: 100)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
|
||||
}
|
||||
}
|
||||
}.padding(.top, -3)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,86 +1,91 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
import JellyfinAPI
|
||||
|
||||
struct PortraitItemView: View {
|
||||
|
||||
var item: BaseItemDto
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
ImageView(src: item.type != "Episode" ? item.getPrimaryImage(maxWidth: 100) : item.getSeriesPrimaryImage(maxWidth: 100),
|
||||
bh: item.type != "Episode" ? item.getPrimaryImageBlurHash() : item.getSeriesPrimaryImageBlurHash())
|
||||
.frame(width: 100, height: 150)
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.overlay(Rectangle()
|
||||
.fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255))
|
||||
.mask(ProgressBar())
|
||||
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7)
|
||||
.padding(0), alignment: .bottomLeading)
|
||||
.overlay(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), alignment: .bottomLeading)
|
||||
.overlay(ZStack {
|
||||
if item.userData?.played ?? false {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.background(Color(.white))
|
||||
.clipShape(Circle().scale(0.8))
|
||||
} else {
|
||||
if item.userData?.unplayedItemCount != nil {
|
||||
Capsule()
|
||||
.fill(Color.accentColor)
|
||||
.frame(minWidth: 20, minHeight: 20, maxHeight: 20)
|
||||
Text(String(item.userData!.unplayedItemCount ?? 0))
|
||||
.foregroundColor(.white)
|
||||
.font(.caption2)
|
||||
.padding(2)
|
||||
NavigationLink(destination: LazyView { ItemNavigationView(item: item) }) {
|
||||
VStack(alignment: .leading) {
|
||||
ImageView(src: item.type != "Episode" ? item.getPrimaryImage(maxWidth: 100) : item.getSeriesPrimaryImage(maxWidth: 100), bh: item.type != "Episode" ? item.getPrimaryImageBlurHash() : item.getSeriesPrimaryImageBlurHash())
|
||||
.frame(width: 100, height: 150)
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.shadow(radius: 4, y: 2)
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(Color(red: 172/255, green: 92/255, blue: 195/255))
|
||||
.mask(ProgressBar())
|
||||
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7)
|
||||
.padding(0), alignment: .bottomLeading
|
||||
)
|
||||
.overlay(
|
||||
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(2)
|
||||
.fixedSize()
|
||||
.opacity(1), alignment: .topTrailing).opacity(1)
|
||||
Text(item.seriesName ?? item.name ?? "")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
if item.type == "Movie" || item.type == "Series" {
|
||||
Text("\(String(item.productionYear ?? 0)) • \(item.officialRating ?? "N/A")")
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.leading, 2)
|
||||
.padding(.bottom, item.userData?.playedPercentage == nil ? 2 : 9)
|
||||
.opacity(1), alignment: .bottomLeading)
|
||||
.overlay(
|
||||
ZStack {
|
||||
if item.userData?.played ?? false {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.background(Color(.white))
|
||||
.clipShape(Circle().scale(0.8))
|
||||
} else {
|
||||
if item.userData?.unplayedItemCount != nil {
|
||||
Capsule()
|
||||
.fill(Color.accentColor)
|
||||
.frame(minWidth: 20, minHeight: 20, maxHeight: 20)
|
||||
Text(String(item.userData!.unplayedItemCount ?? 0))
|
||||
.foregroundColor(.white)
|
||||
.font(.caption2)
|
||||
.padding(2)
|
||||
}
|
||||
}
|
||||
}.padding(2)
|
||||
.fixedSize()
|
||||
.opacity(1), alignment: .topTrailing).opacity(1)
|
||||
Text(item.seriesName ?? item.name ?? "")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
} else if item.type == "Season" {
|
||||
Text("\(item.name ?? "") • \(String(item.productionYear ?? 0))")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
} else {
|
||||
Text("S\(String(item.parentIndexNumber ?? 0)):E\(String(item.indexNumber ?? 0))")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}.frame(width: 100)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
if item.type == "Movie" || item.type == "Series" {
|
||||
Text("\(String(item.productionYear ?? 0)) • \(item.officialRating ?? "N/A")")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
} else if item.type == "Season" {
|
||||
Text("\(item.name ?? "") • \(String(item.productionYear ?? 0))")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
} else {
|
||||
Text("S\(String(item.parentIndexNumber ?? 0)):E\(String(item.indexNumber ?? 0))")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}.frame(width: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import SwiftUI
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
|
||||
struct ProgressBar: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
|
@ -32,17 +31,13 @@ struct ProgressBar: Shape {
|
|||
}
|
||||
|
||||
struct ContinueWatchingView: View {
|
||||
@EnvironmentObject var homeRouter: NavigationRouter<HomeCoordinator.Route>
|
||||
|
||||
var items: [BaseItemDto]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack {
|
||||
ForEach(items, id: \.id) { item in
|
||||
Button {
|
||||
homeRouter.route(to: .item(viewModel: .init(id: item.id!)))
|
||||
} label: {
|
||||
NavigationLink(destination: LazyView { ItemNavigationView(item: item) }) {
|
||||
VStack(alignment: .leading) {
|
||||
ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash())
|
||||
.frame(width: 320, height: 180)
|
||||
|
@ -63,7 +58,7 @@ struct ContinueWatchingView: View {
|
|||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
if item.type == "Episode" {
|
||||
Text("• \(item.getEpisodeLocator()) - \(item.name ?? "")")
|
||||
Text("• \(item.getEpisodeLocator() ?? "") - \(item.name ?? "")")
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
|
|
|
@ -1,431 +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 Stinsen
|
||||
import SwiftUI
|
||||
|
||||
struct EpisodeItemView: View {
|
||||
@EnvironmentObject var itemRouter: NavigationRouter<ItemCoordinator.Route>
|
||||
@StateObject var viewModel: EpisodeItemViewModel
|
||||
@State private var orientation = UIDeviceOrientation.unknown
|
||||
@Environment(\.horizontalSizeClass) var hSizeClass
|
||||
@Environment(\.verticalSizeClass) var vSizeClass
|
||||
@EnvironmentObject private var playbackInfo: VideoPlayerItem
|
||||
|
||||
var portraitHeaderView: some View {
|
||||
ImageView(src: viewModel.item
|
||||
.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)),
|
||||
bh: viewModel.item.getBackdropImageBlurHash())
|
||||
.opacity(0.4)
|
||||
.blur(radius: 2.0)
|
||||
}
|
||||
|
||||
var portraitHeaderOverlayView: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
ImageView(src: viewModel.item.getSeriesPrimaryImage(maxWidth: 120), bh: viewModel.item.getSeriesPrimaryImageBlurHash())
|
||||
.frame(width: 120, height: 180)
|
||||
.cornerRadius(10)
|
||||
VStack(alignment: .leading) {
|
||||
Spacer()
|
||||
Text(viewModel.item.name ?? "").font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.offset(y: 5)
|
||||
HStack {
|
||||
Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(viewModel.item.getItemRuntime()).font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
if viewModel.item.officialRating != nil {
|
||||
Text(viewModel.item.officialRating!).font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
|
||||
.overlay(RoundedRectangle(cornerRadius: 2)
|
||||
.stroke(Color.secondary, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
.padding(.top, 1)
|
||||
}
|
||||
.padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30)
|
||||
}
|
||||
HStack {
|
||||
// Play button
|
||||
Button {
|
||||
self.playbackInfo.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
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(genre: genre), title: genre.name ?? ""))
|
||||
} label: {
|
||||
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" {
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(person: person), title: person.name ?? ""))
|
||||
} label: {
|
||||
VStack {
|
||||
ImageView(src: person
|
||||
.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100),
|
||||
bh: person.getBlurHash())
|
||||
.frame(width: 100, height: 100)
|
||||
.cornerRadius(10)
|
||||
Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1)
|
||||
.frame(width: 100).foregroundColor(Color.primary)
|
||||
if person.role != "" {
|
||||
Text(person.role!).font(.caption).fontWeight(.medium).lineLimit(1)
|
||||
.foregroundColor(Color.secondary).frame(width: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer().frame(width: 10)
|
||||
}
|
||||
}
|
||||
Spacer().frame(width: 16)
|
||||
}
|
||||
}
|
||||
}.padding(.top, -3)
|
||||
}
|
||||
if !(viewModel.item.studios ?? []).isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
Text("Studios:").font(.callout).fontWeight(.semibold)
|
||||
ForEach(viewModel.item.studios!, id: \.id) { studio in
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(studio: studio), title: studio.name ?? ""))
|
||||
} label: {
|
||||
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
|
||||
Button {
|
||||
itemRouter.route(to: .item(viewModel: .init(id: similarItem.id!)))
|
||||
} label: {
|
||||
PortraitItemView(item: similarItem)
|
||||
}
|
||||
Spacer().frame(width: 10)
|
||||
}
|
||||
Spacer().frame(width: 16)
|
||||
}
|
||||
}
|
||||
}.padding(.top, -5)
|
||||
}
|
||||
Spacer().frame(height: 3)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), bh: viewModel.item.getBackdropImageBlurHash())
|
||||
.opacity(0.3)
|
||||
.frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing,
|
||||
height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.blur(radius: 4)
|
||||
HStack {
|
||||
VStack {
|
||||
ImageView(src: viewModel.item.getSeriesPrimaryImage(maxWidth: 120),
|
||||
bh: viewModel.item.getSeriesPrimaryImageBlurHash())
|
||||
.frame(width: 120, height: 180)
|
||||
.cornerRadius(10)
|
||||
Spacer().frame(height: 15)
|
||||
Button {
|
||||
self.playbackInfo.shouldShowPlayer = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text(viewModel.item.getItemProgressString() == "" ? "Play" : viewModel.item.getItemProgressString())
|
||||
.foregroundColor(Color.white).font(.callout).fontWeight(.semibold)
|
||||
Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20))
|
||||
}
|
||||
.frame(width: 120, height: 35)
|
||||
.background(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255))
|
||||
.cornerRadius(10)
|
||||
}.buttonStyle(PlainButtonStyle())
|
||||
.frame(width: 120, height: 35)
|
||||
Spacer()
|
||||
}
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(viewModel.item.name ?? "").font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.offset(x: 14, y: 0)
|
||||
Spacer().frame(height: 1)
|
||||
HStack {
|
||||
Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(viewModel.item.getItemRuntime()).font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
if viewModel.item.officialRating != nil {
|
||||
Text(viewModel.item.officialRating!).font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
|
||||
.overlay(RoundedRectangle(cornerRadius: 2)
|
||||
.stroke(Color.secondary, lineWidth: 1))
|
||||
}
|
||||
if viewModel.item.communityRating != nil {
|
||||
HStack {
|
||||
Image(systemName: "star").foregroundColor(.secondary)
|
||||
Text(String(viewModel.item.communityRating!)).font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.offset(x: -7, y: 0.7)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.offset(x: 14)
|
||||
.padding(.top, 1)
|
||||
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Spacer()
|
||||
HStack {
|
||||
Button {
|
||||
viewModel.updateFavoriteState()
|
||||
} label: {
|
||||
if viewModel.isFavorited {
|
||||
Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed))
|
||||
.font(.system(size: 20))
|
||||
} else {
|
||||
Image(systemName: "heart").foregroundColor(Color.primary)
|
||||
.font(.system(size: 20))
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
Button {
|
||||
viewModel.updateWatchState()
|
||||
} label: {
|
||||
if viewModel.isWatched {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary)
|
||||
.font(.system(size: 20))
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle").foregroundColor(Color.primary)
|
||||
.font(.system(size: 20))
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
|
||||
if !(viewModel.item.taglines ?? []).isEmpty {
|
||||
Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 3)
|
||||
.fixedSize(horizontal: false, vertical: true).padding(.leading, 16)
|
||||
.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
|
||||
}
|
||||
Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3)
|
||||
.fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16)
|
||||
.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
|
||||
if !(viewModel.item.genreItems ?? []).isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
Text("Genres:").font(.callout).fontWeight(.semibold)
|
||||
ForEach(viewModel.item.genreItems!, id: \.id) { genre in
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(genre: genre), title: genre.name ?? ""))
|
||||
} label: {
|
||||
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" {
|
||||
Button {
|
||||
itemRouter
|
||||
.route(to: .library(viewModel: .init(person: person),
|
||||
title: person.name ?? ""))
|
||||
} label: {
|
||||
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
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(studio: studio), title: studio.name ?? ""))
|
||||
} label: {
|
||||
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
|
||||
Button {
|
||||
itemRouter.route(to: .item(viewModel: .init(id: similarItem.id!)))
|
||||
} label: {
|
||||
PortraitItemView(item: similarItem)
|
||||
}
|
||||
Spacer().frame(width: 10)
|
||||
}
|
||||
Spacer().frame(width: 16)
|
||||
}
|
||||
}
|
||||
}.padding(.top, -5)
|
||||
}
|
||||
Spacer().frame(height: 195)
|
||||
}.frame(maxHeight: .infinity)
|
||||
}
|
||||
}.padding(.top, 16).padding(.leading, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
|
||||
.edgesIgnoringSafeArea(.leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onRotate(perform: { orientation in
|
||||
self.orientation = orientation
|
||||
})
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("\(viewModel.item.seriesName ?? "") - S\(String(viewModel.item.parentIndexNumber ?? 0)):E\(String(viewModel.item.indexNumber ?? 0))")
|
||||
}
|
||||
}
|
|
@ -8,12 +8,19 @@
|
|||
*/
|
||||
|
||||
import Foundation
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
struct HomeView: View {
|
||||
@EnvironmentObject var homeRouter: NavigationRouter<HomeCoordinator.Route>
|
||||
@StateObject var viewModel = HomeViewModel()
|
||||
@State var showingSettings = false
|
||||
|
||||
init() {
|
||||
let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill")
|
||||
let barAppearance = UINavigationBar.appearance()
|
||||
barAppearance.backIndicatorImage = backButtonBackgroundImage
|
||||
barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage
|
||||
barAppearance.tintColor = UIColor(Color.jellyfinPurple)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var innerBody: some View {
|
||||
|
@ -21,7 +28,7 @@ struct HomeView: View {
|
|||
ProgressView()
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading) {
|
||||
VStack(alignment: .leading) {
|
||||
if !viewModel.resumeItems.isEmpty {
|
||||
ContinueWatchingView(items: viewModel.resumeItems)
|
||||
}
|
||||
|
@ -36,18 +43,16 @@ struct HomeView: View {
|
|||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
Spacer()
|
||||
Button {
|
||||
homeRouter
|
||||
.route(to: .library(viewModel: .init(parentID: libraryID, filters: viewModel.recentFilterSet),
|
||||
title: library?.name ?? ""))
|
||||
} label: {
|
||||
NavigationLink(destination: LazyView {
|
||||
LibraryView(viewModel: .init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? "")
|
||||
}) {
|
||||
HStack {
|
||||
Text("See All").font(.subheadline).fontWeight(.bold)
|
||||
Image(systemName: "chevron.right").font(Font.subheadline.bold())
|
||||
}
|
||||
}
|
||||
}.padding(.leading, 16)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.trailing, 16)
|
||||
LatestMediaView(viewModel: .init(libraryID: libraryID))
|
||||
}
|
||||
}
|
||||
|
@ -63,17 +68,14 @@ struct HomeView: View {
|
|||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
homeRouter.route(to: .settings)
|
||||
showingSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gear")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
AppURLHandler.shared.appURLState = .allowed
|
||||
AppURLHandler.shared.processLaunchedURLIfNeeded()
|
||||
}
|
||||
.fullScreenCover(isPresented: $showingSettings) {
|
||||
SettingsView(viewModel: SettingsViewModel(), close: $showingSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,58 +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 Introspect
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
class VideoPlayerItem: ObservableObject {
|
||||
@Published var shouldShowPlayer: Bool = false
|
||||
}
|
||||
|
||||
struct ItemView: View {
|
||||
@EnvironmentObject var itemRouter: NavigationRouter<ItemCoordinator.Route>
|
||||
@StateObject var viewModel: ItemViewModel
|
||||
@StateObject private var videoPlayerItem = VideoPlayerItem()
|
||||
@State private var videoIsLoading: Bool = false // This variable is only changed by the underlying VLC view.
|
||||
@State private var isLoading: Bool = false
|
||||
@State private var viewDidLoad: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Group {
|
||||
if let item = viewModel.item {
|
||||
if item.type == "Movie" {
|
||||
MovieItemView(viewModel: .init(item: item))
|
||||
} else if item.type == "Season" {
|
||||
SeasonItemView(viewModel: .init(item: item))
|
||||
} else if item.type == "Series" {
|
||||
SeriesItemView(viewModel: .init(item: item))
|
||||
} else if item.type == "Episode" {
|
||||
EpisodeItemView(viewModel: .init(item: item))
|
||||
} else {
|
||||
Text("Type: \(item.type ?? "") not implemented yet :(")
|
||||
}
|
||||
}
|
||||
}
|
||||
.introspectTabBarController { UITabBarController in
|
||||
UITabBarController.tabBar.isHidden = false
|
||||
}
|
||||
.navigationBarHidden(false)
|
||||
.navigationBarBackButtonHidden(false)
|
||||
.environmentObject(videoPlayerItem)
|
||||
}
|
||||
.onReceive(videoPlayerItem.$shouldShowPlayer) { flag in
|
||||
guard flag,
|
||||
let item = viewModel.item else { return }
|
||||
self.itemRouter.route(to: .videoPlayer(item: item))
|
||||
}
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
import Introspect
|
||||
import JellyfinAPI
|
||||
|
||||
class VideoPlayerItem: ObservableObject {
|
||||
@Published var shouldShowPlayer: Bool = false
|
||||
@Published var itemToPlay: BaseItemDto = BaseItemDto()
|
||||
}
|
||||
|
||||
// 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: .inline)
|
||||
}
|
||||
}
|
||||
|
||||
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) private var hSizeClass
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private let viewModel: ItemViewModel
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
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 {
|
||||
ItemPortraitMainView(videoIsLoading: $videoIsLoading)
|
||||
.environmentObject(videoPlayerItem)
|
||||
.environmentObject(viewModel)
|
||||
} else {
|
||||
ItemLandscapeMainView(videoIsLoading: $videoIsLoading)
|
||||
.environmentObject(videoPlayerItem)
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ItemLandscapeMainView: View {
|
||||
|
||||
@Binding private var videoIsLoading: Bool
|
||||
@EnvironmentObject private var viewModel: ItemViewModel
|
||||
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
|
||||
|
||||
init(videoIsLoading: Binding<Bool>) {
|
||||
self._videoIsLoading = videoIsLoading
|
||||
}
|
||||
|
||||
// MARK: innerBody
|
||||
private var innerBody: some View {
|
||||
HStack {
|
||||
|
||||
// MARK: Sidebar Image
|
||||
VStack {
|
||||
ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 130),
|
||||
bh: viewModel.item.getPrimaryImageBlurHash())
|
||||
.frame(width: 130, height: 195)
|
||||
.cornerRadius(10)
|
||||
|
||||
Spacer().frame(height: 15)
|
||||
|
||||
Button {
|
||||
if let playButtonItem = viewModel.playButtonItem {
|
||||
self.videoPlayerItem.itemToPlay = playButtonItem
|
||||
self.videoPlayerItem.shouldShowPlayer = true
|
||||
}
|
||||
} label: {
|
||||
|
||||
// MARK: Play
|
||||
HStack {
|
||||
Image(systemName: "play.fill")
|
||||
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white)
|
||||
.font(.system(size: 20))
|
||||
Text(viewModel.playButtonText())
|
||||
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white)
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(width: 130, height: 40)
|
||||
.background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple)
|
||||
.cornerRadius(10)
|
||||
}.disabled(viewModel.playButtonItem == nil)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
// MARK: ItemLandscapeTopBarView
|
||||
ItemLandscapeTopBarView()
|
||||
.environmentObject(viewModel)
|
||||
|
||||
// MARK: ItemViewBody
|
||||
if let episodeViewModel = viewModel as? SeasonItemViewModel {
|
||||
CardVStackView(items: episodeViewModel.episodes)
|
||||
} else {
|
||||
ItemViewBody()
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: body
|
||||
var body: some View {
|
||||
VStack {
|
||||
NavigationLink(destination: LoadingViewNoBlur(isShowing: $videoIsLoading) {
|
||||
VLCPlayerWithControls(item: videoPlayerItem.itemToPlay,
|
||||
loadBinding: $videoIsLoading,
|
||||
pBinding: _videoPlayerItem.projectedValue.shouldShowPlayer)
|
||||
.navigationBarHidden(true)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.statusBar(hidden: true)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.prefersHomeIndicatorAutoHidden(true)
|
||||
}, isActive: $videoPlayerItem.shouldShowPlayer) {
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
ZStack {
|
||||
|
||||
// MARK: Backdrop
|
||||
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200),
|
||||
bh: viewModel.item.getBackdropImageBlurHash())
|
||||
.opacity(0.3)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.blur(radius: 4)
|
||||
|
||||
// iPadOS is making the view go all the way to the edge.
|
||||
// We have to accomodate this here
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
innerBody.padding(.horizontal, 25)
|
||||
} else {
|
||||
innerBody
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
import JellyfinAPI
|
||||
|
||||
struct PortraitHeaderOverlayView: View {
|
||||
|
||||
@EnvironmentObject 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)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Spacer()
|
||||
|
||||
// MARK: Name
|
||||
Text(viewModel.getItemDisplayName())
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.bottom, 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))
|
||||
.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(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30)
|
||||
}
|
||||
|
||||
HStack {
|
||||
|
||||
// MARK: Play
|
||||
Button {
|
||||
if let playButtonItem = viewModel.playButtonItem {
|
||||
self.videoPlayerItem.itemToPlay = playButtonItem
|
||||
self.videoPlayerItem.shouldShowPlayer = true
|
||||
}
|
||||
} 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()
|
||||
} 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.jellyfinPurple)
|
||||
.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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
import JellyfinAPI
|
||||
|
||||
struct ItemPortraitMainView: View {
|
||||
|
||||
@Binding private var videoIsLoading: Bool
|
||||
@EnvironmentObject private var viewModel: ItemViewModel
|
||||
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
|
||||
|
||||
init(videoIsLoading: Binding<Bool>) {
|
||||
self._videoIsLoading = videoIsLoading
|
||||
}
|
||||
|
||||
// MARK: portraitHeaderView
|
||||
var portraitHeaderView: some View {
|
||||
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 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: 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)
|
||||
} else {
|
||||
ItemViewBody()
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -241,8 +241,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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,439 +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 Stinsen
|
||||
import SwiftUI
|
||||
|
||||
struct MovieItemView: View {
|
||||
@EnvironmentObject var itemRouter: NavigationRouter<ItemCoordinator.Route>
|
||||
@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.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
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(genre: genre), title: genre.name ?? ""))
|
||||
} label: {
|
||||
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" {
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(person: person), title: person.name ?? ""))
|
||||
} label: {
|
||||
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
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(studio: studio), title: studio.name ?? ""))
|
||||
} label: {
|
||||
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
|
||||
Button {
|
||||
itemRouter.route(to: .item(viewModel: .init(id: similarItem.id!)))
|
||||
} label: {
|
||||
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.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
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(genre: genre), title: genre.name ?? ""))
|
||||
} label: {
|
||||
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" {
|
||||
Button {
|
||||
itemRouter
|
||||
.route(to: .library(viewModel: .init(person: person),
|
||||
title: person.name ?? ""))
|
||||
} label: {
|
||||
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
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(studio: studio), title: studio.name ?? ""))
|
||||
} label: {
|
||||
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
|
||||
Button {
|
||||
itemRouter.route(to: .item(viewModel: .init(id: similarItem.id!)))
|
||||
} label: {
|
||||
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 ?? "")
|
||||
}
|
||||
}
|
|
@ -1,261 +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 Stinsen
|
||||
import SwiftUI
|
||||
|
||||
struct SeasonItemView: View {
|
||||
@EnvironmentObject var itemRouter: NavigationRouter<ItemCoordinator.Route>
|
||||
@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
|
||||
Button {
|
||||
itemRouter.route(to: .item(viewModel: .init(id: episode.id!)))
|
||||
} label: {
|
||||
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
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(studio: studio), title: studio.name ?? ""))
|
||||
} label: {
|
||||
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
|
||||
Button {
|
||||
itemRouter.route(to: .item(viewModel: .init(id: episode.id!)))
|
||||
} label: {
|
||||
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
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(studio: studio), title: studio.name ?? ""))
|
||||
} label: {
|
||||
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 ?? "")")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 Stinsen
|
||||
import SwiftUI
|
||||
|
||||
struct SeriesItemView: View {
|
||||
@EnvironmentObject var itemRouter: NavigationRouter<ItemCoordinator.Route>
|
||||
@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
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(genre: genre), title: genre.name ?? ""))
|
||||
} label: {
|
||||
Text(genre.name ?? "").font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
Text(viewModel.item.overview ?? "")
|
||||
.font(.footnote)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.horizontal, 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
|
||||
Button {
|
||||
itemRouter.route(to: .library(viewModel: .init(studio: studio), title: studio.name ?? ""))
|
||||
} label: {
|
||||
Text(studio.name ?? "").font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
Text("Seasons")
|
||||
.font(.callout).fontWeight(.semibold)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
LazyVGrid(columns: tracks) {
|
||||
ForEach(viewModel.seasons, id: \.id) { season in
|
||||
Button {
|
||||
itemRouter.route(to: .item(viewModel: .init(id: season.id!)))
|
||||
} label: {
|
||||
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" {
|
||||
Button {
|
||||
itemRouter
|
||||
.route(to: .library(viewModel: .init(person: person),
|
||||
title: person.name ?? ""))
|
||||
} label: {
|
||||
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 !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
|
||||
Button {
|
||||
itemRouter.route(to: .item(viewModel: .init(id: similarItem.id!)))
|
||||
} label: {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,17 +6,15 @@
|
|||
*/
|
||||
|
||||
import CoreData
|
||||
import Defaults
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct SettingsView: View {
|
||||
@RouterObject var mainRouter: ViewRouter<MainCoordinator.Route>?
|
||||
@EnvironmentObject var settingsRouter: NavigationRouter<SettingsCoordinator.Route>
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@ObservedObject var viewModel: SettingsViewModel
|
||||
|
||||
@Binding var close: Bool
|
||||
@Default(.inNetworkBandwidth) var inNetworkStreamBitrate
|
||||
@Default(.outOfNetworkBandwidth) var outOfNetworkStreamBitrate
|
||||
@Default(.isAutoSelectSubtitles) var isAutoSelectSubtitles
|
||||
|
@ -27,102 +25,101 @@ struct SettingsView: View {
|
|||
@Default(.videoPlayerJumpBackward) var jumpBackwardLength
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
Text("User")
|
||||
Spacer()
|
||||
Text(SessionManager.current.user.username ?? "")
|
||||
.foregroundColor(.jellyfinPurple)
|
||||
}
|
||||
|
||||
Button {
|
||||
settingsRouter.route(to: .serverDetail)
|
||||
} label: {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section(header: EmptyView()) {
|
||||
HStack {
|
||||
Text("Server")
|
||||
Text("User")
|
||||
Spacer()
|
||||
Text(ServerEnvironment.current.server.name ?? "")
|
||||
Text(SessionManager.current.user.username ?? "")
|
||||
.foregroundColor(.jellyfinPurple)
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
|
||||
NavigationLink(
|
||||
destination: ServerDetailView(),
|
||||
label: {
|
||||
HStack {
|
||||
Text("Server")
|
||||
Spacer()
|
||||
Text(ServerEnvironment.current.server.name ?? "")
|
||||
.foregroundColor(.jellyfinPurple)
|
||||
}
|
||||
})
|
||||
|
||||
Button {
|
||||
close = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
SessionManager.current.logout()
|
||||
let nc = NotificationCenter.default
|
||||
nc.post(name: Notification.Name("didSignOut"), object: nil)
|
||||
}
|
||||
} label: {
|
||||
Text("Sign out")
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
Section(header: Text("Playback")) {
|
||||
Picker("Default local quality", selection: $inNetworkStreamBitrate) {
|
||||
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
|
||||
Text(bitrate.name).tag(bitrate.value)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) {
|
||||
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
|
||||
Text(bitrate.name).tag(bitrate.value)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Jump Forward Length", selection: $jumpForwardLength) {
|
||||
ForEach(self.viewModel.videoPlayerJumpLengths, id: \.self) { length in
|
||||
Text(length.label).tag(length.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Jump Backward Length", selection: $jumpBackwardLength) {
|
||||
ForEach(self.viewModel.videoPlayerJumpLengths, id: \.self) { length in
|
||||
Text(length.label).tag(length.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
settingsRouter.dismiss {
|
||||
SessionManager.current.logout()
|
||||
mainRouter?.route(to: .connectToServer)
|
||||
}
|
||||
} label: {
|
||||
Text("Sign out")
|
||||
.font(.callout)
|
||||
Section(header: Text("Accessibility")) {
|
||||
Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles)
|
||||
SearchablePicker(label: "Preferred subtitle language",
|
||||
options: viewModel.langs,
|
||||
optionToString: { $0.name },
|
||||
selected: Binding<TrackLanguage>(
|
||||
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectSubtitlesLangcode }) ?? .auto },
|
||||
set: {autoSelectSubtitlesLangcode = $0.isoCode}
|
||||
)
|
||||
)
|
||||
SearchablePicker(label: "Preferred audio language",
|
||||
options: viewModel.langs,
|
||||
optionToString: { $0.name },
|
||||
selected: Binding<TrackLanguage>(
|
||||
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectAudioLangcode }) ?? .auto },
|
||||
set: { autoSelectAudioLangcode = $0.isoCode}
|
||||
)
|
||||
)
|
||||
Picker(NSLocalizedString("Appearance", comment: ""), selection: $appAppearance) {
|
||||
ForEach(self.viewModel.appearances, id: \.self) { appearance in
|
||||
Text(appearance.localizedName).tag(appearance.rawValue)
|
||||
}
|
||||
}.onChange(of: appAppearance, perform: { value in
|
||||
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style
|
||||
})
|
||||
}
|
||||
}
|
||||
Section(header: Text("Playback")) {
|
||||
Picker("Default local quality", selection: $inNetworkStreamBitrate) {
|
||||
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
|
||||
Text(bitrate.name).tag(bitrate.value)
|
||||
.navigationBarTitle("Settings", displayMode: .inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
close = false
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) {
|
||||
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
|
||||
Text(bitrate.name).tag(bitrate.value)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Jump Forward Length", selection: $jumpForwardLength) {
|
||||
ForEach(self.viewModel.videoPlayerJumpLengths, id: \.self) { length in
|
||||
Text(length.label).tag(length.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Jump Backward Length", selection: $jumpBackwardLength) {
|
||||
ForEach(self.viewModel.videoPlayerJumpLengths, id: \.self) { length in
|
||||
Text(length.label).tag(length.rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Accessibility")) {
|
||||
Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles)
|
||||
SearchablePicker(label: "Preferred subtitle language",
|
||||
options: viewModel.langs,
|
||||
optionToString: { $0.name },
|
||||
selected: Binding<TrackLanguage>(get: {
|
||||
viewModel.langs
|
||||
.first(where: { $0.isoCode == autoSelectSubtitlesLangcode
|
||||
}) ??
|
||||
.auto
|
||||
},
|
||||
set: { autoSelectSubtitlesLangcode = $0.isoCode }))
|
||||
SearchablePicker(label: "Preferred audio language",
|
||||
options: viewModel.langs,
|
||||
optionToString: { $0.name },
|
||||
selected: Binding<TrackLanguage>(get: {
|
||||
viewModel.langs
|
||||
.first(where: { $0.isoCode == autoSelectAudioLangcode }) ??
|
||||
.auto
|
||||
},
|
||||
set: { autoSelectAudioLangcode = $0.isoCode }))
|
||||
Picker(NSLocalizedString("Appearance", comment: ""), selection: $appAppearance) {
|
||||
ForEach(self.viewModel.appearances, id: \.self) { appearance in
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
.navigationBarTitle("Settings", displayMode: .inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
settingsRouter.dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -182,10 +182,13 @@
|
|||
<outletCollection property="gestureRecognizers" destination="iQW-fW-KWT" appends="YES" id="H09-88-nzQ"/>
|
||||
</connections>
|
||||
</view>
|
||||
<view hidden="YES" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="CY9-gw-dv8" userLabel="UpNextView">
|
||||
<rect key="frame" x="675" y="254" width="224" height="160"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="CY9-gw-dv8" userLabel="UpNextView">
|
||||
<rect key="frame" x="672" y="254" width="224" height="160"/>
|
||||
<color key="backgroundColor" red="0.34509803921568627" green="0.33725490196078434" blue="0.83921568627450982" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="160" id="IyL-p4-Y54"/>
|
||||
<constraint firstAttribute="width" constant="224" id="rFU-Nq-Qmj"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="zud-b9-RyD"/>
|
||||
|
@ -194,8 +197,11 @@
|
|||
<constraints>
|
||||
<constraint firstItem="Qcb-Fb-qZl" firstAttribute="top" secondItem="zud-b9-RyD" secondAttribute="top" id="0rU-S8-2ZG"/>
|
||||
<constraint firstItem="Tsh-rC-BwO" firstAttribute="bottom" secondItem="IQg-r0-AeH" secondAttribute="bottom" id="CLQ-xL-eMg"/>
|
||||
<constraint firstAttribute="trailing" secondItem="CY9-gw-dv8" secondAttribute="trailing" id="GAY-9O-TMP"/>
|
||||
<constraint firstItem="zud-b9-RyD" firstAttribute="trailing" secondItem="Tsh-rC-BwO" secondAttribute="trailing" constant="-13" id="MTY-zG-Jfx"/>
|
||||
<constraint firstAttribute="trailing" secondItem="Qcb-Fb-qZl" secondAttribute="trailing" id="N96-TI-UDZ"/>
|
||||
<constraint firstAttribute="trailing" secondItem="CY9-gw-dv8" secondAttribute="trailing" id="VY1-j7-qK2"/>
|
||||
<constraint firstAttribute="bottom" secondItem="CY9-gw-dv8" secondAttribute="bottom" id="Wtk-gJ-gF4"/>
|
||||
<constraint firstItem="Qcb-Fb-qZl" firstAttribute="leading" secondItem="IQg-r0-AeH" secondAttribute="leading" id="ctC-7w-DiS"/>
|
||||
<constraint firstItem="Tsh-rC-BwO" firstAttribute="leading" secondItem="zud-b9-RyD" secondAttribute="leading" constant="-13" id="cw7-9C-iua"/>
|
||||
<constraint firstItem="Tsh-rC-BwO" firstAttribute="top" secondItem="zud-b9-RyD" secondAttribute="top" id="d4Q-bp-K4m"/>
|
||||
|
|
|
@ -16,7 +16,7 @@ extension Defaults.Keys {
|
|||
static let isAutoSelectSubtitles = Key<Bool>("isAutoSelectSubtitles", default: false)
|
||||
static let autoSelectSubtitlesLangCode = Key<String>("AutoSelectSubtitlesLangCode", default: "Auto")
|
||||
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto")
|
||||
static let appAppearance = Key<String>("appAppearance", default: AppAppearance.system.rawValue)
|
||||
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system)
|
||||
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward", default: .thirty)
|
||||
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>("videoPlayerJumpBackward", default: .thirty)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
/* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
@ -11,7 +13,8 @@ import UIKit
|
|||
|
||||
// 001fC^ = dark grey plain blurhash
|
||||
|
||||
extension BaseItemDto {
|
||||
public extension BaseItemDto {
|
||||
|
||||
// MARK: Images
|
||||
|
||||
func getSeriesBackdropImageBlurHash() -> String {
|
||||
|
@ -75,11 +78,11 @@ 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 {
|
||||
|
@ -149,27 +152,42 @@ extension BaseItemDto {
|
|||
return "\(String(progminutes))m"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func round(_ value: Double, toNearest: Double) -> Double {
|
||||
return round(value / toNearest) * toNearest
|
||||
}
|
||||
|
||||
extension BaseItemPerson {
|
||||
func getImage(baseURL: String, maxWidth: Int) -> URL {
|
||||
let imageType = "Primary"
|
||||
let imageTag = primaryImageTag ?? ""
|
||||
|
||||
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
|
||||
|
||||
let urlString = "\(baseURL)/Items/\(id ?? "")/Images/\(imageType)?maxWidth=\(String(Int(x)))&quality=85&tag=\(imageTag)"
|
||||
return URL(string: urlString)!
|
||||
|
||||
// MARK: ItemType
|
||||
|
||||
enum ItemType: String {
|
||||
case movie = "Movie"
|
||||
case season = "Season"
|
||||
case episode = "Episode"
|
||||
case series = "Series"
|
||||
|
||||
case unknown
|
||||
|
||||
var showDetails: Bool {
|
||||
switch self {
|
||||
case .season, .series:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getBlurHash() -> String {
|
||||
let rawImgURL = getImage(baseURL: "", maxWidth: 1).absoluteString
|
||||
let imgTag = rawImgURL.components(separatedBy: "&tag=")[1]
|
||||
|
||||
return imageBlurHashes?.primary?[imgTag] ?? "001fC^"
|
||||
|
||||
var itemType: ItemType {
|
||||
guard let originalType = self.type, let knownType = ItemType(rawValue: originalType) else { return .unknown }
|
||||
return knownType
|
||||
}
|
||||
|
||||
// MARK: PortraitHeaderViewURL
|
||||
|
||||
func portraitHeaderViewURL(maxWidth: Int) -> URL {
|
||||
switch self.itemType {
|
||||
case .movie, .season, .series:
|
||||
return getPrimaryImage(maxWidth: maxWidth)
|
||||
case .episode:
|
||||
return getSeriesPrimaryImage(maxWidth: maxWidth)
|
||||
case .unknown:
|
||||
return getPrimaryImage(maxWidth: maxWidth)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
/* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import UIKit
|
||||
|
||||
extension BaseItemPerson {
|
||||
|
||||
// MARK: Get Image
|
||||
func getImage(baseURL: String, maxWidth: Int) -> URL {
|
||||
let imageType = "Primary"
|
||||
let imageTag = primaryImageTag ?? ""
|
||||
|
||||
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
|
||||
|
||||
let urlString = "\(baseURL)/Items/\(id ?? "")/Images/\(imageType)?maxWidth=\(String(Int(x)))&quality=85&tag=\(imageTag)"
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
|
||||
func getBlurHash() -> String {
|
||||
let rawImgURL = getImage(baseURL: "", maxWidth: 1).absoluteString
|
||||
let imgTag = rawImgURL.components(separatedBy: "&tag=")[1]
|
||||
|
||||
return imageBlurHashes?.primary?[imgTag] ?? "001fC^"
|
||||
}
|
||||
|
||||
|
||||
// MARK: First Role
|
||||
|
||||
// Jellyfin will grab all roles the person played in the show which makes the role
|
||||
// text too long. This will grab the first role which:
|
||||
// - assumes that the most important role is the first
|
||||
// - will also grab the last "(<text>)" instance, like "(voice)"
|
||||
func firstRole() -> String? {
|
||||
guard let role = self.role else { return nil }
|
||||
let split = role.split(separator: "/")
|
||||
guard split.count > 1 else { return role }
|
||||
|
||||
guard let firstRole = split.first?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), let lastRole = split.last?.trimmingCharacters(in: CharacterSet(charactersIn: " ")) else { return role }
|
||||
|
||||
var final = firstRole
|
||||
|
||||
if let lastOpenIndex = lastRole.lastIndex(of: "("), let lastClosingIndex = lastRole.lastIndex(of: ")") {
|
||||
let roleText = lastRole[lastOpenIndex...lastClosingIndex]
|
||||
final.append(" \(roleText)")
|
||||
}
|
||||
|
||||
return final
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: PortraitImageStackable
|
||||
extension BaseItemPerson: PortraitImageStackable {
|
||||
public func imageURLContsructor(maxWidth: Int) -> URL {
|
||||
return self.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: maxWidth)
|
||||
}
|
||||
|
||||
public var title: String {
|
||||
return self.name ?? ""
|
||||
}
|
||||
|
||||
public var description: String? {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
extension NameGuidPair: PillStackable {
|
||||
var title: String {
|
||||
return self.name ?? ""
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
enum DetailItemType: String {
|
||||
case movie = "Movie"
|
||||
case season = "Season"
|
||||
case series = "Series"
|
||||
case episode = "Episode"
|
||||
}
|
||||
|
||||
struct DetailItem {
|
||||
|
||||
let baseItem: BaseItemDto
|
||||
let type: DetailItemType
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -60,6 +60,13 @@ final class ConnectToServerViewModel: ViewModel {
|
|||
}
|
||||
|
||||
func connectToServer() {
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
if uriSubject.value == "localhost" {
|
||||
uriSubject.value = "http://localhost:8096"
|
||||
}
|
||||
#endif
|
||||
|
||||
LogManager.shared.log.debug("Attempting to connect to server at \"\(uriSubject.value)\"", tag: "connectToServer")
|
||||
ServerEnvironment.current.create(with: uriSubject.value)
|
||||
.trackActivity(loading)
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
class DetailItemViewModel: ViewModel {
|
||||
|
||||
@Published var item: BaseItemDto
|
||||
@Published var similarItems: [BaseItemDto] = []
|
||||
|
||||
@Published var isWatched = false
|
||||
@Published var isFavorited = false
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self.item = item
|
||||
isFavorited = item.userData?.isFavorite ?? false
|
||||
isWatched = item.userData?.played ?? false
|
||||
super.init()
|
||||
|
||||
getRelatedItems()
|
||||
}
|
||||
|
||||
func getRelatedItems() {
|
||||
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
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
self?.similarItems = response.items ?? []
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func updateWatchState() {
|
||||
if isWatched {
|
||||
PlaystateAPI.markUnplayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isWatched = false
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
} else {
|
||||
PlaystateAPI.markPlayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isWatched = true
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
func updateFavoriteState() {
|
||||
if isFavorited {
|
||||
UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isFavorited = false
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
} else {
|
||||
UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isFavorited = true
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,35 +1,103 @@
|
|||
//
|
||||
/*
|
||||
* SwiftFin is subject to the terms of the Mozilla Public
|
||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||
*/
|
||||
/*
|
||||
* 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
|
||||
|
||||
class ItemViewModel: ViewModel {
|
||||
var id: String
|
||||
|
||||
@Published var item: BaseItemDto
|
||||
@Published var playButtonItem: BaseItemDto?
|
||||
@Published var similarItems: [BaseItemDto] = []
|
||||
@Published var isWatched = false
|
||||
@Published var isFavorited = false
|
||||
|
||||
@Published var item: BaseItemDto?
|
||||
|
||||
init(id: String) {
|
||||
self.id = id
|
||||
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() {
|
||||
UserLibraryAPI.getItem(userId: SessionManager.current.user.user_id!, itemId: id)
|
||||
|
||||
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
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
self?.item = response
|
||||
self?.similarItems = response.items ?? []
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func updateWatchState() {
|
||||
if isWatched {
|
||||
PlaystateAPI.markUnplayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isWatched = false
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
} else {
|
||||
PlaystateAPI.markPlayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isWatched = true
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
func updateFavoriteState() {
|
||||
if isFavorited {
|
||||
UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isFavorited = false
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
} else {
|
||||
UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isFavorited = true
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,5 +11,5 @@ import Combine
|
|||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
final class MovieItemViewModel: DetailItemViewModel {
|
||||
final class MovieItemViewModel: ItemViewModel {
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue