diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index c555a533..d108f1ce 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -171,12 +171,25 @@ 621338932660107500A81A2A /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; 621338B32660A07800A81A2A /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338B22660A07800A81A2A /* LazyView.swift */; }; 621C638026672A30004216EA /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 621C637F26672A30004216EA /* NukeUI */; }; + 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */; }; + 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */; }; + 6220D0AF26D5EABE00B8E046 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */; }; + 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */; }; + 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */; }; + 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */; }; + 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */; }; + 6220D0BD26D60D6600B8E046 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0BC26D60D6600B8E046 /* ItemViewModel.swift */; }; + 6220D0BE26D60D6600B8E046 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0BC26D60D6600B8E046 /* ItemViewModel.swift */; }; + 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */; }; + 6220D0C626D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */; }; + 6220D0C726D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */; }; + 6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 6220D0C826D63F3700B8E046 /* Stinsen */; }; + 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */; }; 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; }; 6228B1C22670EB010067FD35 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFD263B596B003A4E83 /* PersistenceController.swift */; }; 624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624C21742685CF60007F1390 /* SearchablePickerView.swift */; }; 625CB5682678B6FB00530A6E /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5672678B6FB00530A6E /* SplashView.swift */; }; 625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5692678B71200530A6E /* SplashViewModel.swift */; }; - 625CB56C2678C0FD00530A6E /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB56B2678C0FD00530A6E /* MainTabView.swift */; }; 625CB56F2678C23300530A6E /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB56E2678C23300530A6E /* HomeView.swift */; }; 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5722678C32A00530A6E /* HomeViewModel.swift */; }; 625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5742678C33500530A6E /* LibraryListViewModel.swift */; }; @@ -200,10 +213,17 @@ 628B95382670CDAB0091AF3B /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFF263B596B003A4E83 /* Model.xcdatamodeld */; }; 628B953A2670CE250091AF3B /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 628B95392670CE250091AF3B /* KeychainSwift */; }; 628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; + 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 62C29E9B26D0FE4200C1D2E7 /* Stinsen */; }; + 62C29E9F26D1016600C1D2E7 /* MainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29E9E26D1016600C1D2E7 /* MainCoordinator.swift */; }; + 62C29EA126D102A500C1D2E7 /* MainTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA026D102A500C1D2E7 /* MainTabCoordinator.swift */; }; + 62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */; }; + 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */; }; + 62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA726D103D500C1D2E7 /* LibraryListCoordinator.swift */; }; 62CB3F462685BAF7003D0A6F /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 62CB3F452685BAF7003D0A6F /* Defaults */; }; 62CB3F482685BB3B003D0A6F /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 62CB3F472685BB3B003D0A6F /* Defaults */; }; 62CB3F4B2685BB77003D0A6F /* DefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CB3F4A2685BB77003D0A6F /* DefaultsExtension.swift */; }; 62CB3F4C2685BB77003D0A6F /* DefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CB3F4A2685BB77003D0A6F /* DefaultsExtension.swift */; }; + 62D8535B26FC631300FDFC59 /* MainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29E9E26D1016600C1D2E7 /* MainCoordinator.swift */; }; 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */; }; 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; }; 62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; }; @@ -229,6 +249,7 @@ 62EC353126766848000E9F2D /* ServerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352B26766675000E9F2D /* ServerEnvironment.swift */; }; 62EC353226766849000E9F2D /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352E267666A5000E9F2D /* SessionManager.swift */; }; 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */; }; + 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */; }; AE8C3159265D6F90008AA076 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */; }; @@ -244,7 +265,7 @@ 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 */; }; + E188460426DEF04800B0C5B7 /* EpisodeCardVStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E188460326DEF04800B0C5B7 /* EpisodeCardVStackView.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 */; }; @@ -413,11 +434,19 @@ 6213388F265F83A900A81A2A /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; 621338922660107500A81A2A /* StringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensions.swift; sourceTree = ""; }; 621338B22660A07800A81A2A /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; + 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = ""; }; + 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCoordinator.swift; sourceTree = ""; }; + 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryCoordinator.swift; sourceTree = ""; }; + 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCoordinator.swift; sourceTree = ""; }; + 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterCoordinator.swift; sourceTree = ""; }; + 6220D0BC26D60D6600B8E046 /* ItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewModel.swift; sourceTree = ""; }; + 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCoordinator.swift; sourceTree = ""; }; + 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCoordinator.swift; sourceTree = ""; }; + 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLHandler.swift; sourceTree = ""; }; 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeader.swift; sourceTree = ""; }; 624C21742685CF60007F1390 /* SearchablePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchablePickerView.swift; sourceTree = ""; }; 625CB5672678B6FB00530A6E /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; 625CB5692678B71200530A6E /* SplashViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashViewModel.swift; sourceTree = ""; }; - 625CB56B2678C0FD00530A6E /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; 625CB56E2678C23300530A6E /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 625CB5722678C32A00530A6E /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 625CB5742678C33500530A6E /* LibraryListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListViewModel.swift; sourceTree = ""; }; @@ -434,6 +463,11 @@ 628B952A2670CABE0091AF3B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 628B95362670CB800091AF3B /* JellyfinWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinWidget.swift; sourceTree = ""; }; 628B953B2670D1FC0091AF3B /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = ""; }; + 62C29E9E26D1016600C1D2E7 /* MainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinator.swift; sourceTree = ""; }; + 62C29EA026D102A500C1D2E7 /* MainTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabCoordinator.swift; sourceTree = ""; }; + 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerCoodinator.swift; sourceTree = ""; }; + 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCoordinator.swift; sourceTree = ""; }; + 62C29EA726D103D500C1D2E7 /* LibraryListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListCoordinator.swift; sourceTree = ""; }; 62CB3F4A2685BB77003D0A6F /* DefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultsExtension.swift; sourceTree = ""; }; 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaViewModel.swift; sourceTree = ""; }; 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchViewModel.swift; sourceTree = ""; }; @@ -447,6 +481,7 @@ 62EC352B26766675000E9F2D /* ServerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEnvironment.swift; sourceTree = ""; }; 62EC352E267666A5000E9F2D /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; + 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = ""; }; BEEC50E7EFD4848C0E320941 /* Pods-JellyfinPlayer iOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer iOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer iOS/Pods-JellyfinPlayer iOS.release.xcconfig"; sourceTree = ""; }; D79953919FED0C4DF72BA578 /* Pods-JellyfinPlayer tvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JellyfinPlayer tvOS.release.xcconfig"; path = "Target Support Files/Pods-JellyfinPlayer tvOS/Pods-JellyfinPlayer tvOS.release.xcconfig"; sourceTree = ""; }; @@ -461,7 +496,7 @@ E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Stackable.swift"; sourceTree = ""; }; E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewBody.swift; sourceTree = ""; }; E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = ""; }; - E188460326DEF04800B0C5B7 /* CardVStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVStackView.swift; sourceTree = ""; }; + E188460326DEF04800B0C5B7 /* EpisodeCardVStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeCardVStackView.swift; sourceTree = ""; }; E1AD104926D94822003E4A08 /* DetailItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItem.swift; sourceTree = ""; }; E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = ""; }; E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHStackView.swift; sourceTree = ""; }; @@ -481,6 +516,7 @@ files = ( 53649AAF269CFAF600A2D8B7 /* Puppy in Frameworks */, 53EC6E1E267E80AC006DD26A /* Pods_JellyfinPlayer_tvOS.framework in Frameworks */, + 6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */, 53A431BF266B0FFE0016769F /* JellyfinAPI in Frameworks */, 535870912669D7A800D05A09 /* Introspect in Frameworks */, 6261A0E026A0AB710072EF1C /* CombineExt in Frameworks */, @@ -499,6 +535,7 @@ buildActionMask = 2147483647; files = ( 53649AAD269CFAEA00A2D8B7 /* Puppy in Frameworks */, + 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */, 62CB3F462685BAF7003D0A6F /* Defaults in Frameworks */, 5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */, 53EC6E25267EB10F006DD26A /* SwiftyJSON in Frameworks */, @@ -573,6 +610,7 @@ 625CB5692678B71200530A6E /* SplashViewModel.swift */, 09389CC626819B4500AE350E /* VideoPlayerModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, + 6220D0BC26D60D6600B8E046 /* ItemViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -730,7 +768,9 @@ 5377CBF3263B596A003A4E83 /* JellyfinPlayer */ = { isa = PBXGroup; children = ( + 62C29E9D26D0FE5900C1D2E7 /* Coordinators */, 53F866422687A45400DCD1D7 /* Components */, + 62ECA01926FA6D6900E8EBB7 /* Singleton */, 53AD124C2670278D0094A276 /* JellyfinPlayer.entitlements */, 5377CBF8263B596B003A4E83 /* Assets.xcassets */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, @@ -755,8 +795,8 @@ 532E68CE267D9F6B007B9F13 /* VideoPlayerCastDeviceSelector.swift */, 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */, 625CB5672678B6FB00530A6E /* SplashView.swift */, - 625CB56B2678C0FD00530A6E /* MainTabView.swift */, 625CB56E2678C23300530A6E /* HomeView.swift */, + 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */, ); path = JellyfinPlayer; sourceTree = ""; @@ -923,7 +963,7 @@ E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */, E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */, 53F866432687A45F00DCD1D7 /* PortraitItemView.swift */, - E188460326DEF04800B0C5B7 /* CardVStackView.swift */, + E188460326DEF04800B0C5B7 /* EpisodeCardVStackView.swift */, ); path = Components; sourceTree = ""; @@ -938,6 +978,7 @@ 6267B3D92671138200A7371D /* ImageExtensions.swift */, E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */, 621338922660107500A81A2A /* StringExtensions.swift */, + 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */, ); path = Extensions; sourceTree = ""; @@ -954,6 +995,24 @@ path = WidgetExtension; sourceTree = ""; }; + 62C29E9D26D0FE5900C1D2E7 /* Coordinators */ = { + isa = PBXGroup; + children = ( + 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */, + 6220D0B926D6092100B8E046 /* FilterCoordinator.swift */, + 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */, + 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */, + 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */, + 62C29EA726D103D500C1D2E7 /* LibraryListCoordinator.swift */, + 62C29E9E26D1016600C1D2E7 /* MainCoordinator.swift */, + 62C29EA026D102A500C1D2E7 /* MainTabCoordinator.swift */, + 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */, + 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */, + 6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; 62EC352A26766657000E9F2D /* Singleton */ = { isa = PBXGroup; children = ( @@ -965,6 +1024,14 @@ path = Singleton; sourceTree = ""; }; + 62ECA01926FA6D6900E8EBB7 /* Singleton */ = { + isa = PBXGroup; + children = ( + 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */, + ); + path = Singleton; + sourceTree = ""; + }; AE8C3157265D6F5E008AA076 /* Resources */ = { isa = PBXGroup; children = ( @@ -1077,6 +1144,7 @@ 53272534268BF9710035FBF1 /* SwiftUIFocusGuide */, 53649AAE269CFAF600A2D8B7 /* Puppy */, 6261A0DF26A0AB710072EF1C /* CombineExt */, + 6220D0C826D63F3700B8E046 /* Stinsen */, ); productName = "JellyfinPlayer tvOS"; productReference = 535870602669D21600D05A09 /* JellyfinPlayer tvOS.app */; @@ -1111,6 +1179,7 @@ 62CB3F452685BAF7003D0A6F /* Defaults */, 53649AAC269CFAEA00A2D8B7 /* Puppy */, 6260FFF826A09754003FA968 /* CombineExt */, + 62C29E9B26D0FE4200C1D2E7 /* Stinsen */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* JellyfinPlayer iOS.app */; @@ -1199,6 +1268,7 @@ 53272533268BF9710035FBF1 /* XCRemoteSwiftPackageReference "SwiftUIFocusGuide" */, 53649AAB269CFAEA00A2D8B7 /* XCRemoteSwiftPackageReference "Puppy" */, 6260FFF726A09754003FA968 /* XCRemoteSwiftPackageReference "CombineExt" */, + 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -1447,6 +1517,7 @@ 5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */, 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */, 62CB3F4C2685BB77003D0A6F /* DefaultsExtension.swift in Sources */, + 62D8535B26FC631300FDFC59 /* MainCoordinator.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 5358706C2669D21700D05A09 /* PersistenceController.swift in Sources */, 53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */, @@ -1454,9 +1525,11 @@ 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */, 531069582684E7EE00CFFDBA /* MediaInfoView.swift in Sources */, + 6220D0C726D62D8700B8E046 /* VideoPlayerCoordinator.swift in Sources */, 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */, 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */, 535870A62669D8AE00D05A09 /* LazyView.swift in Sources */, + 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, 5321753E2671DE9C005491E6 /* Typings.swift in Sources */, E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, @@ -1466,6 +1539,8 @@ 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, 53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, + 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, + 6220D0BE26D60D6600B8E046 /* ItemViewModel.swift in Sources */, 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */, E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */, E1AD105726D981CE003E4A08 /* PortraitHStackView.swift in Sources */, @@ -1480,27 +1555,36 @@ files = ( 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, + 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, + 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, + 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */, 621338932660107500A81A2A /* StringExtensions.swift in Sources */, 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, + 62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */, 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, 5377CBFE263B596B003A4E83 /* PersistenceController.swift in Sources */, + 6220D0BD26D60D6600B8E046 /* ItemViewModel.swift in Sources */, + 62C29E9F26D1016600C1D2E7 /* MainCoordinator.swift in Sources */, 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, 53F866442687A45F00DCD1D7 /* PortraitItemView.swift in Sources */, E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, + 62C29EA126D102A500C1D2E7 /* MainTabCoordinator.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, + 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, 62CB3F4B2685BB77003D0A6F /* DefaultsExtension.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, - E188460426DEF04800B0C5B7 /* CardVStackView.swift in Sources */, + E188460426DEF04800B0C5B7 /* EpisodeCardVStackView.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 */, @@ -1508,6 +1592,7 @@ E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */, 53892770263C25230035E14B /* NextUpView.swift in Sources */, 625CB5682678B6FB00530A6E /* SplashView.swift in Sources */, + 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */, @@ -1525,11 +1610,16 @@ 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, + 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */, + 62E632F3267D54030063E547 /* ItemViewModel.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 */, @@ -1542,14 +1632,15 @@ 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 */, 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */, 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, - 625CB56C2678C0FD00530A6E /* MainTabView.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, + 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, @@ -1573,6 +1664,7 @@ 6228B1C22670EB010067FD35 /* PersistenceController.swift in Sources */, E1FCD09A26C4F35A007C8DCF /* ErrorMessage.swift in Sources */, 628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */, + 6220D0AF26D5EABE00B8E046 /* ViewExtensions.swift in Sources */, 628B95382670CDAB0091AF3B /* Model.xcdatamodeld in Sources */, E1FCD09926C4F358007C8DCF /* NetworkError.swift in Sources */, E131691926C583BC0074BFEE /* LogConstructor.swift in Sources */, @@ -2152,6 +2244,14 @@ kind = branch; }; }; + 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/rundfunk47/stinsen"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.2; + }; + }; 62CB3F442685BAF7003D0A6F /* XCRemoteSwiftPackageReference "Defaults" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/acvigue/Defaults"; @@ -2248,6 +2348,11 @@ package = 621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */; productName = NukeUI; }; + 6220D0C826D63F3700B8E046 /* Stinsen */ = { + isa = XCSwiftPackageProductDependency; + package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; + productName = Stinsen; + }; 625CB5792678C4A400530A6E /* ActivityIndicator */ = { isa = XCSwiftPackageProductDependency; package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */; @@ -2278,6 +2383,11 @@ package = 5338F755263B7E2E0014BF09 /* XCRemoteSwiftPackageReference "keychain-swift" */; productName = KeychainSwift; }; + 62C29E9B26D0FE4200C1D2E7 /* Stinsen */ = { + isa = XCSwiftPackageProductDependency; + package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; + productName = Stinsen; + }; 62CB3F452685BAF7003D0A6F /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = 62CB3F442685BAF7003D0A6F /* XCRemoteSwiftPackageReference "Defaults" */; diff --git a/JellyfinPlayer.xcworkspace/xcshareddata/swiftpm/Package.resolved b/JellyfinPlayer.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2f82a2bf..d601cf81 100644 --- a/JellyfinPlayer.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/JellyfinPlayer.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -109,6 +109,15 @@ "version": "0.3.1" } }, + { + "package": "Stinsen", + "repositoryURL": "https://github.com/rundfunk47/stinsen", + "state": { + "branch": null, + "revision": "3d06c7603c70f8af1bd49f8d49f17e98f25b2d6a", + "version": "2.0.2" + } + }, { "package": "swift-log", "repositoryURL": "https://github.com/apple/swift-log.git", diff --git a/JellyfinPlayer/Components/CardVStackView.swift b/JellyfinPlayer/Components/EpisodeCardVStackView.swift similarity index 95% rename from JellyfinPlayer/Components/CardVStackView.swift rename to JellyfinPlayer/Components/EpisodeCardVStackView.swift index 84583dd9..b7f2bac8 100644 --- a/JellyfinPlayer/Components/CardVStackView.swift +++ b/JellyfinPlayer/Components/EpisodeCardVStackView.swift @@ -10,9 +10,10 @@ import SwiftUI import JellyfinAPI -struct CardVStackView: View { +struct EpisodeCardVStackView: View { let items: [BaseItemDto] + let selectedAction: (BaseItemDto) -> Void private func buildCardOverlayView(item: BaseItemDto) -> some View { HStack { @@ -45,8 +46,9 @@ struct CardVStackView: View { var body: some View { VStack { ForEach(items, id: \.id) { item in - NavigationLink(destination: ItemNavigationView(item: item)) { - + Button { + selectedAction(item) + } label: { HStack { // MARK: Image diff --git a/JellyfinPlayer/Components/PillHStackView.swift b/JellyfinPlayer/Components/PillHStackView.swift index 3c6fcf06..e0b5d0fc 100644 --- a/JellyfinPlayer/Components/PillHStackView.swift +++ b/JellyfinPlayer/Components/PillHStackView.swift @@ -13,11 +13,12 @@ protocol PillStackable { var title: String { get } } -struct PillHStackView: View { +struct PillHStackView: View { let title: String let items: [ItemType] - let navigationView: (ItemType) -> NavigationView +// let navigationView: (ItemType) -> NavigationView + let selectedAction: (ItemType) -> Void var body: some View { VStack(alignment: .leading) { @@ -30,14 +31,14 @@ struct PillHStackView: View { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(items, id: \.title) { item in - NavigationLink(destination: LazyView { - navigationView(item) - }) { + Button { + selectedAction(item) + } label: { ZStack { Color(UIColor.systemFill) .frame(maxWidth: .infinity, maxHeight: .infinity) .cornerRadius(10) - + Text(item.title) .font(.caption) .fontWeight(.semibold) diff --git a/JellyfinPlayer/Components/PortraitHStackView.swift b/JellyfinPlayer/Components/PortraitHStackView.swift index 96e32645..4d97d34a 100644 --- a/JellyfinPlayer/Components/PortraitHStackView.swift +++ b/JellyfinPlayer/Components/PortraitHStackView.swift @@ -17,20 +17,20 @@ public protocol PortraitImageStackable { var failureInitials: String { get } } -struct PortraitImageHStackView: View { +struct PortraitImageHStackView: View { let items: [ItemType] let maxWidth: Int let horizontalAlignment: HorizontalAlignment let topBarView: () -> TopBarView - let navigationView: (ItemType) -> NavigationView + let selectedAction: (ItemType) -> Void - init(items: [ItemType], maxWidth: Int, horizontalAlignment: HorizontalAlignment = .leading, topBarView: @escaping () -> TopBarView, navigationView: @escaping (ItemType) -> NavigationView) { + init(items: [ItemType], maxWidth: Int, horizontalAlignment: HorizontalAlignment = .leading, topBarView: @escaping () -> TopBarView, selectedAction: @escaping (ItemType) -> Void) { self.items = items self.maxWidth = maxWidth self.horizontalAlignment = horizontalAlignment self.topBarView = topBarView - self.navigationView = navigationView + self.selectedAction = selectedAction } var body: some View { @@ -45,38 +45,36 @@ struct PortraitImageHStackView Path { @@ -31,26 +31,27 @@ struct ProgressBar: Shape { } struct ContinueWatchingView: View { + @EnvironmentObject var homeRouter: HomeCoordinator.Router var items: [BaseItemDto] var body: some View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack { ForEach(items, id: \.id) { item in - NavigationLink(destination: LazyView { ItemNavigationView(item: item) }) { + Button { + homeRouter.route(to: \.item, item) + } label: { VStack(alignment: .leading) { ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash()) .frame(width: 320, height: 180) .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) * 3.2), height: 7) - .padding(0), alignment: .bottomLeading - ) + .overlay(Rectangle() + .fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) + .mask(ProgressBar()) + .frame(width: CGFloat((item.userData?.playedPercentage ?? 0) * 3.2), height: 7) + .padding(0), alignment: .bottomLeading) HStack { Text("\(item.seriesName ?? item.name ?? "")") .font(.callout) @@ -68,11 +69,11 @@ struct ContinueWatchingView: View { Spacer() }.frame(width: 320, alignment: .leading) }.padding(.top, 10) - .padding(.bottom, 5) + .padding(.bottom, 5) } }.padding(.trailing, 16) }.frame(height: 215) - .padding(EdgeInsets(top: 8, leading: 20, bottom: 10, trailing: 2)) + .padding(EdgeInsets(top: 8, leading: 20, bottom: 10, trailing: 2)) } } } diff --git a/JellyfinPlayer/Coordinators/ConnectToServerCoodinator.swift b/JellyfinPlayer/Coordinators/ConnectToServerCoodinator.swift new file mode 100644 index 00000000..5f81bd85 --- /dev/null +++ b/JellyfinPlayer/Coordinators/ConnectToServerCoodinator.swift @@ -0,0 +1,22 @@ +// +/* + * 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 Stinsen +import SwiftUI + +final class ConnectToServerCoodinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \ConnectToServerCoodinator.start) + + @Root var start = makeStart + + @ViewBuilder func makeStart() -> some View { + ConnectToServerView() + } +} diff --git a/JellyfinPlayer/Coordinators/FilterCoordinator.swift b/JellyfinPlayer/Coordinators/FilterCoordinator.swift new file mode 100644 index 00000000..48496d14 --- /dev/null +++ b/JellyfinPlayer/Coordinators/FilterCoordinator.swift @@ -0,0 +1,33 @@ +// +/* + * 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 Stinsen +import SwiftUI + +typealias FilterCoordinatorParams = (filters: Binding, enabledFilterType: [FilterType], parentId: String) + +final class FilterCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \FilterCoordinator.start) + @Root var start = makeStart + + @Binding var filters: LibraryFilters + var enabledFilterType: [FilterType] + var parentId: String = "" + + init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { + _filters = filters + self.enabledFilterType = enabledFilterType + self.parentId = parentId + } + + @ViewBuilder func makeStart() -> some View { + LibraryFilterView(filters: $filters, enabledFilterType: enabledFilterType, parentId: parentId) + } +} diff --git a/JellyfinPlayer/Coordinators/HomeCoordinator.swift b/JellyfinPlayer/Coordinators/HomeCoordinator.swift new file mode 100644 index 00000000..be38b278 --- /dev/null +++ b/JellyfinPlayer/Coordinators/HomeCoordinator.swift @@ -0,0 +1,38 @@ +// +/* + * 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 Stinsen +import SwiftUI + +final class HomeCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \HomeCoordinator.start) + + @Root var start = makeStart + @Route(.modal) var settings = makeSettings + @Route(.push) var library = makeLibrary + @Route(.push) var item = makeItem + + func makeSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator(SettingsCoordinator()) + } + + func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { + LibraryCoordinator(viewModel: params.viewModel, title: params.title) + } + + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } + + @ViewBuilder func makeStart() -> some View { + HomeView() + } +} diff --git a/JellyfinPlayer/Coordinators/ItemCoordinator.swift b/JellyfinPlayer/Coordinators/ItemCoordinator.swift new file mode 100644 index 00000000..d8f4588f --- /dev/null +++ b/JellyfinPlayer/Coordinators/ItemCoordinator.swift @@ -0,0 +1,44 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class ItemCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \ItemCoordinator.start) + + @Root var start = makeStart + @Route(.push) var item = makeItem + @Route(.push) var library = makeLibrary + @Route(.fullScreen) var videoPlayer = makeVideoPlayer + + let itemDto: BaseItemDto + + init(item: BaseItemDto) { + self.itemDto = item + } + + func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { + LibraryCoordinator(viewModel: params.viewModel, title: params.title) + } + + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } + + func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) + } + + @ViewBuilder func makeStart() -> some View { + ItemNavigationView(item: itemDto) + } +} diff --git a/JellyfinPlayer/Coordinators/LibraryCoordinator.swift b/JellyfinPlayer/Coordinators/LibraryCoordinator.swift new file mode 100644 index 00000000..47f45978 --- /dev/null +++ b/JellyfinPlayer/Coordinators/LibraryCoordinator.swift @@ -0,0 +1,50 @@ +// +/* + * 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 Stinsen +import SwiftUI + +typealias LibraryCoordinatorParams = (viewModel: LibraryViewModel, title: String) + +final class LibraryCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \LibraryCoordinator.start) + + @Root var start = makeStart + @Route(.push) var search = makeSearch + @Route(.modal) var filter = makeFilter + @Route(.push) var item = makeItem + + var viewModel: LibraryViewModel + var title: String + + init(viewModel: LibraryViewModel, title: String) { + self.viewModel = viewModel + self.title = title + } + + @ViewBuilder func makeStart() -> some View { + LibraryView(viewModel: self.viewModel, title: title) + } + + func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { + SearchCoordinator(viewModel: viewModel) + } + + func makeFilter(params: FilterCoordinatorParams) -> NavigationViewCoordinator { + NavigationViewCoordinator(FilterCoordinator(filters: params.filters, + enabledFilterType: params.enabledFilterType, + parentId: params.parentId)) + } + + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } +} diff --git a/JellyfinPlayer/Coordinators/LibraryListCoordinator.swift b/JellyfinPlayer/Coordinators/LibraryListCoordinator.swift new file mode 100644 index 00000000..2ff63ad5 --- /dev/null +++ b/JellyfinPlayer/Coordinators/LibraryListCoordinator.swift @@ -0,0 +1,33 @@ +// +/* + * 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 Stinsen +import SwiftUI + +final class LibraryListCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \LibraryListCoordinator.start) + + @Root var start = makeStart + @Route(.push) var search = makeSearch + @Route(.push) var library = makeLibrary + + func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { + LibraryCoordinator(viewModel: params.viewModel, title: params.title) + } + + func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { + SearchCoordinator(viewModel: viewModel) + } + + @ViewBuilder + func makeStart() -> some View { + LibraryListView() + } +} diff --git a/JellyfinPlayer/Coordinators/MainCoordinator.swift b/JellyfinPlayer/Coordinators/MainCoordinator.swift new file mode 100644 index 00000000..0d0f7f29 --- /dev/null +++ b/JellyfinPlayer/Coordinators/MainCoordinator.swift @@ -0,0 +1,88 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import Nuke +import Stinsen +import SwiftUI +#if !os(tvOS) + import WidgetKit +#endif + +#if os(iOS) + final class MainCoordinator: NavigationCoordinatable { + var stack: NavigationStack + + @Root var mainTab = makeMainTab + @Root var connectToServer = makeConnectToServer + + init() { + if ServerEnvironment.current.server != nil, SessionManager.current.user != nil { + self.stack = NavigationStack(initial: \MainCoordinator.mainTab) + } else { + self.stack = NavigationStack(initial: \MainCoordinator.connectToServer) + } + ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory + DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk + + #if !os(tvOS) + WidgetCenter.shared.reloadAllTimelines() + UIScrollView.appearance().keyboardDismissMode = .onDrag + #endif + + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(didLogIn), name: Notification.Name("didSignIn"), object: nil) + nc.addObserver(self, selector: #selector(didLogOut), name: Notification.Name("didSignOut"), object: nil) + nc.addObserver(self, selector: #selector(processDeepLink), name: Notification.Name("processDeepLink"), object: nil) + } + + @objc func didLogIn() { + LogManager.shared.log.info("Received `didSignIn` from NSNotificationCenter.") + root(\.mainTab) + } + + @objc func didLogOut() { + LogManager.shared.log.info("Received `didSignOut` from NSNotificationCenter.") + root(\.connectToServer) + } + + @objc func processDeepLink(_ notification: Notification) { + guard let deepLink = notification.object as? DeepLink else { return } + if let coordinator = hasRoot(\.mainTab) { + switch deepLink { + case let .item(item): + coordinator.focusFirst(\.home) + .child + .popToRoot() + .route(to: \.item, item) + } + } + } + + func makeMainTab() -> MainTabCoordinator { + MainTabCoordinator() + } + + func makeConnectToServer() -> NavigationViewCoordinator { + NavigationViewCoordinator(ConnectToServerCoodinator()) + } + } + +#elseif os(tvOS) + // temp for fixing build error + final class MainCoordinator: NavigationCoordinatable { + var stack = NavigationStack(initial: \MainCoordinator.mainTab) + + @Root var mainTab = makeEmpty + + @ViewBuilder func makeEmpty() -> some View { + EmptyView() + } + } +#endif diff --git a/JellyfinPlayer/Coordinators/MainTabCoordinator.swift b/JellyfinPlayer/Coordinators/MainTabCoordinator.swift new file mode 100644 index 00000000..d5f430ab --- /dev/null +++ b/JellyfinPlayer/Coordinators/MainTabCoordinator.swift @@ -0,0 +1,50 @@ +// +/* + * 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 SwiftUI + +import Stinsen + +final class MainTabCoordinator: TabCoordinatable { + var child = TabChild(startingItems: [ + \MainTabCoordinator.home, + \MainTabCoordinator.allMedia, + ]) + + @Route(tabItem: makeHomeTab) var home = makeHome + @Route(tabItem: makeTodosTab) var allMedia = makeTodos + + func makeHome() -> NavigationViewCoordinator { + return NavigationViewCoordinator(HomeCoordinator()) + } + + @ViewBuilder func makeHomeTab(isActive: Bool) -> some View { + Image(systemName: "house") + Text("Home") + } + + func makeTodos() -> NavigationViewCoordinator { + return NavigationViewCoordinator(LibraryListCoordinator()) + } + + @ViewBuilder func makeTodosTab(isActive: Bool) -> some View { + Image(systemName: "folder") + Text("All Media") + } + + @ViewBuilder func customize(_ view: AnyView) -> some View { + view.onAppear { + AppURLHandler.shared.appURLState = .allowed + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + AppURLHandler.shared.processLaunchedURLIfNeeded() + } + } + } +} diff --git a/JellyfinPlayer/Coordinators/SearchCoordinator.swift b/JellyfinPlayer/Coordinators/SearchCoordinator.swift new file mode 100644 index 00000000..60c761d2 --- /dev/null +++ b/JellyfinPlayer/Coordinators/SearchCoordinator.swift @@ -0,0 +1,34 @@ +// +/* + * 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 Stinsen +import SwiftUI +import JellyfinAPI + +final class SearchCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \SearchCoordinator.start) + + @Root var start = makeStart + @Route(.push) var item = makeItem + + var viewModel: LibrarySearchViewModel + + init(viewModel: LibrarySearchViewModel) { + self.viewModel = viewModel + } + + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } + + @ViewBuilder func makeStart() -> some View { + LibrarySearchView(viewModel: self.viewModel) + } +} diff --git a/JellyfinPlayer/Coordinators/SettingsCoordinator.swift b/JellyfinPlayer/Coordinators/SettingsCoordinator.swift new file mode 100644 index 00000000..cbf6b1e0 --- /dev/null +++ b/JellyfinPlayer/Coordinators/SettingsCoordinator.swift @@ -0,0 +1,27 @@ +// +/* + * 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 Stinsen +import SwiftUI + +final class SettingsCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \SettingsCoordinator.start) + + @Root var start = makeStart + @Route(.push) var serverDetail = makeServerDetail + + @ViewBuilder func makeServerDetail() -> some View { + ServerDetailView() + } + + @ViewBuilder func makeStart() -> some View { + SettingsView(viewModel: .init()) + } +} diff --git a/JellyfinPlayer/Coordinators/VideoPlayerCoordinator.swift b/JellyfinPlayer/Coordinators/VideoPlayerCoordinator.swift new file mode 100644 index 00000000..ebe38123 --- /dev/null +++ b/JellyfinPlayer/Coordinators/VideoPlayerCoordinator.swift @@ -0,0 +1,28 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class VideoPlayerCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) + + @Root var start = makeStart + var item: BaseItemDto + + init(item: BaseItemDto) { + self.item = item + } + + @ViewBuilder func makeStart() -> some View { + VideoPlayerView(item: item) + } +} diff --git a/JellyfinPlayer/DeepLink.swift b/JellyfinPlayer/DeepLink.swift new file mode 100644 index 00000000..9270682f --- /dev/null +++ b/JellyfinPlayer/DeepLink.swift @@ -0,0 +1,15 @@ +// +/* + * 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 DeepLink { + case item(BaseItemDto) +} diff --git a/JellyfinPlayer/HomeView.swift b/JellyfinPlayer/HomeView.swift index 797f8e06..05687d0e 100644 --- a/JellyfinPlayer/HomeView.swift +++ b/JellyfinPlayer/HomeView.swift @@ -11,9 +11,9 @@ import Foundation import SwiftUI struct HomeView: View { + @EnvironmentObject var homeRouter: HomeCoordinator.Router @StateObject var viewModel = HomeViewModel() - @State var showingSettings = false - + init() { let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill") let barAppearance = UINavigationBar.appearance() @@ -43,16 +43,19 @@ struct HomeView: View { .font(.title2) .fontWeight(.bold) Spacer() - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? "") - }) { + Button { + homeRouter + .route(to: \.library, (viewModel: .init(parentID: libraryID, + filters: viewModel.recentFilterSet), + title: library?.name ?? "")) + } label: { 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)) } } @@ -68,14 +71,11 @@ struct HomeView: View { .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { Button { - showingSettings = true + homeRouter.route(to: \.settings) } label: { Image(systemName: "gear") } } } - .fullScreenCover(isPresented: $showingSettings) { - SettingsView(viewModel: SettingsViewModel(), close: $showingSettings) - } } } diff --git a/JellyfinPlayer/Info.plist b/JellyfinPlayer/Info.plist index 5121b455..b8c7ecaf 100644 --- a/JellyfinPlayer/Info.plist +++ b/JellyfinPlayer/Info.plist @@ -18,6 +18,17 @@ $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + jellyfin + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption diff --git a/JellyfinPlayer/ItemView/ItemView.swift b/JellyfinPlayer/ItemView/ItemView.swift index e3669681..1fb4af4a 100644 --- a/JellyfinPlayer/ItemView/ItemView.swift +++ b/JellyfinPlayer/ItemView/ItemView.swift @@ -5,41 +5,41 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI import Introspect import JellyfinAPI +import SwiftUI class VideoPlayerItem: ObservableObject { @Published var shouldShowPlayer: Bool = false - @Published var itemToPlay: BaseItemDto = BaseItemDto() + @Published var itemToPlay = 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 { +private struct ItemView: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router - @State private var videoIsLoading: Bool = false; // This variable is only changed by the underlying VLC 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() + @StateObject private var videoPlayerItem = VideoPlayerItem() @Environment(\.horizontalSizeClass) private var hSizeClass @Environment(\.verticalSizeClass) private var vSizeClass - + private let viewModel: ItemViewModel - + init(item: BaseItemDto) { switch item.itemType { case .movie: @@ -56,14 +56,20 @@ fileprivate struct ItemView: View { } var body: some View { - if hSizeClass == .compact && vSizeClass == .regular { - ItemPortraitMainView(videoIsLoading: $videoIsLoading) - .environmentObject(videoPlayerItem) - .environmentObject(viewModel) - } else { - ItemLandscapeMainView(videoIsLoading: $videoIsLoading) - .environmentObject(videoPlayerItem) - .environmentObject(viewModel) + Group { + if hSizeClass == .compact && vSizeClass == .regular { + ItemPortraitMainView(videoIsLoading: $videoIsLoading) + .environmentObject(videoPlayerItem) + .environmentObject(viewModel) + } else { + ItemLandscapeMainView(videoIsLoading: $videoIsLoading) + .environmentObject(videoPlayerItem) + .environmentObject(viewModel) + } + } + .onReceive(videoPlayerItem.$shouldShowPlayer) { flag in + guard flag else { return } + self.itemRouter.route(to: \.videoPlayer, viewModel.item) } } } diff --git a/JellyfinPlayer/ItemView/ItemViewBody.swift b/JellyfinPlayer/ItemView/ItemViewBody.swift index 0050b01c..3c819b25 100644 --- a/JellyfinPlayer/ItemView/ItemViewBody.swift +++ b/JellyfinPlayer/ItemView/ItemViewBody.swift @@ -1,29 +1,30 @@ // - /* - * 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 SwiftUI import JellyfinAPI +import SwiftUI struct ItemViewBody: View { - + @EnvironmentObject var itemRouter: ItemCoordinator.Router @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, @@ -33,28 +34,32 @@ struct ItemViewBody: View { .fontWeight(.semibold) .padding(.top, 3) .padding(.leading, 16) - }, navigationView: { season in - ItemNavigationView(item: season) + }, selectedAction: { season in + itemRouter.route(to: \.item, season) }) } - + // MARK: Genres + PillHStackView(title: "Genres", - items: viewModel.item.genreItems ?? []) { genre in - LibraryView(viewModel: .init(genre: genre), title: genre.title) - } - + items: viewModel.item.genreItems ?? [], + selectedAction: { genre in + itemRouter.route(to: \.library, (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 ?? "") + itemRouter.route(to: \.library, (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 ?? "") }), + PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }, maxWidth: 150, topBarView: { Text("Cast & Crew") @@ -63,12 +68,13 @@ struct ItemViewBody: View { .padding(.top, 3) .padding(.leading, 16) }, - navigationView: { person in - LibraryView(viewModel: .init(person: person), title: person.title) + selectedAction: { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) }) } // MARK: More Like This + if !viewModel.similarItems.isEmpty { PortraitImageHStackView(items: viewModel.similarItems, maxWidth: 150, @@ -79,8 +85,8 @@ struct ItemViewBody: View { .padding(.top, 3) .padding(.leading, 16) }, - navigationView: { item in - ItemNavigationView(item: item) + selectedAction: { item in + itemRouter.route(to: \.item, item) }) } } diff --git a/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift index 4ebb9061..22390b34 100644 --- a/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/JellyfinPlayer/ItemView/Landscape/ItemLandscapeMainView.swift @@ -1,45 +1,47 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +/* + * 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 ItemLandscapeMainView: View { - + @EnvironmentObject var itemRouter: ItemCoordinator.Router @Binding private var videoIsLoading: Bool @EnvironmentObject private var viewModel: ItemViewModel @EnvironmentObject private var videoPlayerItem: VideoPlayerItem - + init(videoIsLoading: Binding) { self._videoIsLoading = videoIsLoading } - + // MARK: innerBody + private var innerBody: some View { HStack { - // MARK: Sidebar Image + VStack { ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 130), bh: viewModel.item.getPrimaryImageBlurHash()) .frame(width: 130, height: 195) .cornerRadius(10) - + Spacer().frame(height: 15) - + Button { if let playButtonItem = viewModel.playButtonItem { self.videoPlayerItem.itemToPlay = playButtonItem self.videoPlayerItem.shouldShowPlayer = true } } label: { - // MARK: Play + HStack { Image(systemName: "play.fill") .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) @@ -53,20 +55,23 @@ struct ItemLandscapeMainView: View { .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) + EpisodeCardVStackView(items: episodeViewModel.episodes) { episode in + itemRouter.route(to: \.item, episode) + } } else { ItemViewBody() .environmentObject(viewModel) @@ -75,32 +80,20 @@ struct ItemLandscapeMainView: View { } } } - + // 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 { diff --git a/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift b/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift index 7ccac0ee..259af534 100644 --- a/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift +++ b/JellyfinPlayer/ItemView/Portrait/ItemPortraitMainView.swift @@ -1,69 +1,61 @@ // - /* - * 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 SwiftUI import JellyfinAPI +import SwiftUI struct ItemPortraitMainView: View { - + @EnvironmentObject var itemRouter: ItemCoordinator.Router @Binding private var videoIsLoading: Bool @EnvironmentObject private var viewModel: ItemViewModel @EnvironmentObject private var videoPlayerItem: VideoPlayerItem - + init(videoIsLoading: Binding) { self._videoIsLoading = videoIsLoading } - + // MARK: portraitHeaderView + var portraitHeaderView: some View { ImageView(src: viewModel.item.getBackdropImage(maxWidth: 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) + EpisodeCardVStackView(items: episodeViewModel.episodes) { episode in + itemRouter.route(to: \.item, episode) + } + .padding(.top, 5) } else { ItemViewBody() .environmentObject(viewModel) diff --git a/JellyfinPlayer/JellyfinPlayerApp.swift b/JellyfinPlayer/JellyfinPlayerApp.swift index 3a08efc1..74e2cedd 100644 --- a/JellyfinPlayer/JellyfinPlayerApp.swift +++ b/JellyfinPlayer/JellyfinPlayerApp.swift @@ -5,9 +5,10 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI -import MessageUI import Defaults +import MessageUI +import Stinsen +import SwiftUI // The notification we'll send when a shake gesture happens. extension UIDevice { @@ -16,18 +17,18 @@ extension UIDevice { // Override the default behavior of shake gestures to send our notification instead. extension UIWindow { - open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { + override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { if motion == .motionShake { NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil) } - } + } } // A view modifier that detects shaking and calls a function of our choosing. struct DeviceShakeViewModifier: ViewModifier { let action: () -> Void - func body(content: Content) -> some View { + func body(content: Self.Content) -> some View { content .onAppear() .onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in @@ -39,20 +40,20 @@ struct DeviceShakeViewModifier: ViewModifier { // A View extension to make the modifier easier to use. extension View { func onShake(perform action: @escaping () -> Void) -> some View { - self.modifier(DeviceShakeViewModifier(action: action)) + modifier(DeviceShakeViewModifier(action: action)) } } extension UIDevice { var hasNotch: Bool { - let bottom = UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.safeAreaInsets.bottom ?? 0 + let bottom = UIApplication.shared.windows.filter { $0.isKeyWindow }.first?.safeAreaInsets.bottom ?? 0 return bottom > 0 } } extension View { func withHostingWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View { - self.background(HostingWindowFinder(callback: callback)) + background(HostingWindowFinder(callback: callback)) } } @@ -67,8 +68,7 @@ struct HostingWindowFinder: UIViewRepresentable { return view } - func updateUIView(_ uiView: UIView, context: Context) { - } + func updateUIView(_ uiView: UIView, context: Context) {} } struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey { @@ -105,18 +105,17 @@ class PreferenceUIHostingController: UIHostingController { init(wrappedView: V) { let box = Box() super.init(rootView: AnyView(wrappedView - .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) { - box.value?._prefersHomeIndicatorAutoHidden = $0 - }.onPreferenceChange(SupportedOrientationsPreferenceKey.self) { - box.value?._orientations = $0 - }.onPreferenceChange(ViewPreferenceKey.self) { - box.value?._viewPreference = $0 - } - )) + .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) { + box.value?._prefersHomeIndicatorAutoHidden = $0 + }.onPreferenceChange(SupportedOrientationsPreferenceKey.self) { + box.value?._orientations = $0 + }.onPreferenceChange(ViewPreferenceKey.self) { + box.value?._viewPreference = $0 + })) box.value = self } - @objc required dynamic init?(coder aDecoder: NSCoder) { + @objc dynamic required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) super.modalPresentationStyle = .fullScreen } @@ -131,6 +130,7 @@ class PreferenceUIHostingController: UIHostingController { public var _prefersHomeIndicatorAutoHidden = false { didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() } } + override var prefersHomeIndicatorAutoHidden: Bool { _prefersHomeIndicatorAutoHidden } @@ -146,6 +146,7 @@ class PreferenceUIHostingController: UIHostingController { } } } + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { _orientations } @@ -176,7 +177,7 @@ extension View { class EmailHelper: NSObject, MFMailComposeViewControllerDelegate { public static let shared = EmailHelper() - private override init() { + override private init() { // } @@ -192,7 +193,9 @@ class EmailHelper: NSObject, MFMailComposeViewControllerDelegate { let data = fileManager.contents(atPath: logURL.path) picker.setSubject("[DEV-BUG] SwiftFin") - picker.setMessageBody("Please don't edit this email.\n Please don't change the subject. \nUDID: \(UIDevice.current.identifierForVendor?.uuidString ?? "NIL")\n", isHTML: false) + picker + .setMessageBody("Please don't edit this email.\n Please don't change the subject. \nUDID: \(UIDevice.current.identifierForVendor?.uuidString ?? "NIL")\n", + isHTML: false) picker.setToRecipients(["SwiftFin Bug Reports "]) picker.addAttachmentData(data!, mimeType: "text/plain", fileName: logURL.lastPathComponent) picker.mailComposeDelegate = self @@ -218,17 +221,22 @@ struct JellyfinPlayerApp: App { var body: some Scene { WindowGroup { - SplashView() + EmptyView() .environment(\.managedObjectContext, persistenceController.container.viewContext) .onAppear(perform: { setupAppearance() }) .withHostingWindow { window in - window?.rootViewController = PreferenceUIHostingController(wrappedView: SplashView().environment(\.managedObjectContext, persistenceController.container.viewContext)) + window? + .rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view() + .environment(\.managedObjectContext, persistenceController.container.viewContext)) } .onShake { EmailHelper.shared.sendLogs(logURL: LogManager.shared.logFileURL()) } + .onOpenURL { url in + AppURLHandler.shared.processDeepLink(url: url) + } } } @@ -238,7 +246,6 @@ struct JellyfinPlayerApp: App { } class AppDelegate: NSObject, UIApplicationDelegate { - static var orientationLock = UIInterfaceOrientationMask.all func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { diff --git a/JellyfinPlayer/LatestMediaView.swift b/JellyfinPlayer/LatestMediaView.swift index 902891de..300f435e 100644 --- a/JellyfinPlayer/LatestMediaView.swift +++ b/JellyfinPlayer/LatestMediaView.swift @@ -5,16 +5,20 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Stinsen import SwiftUI struct LatestMediaView: View { + @EnvironmentObject var homeRouter: HomeCoordinator.Router @StateObject var viewModel: LatestMediaViewModel var body: some View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack { ForEach(viewModel.items, id: \.id) { item in - if item.type == "Series" || item.type == "Movie" { + Button { + homeRouter.route(to: \.item, item) + } label: { PortraitItemView(item: item) } }.padding(.trailing, 16) diff --git a/JellyfinPlayer/LibraryFilterView.swift b/JellyfinPlayer/LibraryFilterView.swift index 8c01231f..0a96a459 100644 --- a/JellyfinPlayer/LibraryFilterView.swift +++ b/JellyfinPlayer/LibraryFilterView.swift @@ -6,9 +6,11 @@ */ import JellyfinAPI +import Stinsen import SwiftUI struct LibraryFilterView: View { + @EnvironmentObject var filterRouter: FilterCoordinator.Router @Environment(\.presentationMode) var presentationMode @Binding var filters: LibraryFilters var parentId: String = "" @@ -18,75 +20,74 @@ struct LibraryFilterView: View { init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { _filters = filters self.parentId = parentId - _viewModel = StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId)) + _viewModel = + StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId)) } var body: some View { - NavigationView { - VStack { - if viewModel.isLoading { - ProgressView() - } else { - Form { - if viewModel.enabledFilterType.contains(.genre) { - MultiSelector(label: NSLocalizedString("Genres", comment: ""), - options: viewModel.possibleGenres, - optionToString: { $0.name ?? "" }, - selected: $viewModel.modifiedFilters.withGenres) - } - if viewModel.enabledFilterType.contains(.filter) { - MultiSelector(label: NSLocalizedString("Filters", comment: ""), - options: viewModel.possibleItemFilters, - optionToString: { $0.localized }, - selected: $viewModel.modifiedFilters.filters) - } - if viewModel.enabledFilterType.contains(.tag) { - MultiSelector(label: NSLocalizedString("Tags", comment: ""), - options: viewModel.possibleTags, - optionToString: { $0 }, - selected: $viewModel.modifiedFilters.tags) - } - if viewModel.enabledFilterType.contains(.sortBy) { - Picker(selection: $viewModel.selectedSortBy, label: Text("Sort by")) { - ForEach(viewModel.possibleSortBys, id: \.self) { so in - Text(so.localized).tag(so) - } - } - } - if viewModel.enabledFilterType.contains(.sortOrder) { - Picker(selection: $viewModel.selectedSortOrder, label: Text("Display order")) { - ForEach(viewModel.possibleSortOrders, id: \.self) { so in - Text(so.rawValue).tag(so) - } + VStack { + if viewModel.isLoading { + ProgressView() + } else { + Form { + if viewModel.enabledFilterType.contains(.genre) { + MultiSelector(label: NSLocalizedString("Genres", comment: ""), + options: viewModel.possibleGenres, + optionToString: { $0.name ?? "" }, + selected: $viewModel.modifiedFilters.withGenres) + } + if viewModel.enabledFilterType.contains(.filter) { + MultiSelector(label: NSLocalizedString("Filters", comment: ""), + options: viewModel.possibleItemFilters, + optionToString: { $0.localized }, + selected: $viewModel.modifiedFilters.filters) + } + if viewModel.enabledFilterType.contains(.tag) { + MultiSelector(label: NSLocalizedString("Tags", comment: ""), + options: viewModel.possibleTags, + optionToString: { $0 }, + selected: $viewModel.modifiedFilters.tags) + } + if viewModel.enabledFilterType.contains(.sortBy) { + Picker(selection: $viewModel.selectedSortBy, label: Text("Sort by")) { + ForEach(viewModel.possibleSortBys, id: \.self) { so in + Text(so.localized).tag(so) } } } - Button { - viewModel.resetFilters() - self.filters = viewModel.modifiedFilters - presentationMode.wrappedValue.dismiss() - } label: { - Text("Reset") + if viewModel.enabledFilterType.contains(.sortOrder) { + Picker(selection: $viewModel.selectedSortOrder, label: Text("Display order")) { + ForEach(viewModel.possibleSortOrders, id: \.self) { so in + Text(so.rawValue).tag(so) + } + } } } + Button { + viewModel.resetFilters() + self.filters = viewModel.modifiedFilters + filterRouter.dismissCoordinator() + } label: { + Text("Reset") + } } - .navigationBarTitle(NSLocalizedString("Filter Results", comment: ""), displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - presentationMode.wrappedValue.dismiss() - } label: { - Image(systemName: "xmark") - } + } + .navigationBarTitle(NSLocalizedString("Filter Results", comment: ""), displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + filterRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark") } - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - viewModel.updateModifiedFilter() - self.filters = viewModel.modifiedFilters - presentationMode.wrappedValue.dismiss() - } label: { - Text("Apply") - } + } + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + viewModel.updateModifiedFilter() + self.filters = viewModel.modifiedFilters + filterRouter.dismissCoordinator() + } label: { + Text("Apply") } } } diff --git a/JellyfinPlayer/LibraryListView.swift b/JellyfinPlayer/LibraryListView.swift index 059cd1de..baac87b8 100644 --- a/JellyfinPlayer/LibraryListView.swift +++ b/JellyfinPlayer/LibraryListView.swift @@ -6,17 +6,20 @@ */ import Foundation +import Stinsen import SwiftUI struct LibraryListView: View { + @EnvironmentObject var libraryListRouter: LibraryListCoordinator.Router @StateObject var viewModel = LibraryListViewModel() var body: some View { ScrollView { LazyVStack { - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(filters: viewModel.withFavorites), title: "Favorites") - }) { + Button { + libraryListRouter.route(to: \.library, + (viewModel: LibraryViewModel(filters: viewModel.withFavorites), title: "Favorites")) + } label: { ZStack { HStack { Spacer() @@ -59,9 +62,11 @@ struct LibraryListView: View { if !viewModel.isLoading { ForEach(viewModel.libraries, id: \.id) { library in if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" { - NavigationLink(destination: LazyView { - LibraryView(viewModel: .init(parentID: library.id), title: library.name ?? "") - }) { + Button { + libraryListRouter.route(to: \.library, + (viewModel: LibraryViewModel(parentID: library.id), + title: library.name ?? "")) + } label: { ZStack { ImageView(src: library.getPrimaryImage(maxWidth: 500), bh: library.getPrimaryImageBlurHash()) .opacity(0.4) @@ -76,8 +81,8 @@ struct LibraryListView: View { Spacer() }.padding(32) }.background(Color.black) - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) } .cornerRadius(10) .shadow(radius: 5) @@ -90,15 +95,15 @@ struct LibraryListView: View { ProgressView() } }.padding(.leading, 16) - .padding(.trailing, 16) - .padding(.top, 8) + .padding(.trailing, 16) + .padding(.top, 8) } .navigationTitle(NSLocalizedString("All Media", comment: "")) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { - NavigationLink(destination: LazyView { - LibrarySearchView(viewModel: .init(parentID: nil)) - }) { + Button { + libraryListRouter.route(to: \.search, LibrarySearchViewModel(parentID: nil)) + } label: { Image(systemName: "magnifyingglass") } } diff --git a/JellyfinPlayer/LibrarySearchView.swift b/JellyfinPlayer/LibrarySearchView.swift index 65b63b34..dc27b5da 100644 --- a/JellyfinPlayer/LibrarySearchView.swift +++ b/JellyfinPlayer/LibrarySearchView.swift @@ -7,11 +7,13 @@ import Combine import JellyfinAPI +import Stinsen import SwiftUI struct LibrarySearchView: View { + @EnvironmentObject var searchRouter: SearchCoordinator.Router @StateObject var viewModel: LibrarySearchViewModel - @State var searchQuery = "" + @State private var searchQuery = "" @State private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) @@ -78,7 +80,11 @@ struct LibrarySearchView: View { if !items.isEmpty { LazyVGrid(columns: tracks) { ForEach(items, id: \.id) { item in - PortraitItemView(item: item) + Button { + searchRouter.route(to: \.item, item) + } label: { + PortraitItemView(item: item) + } } } .padding(.bottom, 16) @@ -106,7 +112,6 @@ struct LibrarySearchView: View { } private extension ItemType { - var localized: String { switch self { case .episode: diff --git a/JellyfinPlayer/LibraryView.swift b/JellyfinPlayer/LibraryView.swift index ee6b6a48..b0fb15b5 100644 --- a/JellyfinPlayer/LibraryView.swift +++ b/JellyfinPlayer/LibraryView.swift @@ -6,17 +6,17 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Stinsen import SwiftUI struct LibraryView: View { + @EnvironmentObject var libraryRouter: LibraryCoordinator.Router @StateObject var viewModel: LibraryViewModel var title: String // MARK: tracks for grid - var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) - @State var isShowingSearchView = false - @State var isShowingFilterView = false + var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) @State private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) @@ -35,7 +35,11 @@ struct LibraryView: View { LazyVGrid(columns: tracks) { ForEach(viewModel.items, id: \.id) { item in if item.type != "Folder" { - PortraitItemView(item: item) + Button { + libraryRouter.route(to: \.item, item) + } label: { + PortraitItemView(item: item) + } } } }.onRotate { _ in @@ -89,26 +93,19 @@ struct LibraryView: View { }.disabled(viewModel.isLoading) } Label("Icon One", systemImage: "line.horizontal.3.decrease.circle") - .foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange)) - .onTapGesture { - isShowingFilterView = true - } + .foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange)) + .onTapGesture { + libraryRouter + .route(to: \.filter, (filters: $viewModel.filters, enabledFilterType: viewModel.enabledFilterType, + parentId: viewModel.parentID ?? "")) + } Button { - isShowingSearchView = true + libraryRouter.route(to: \.search, .init(parentID: viewModel.parentID)) } label: { Image(systemName: "magnifyingglass") } } } - .sheet(isPresented: $isShowingFilterView) { - LibraryFilterView(filters: $viewModel.filters, enabledFilterType: viewModel.enabledFilterType, parentId: viewModel.parentID ?? "") - } - .background( - NavigationLink(destination: LibrarySearchView(viewModel: .init(parentID: viewModel.parentID)), - isActive: $isShowingSearchView) { - EmptyView() - } - ) } } diff --git a/JellyfinPlayer/MainTabView.swift b/JellyfinPlayer/MainTabView.swift deleted file mode 100644 index fabf9b8c..00000000 --- a/JellyfinPlayer/MainTabView.swift +++ /dev/null @@ -1,46 +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 SwiftUI - -struct MainTabView: View { - @State private var tabSelection: Tab = .home - - var body: some View { - TabView(selection: $tabSelection) { - NavigationView { - HomeView() - } - .navigationViewStyle(StackNavigationViewStyle()) - .tabItem { - Text("Home") - Image(systemName: "house") - } - .tag(Tab.home) - NavigationView { - LibraryListView() - } - .navigationViewStyle(StackNavigationViewStyle()) - .tabItem { - Text("All Media") - Image(systemName: "folder") - } - .tag(Tab.allMedia) - } - } -} - -extension MainTabView { - - enum Tab: String { - case home - case allMedia - } -} diff --git a/JellyfinPlayer/NextUpView.swift b/JellyfinPlayer/NextUpView.swift index c0301df1..5cdd5467 100644 --- a/JellyfinPlayer/NextUpView.swift +++ b/JellyfinPlayer/NextUpView.swift @@ -5,11 +5,13 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI import Combine import JellyfinAPI +import Stinsen +import SwiftUI struct NextUpView: View { + @EnvironmentObject var homeRouter: HomeCoordinator.Router var items: [BaseItemDto] @@ -22,7 +24,11 @@ struct NextUpView: View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack { ForEach(items, id: \.id) { item in - PortraitItemView(item: item) + Button { + homeRouter.route(to: \.item, item) + } label: { + PortraitItemView(item: item) + } }.padding(.trailing, 16) } .padding(.leading, 20) diff --git a/JellyfinPlayer/SettingsView.swift b/JellyfinPlayer/SettingsView.swift index 47c98512..c309b8c2 100644 --- a/JellyfinPlayer/SettingsView.swift +++ b/JellyfinPlayer/SettingsView.swift @@ -6,15 +6,16 @@ */ import CoreData -import SwiftUI import Defaults +import Stinsen +import SwiftUI struct SettingsView: View { + @EnvironmentObject var settingsRouter: SettingsCoordinator.Router @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 @@ -25,101 +26,104 @@ struct SettingsView: View { @Default(.videoPlayerJumpBackward) var jumpBackwardLength var body: some View { - NavigationView { - Form { - Section(header: EmptyView()) { + Form { + Section(header: EmptyView()) { + HStack { + Text("User") + Spacer() + Text(SessionManager.current.user?.username ?? "") + .foregroundColor(.jellyfinPurple) + } + + Button { + settingsRouter.route(to: \.serverDetail) + } label: { HStack { - Text("User") + Text("Server") Spacer() - Text(SessionManager.current.user.username ?? "") + Text(ServerEnvironment.current.server?.name ?? "") .foregroundColor(.jellyfinPurple) - } - 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) - } + Image(systemName: "chevron.right") } } - Section(header: Text("Accessibility")) { - Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles) - SearchablePicker(label: "Preferred subtitle language", - options: viewModel.langs, - optionToString: { $0.name }, - selected: Binding( - 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( - 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 - }) + Button { + settingsRouter.dismissCoordinator() + 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) } } - .navigationBarTitle("Settings", displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - close = false - } label: { - Image(systemName: "xmark") + 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) + } + } + } + + Section(header: Text("Accessibility")) { + Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles) + SearchablePicker(label: "Preferred subtitle language", + options: viewModel.langs, + optionToString: { $0.name }, + selected: Binding(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(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: { _ in + UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style + }) + } + } + .navigationBarTitle("Settings", displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + settingsRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark") + } } } } diff --git a/JellyfinPlayer/Singleton/AppURLHandler.swift b/JellyfinPlayer/Singleton/AppURLHandler.swift new file mode 100644 index 00000000..9dcb5eaf --- /dev/null +++ b/JellyfinPlayer/Singleton/AppURLHandler.swift @@ -0,0 +1,110 @@ +// +/* + * 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 Combine +import Foundation +import JellyfinAPI +import Stinsen + +final class AppURLHandler { + static let deepLinkScheme = "jellyfin" + + enum AppURLState { + case launched + case allowedInLogin + case allowed + + func allowedScheme(with url: URL) -> Bool { + switch self { + case .launched: + return false + case .allowed: + return true + case .allowedInLogin: + return false + } + } + } + + static let shared = AppURLHandler() + + var cancellables = Set() + + var appURLState: AppURLState = .launched + var launchURL: URL? +} + +extension AppURLHandler { + @discardableResult + func processDeepLink(url: URL) -> Bool { + guard url.scheme == Self.deepLinkScheme || url.scheme == "widget-extension" else { + return false + } + if AppURLHandler.shared.appURLState.allowedScheme(with: url) { + return processURL(url) + } else { + launchURL = url + } + return true + } + + func processLaunchedURLIfNeeded() { + guard let launchURL = launchURL, + !launchURL.absoluteString.isEmpty else { return } + if processDeepLink(url: launchURL) { + self.launchURL = nil + } + } + + private func processURL(_ url: URL) -> Bool { + if processURLForUser(url: url) { + return true + } + + return false + } + + private func processURLForUser(url: URL) -> Bool { + guard url.host?.lowercased() == "users", + url.pathComponents[safe: 1]?.isEmpty == false else { return false } + + // /Users/{UserID}/Items/{ItemID} + if url.pathComponents[safe: 2]?.lowercased() == "items", + let userID = url.pathComponents[safe: 1], + let itemID = url.pathComponents[safe: 3] + { + // It would be nice if the ItemViewModel could be initialized to id later. + getItem(userID: userID, itemID: itemID) { item in + guard let item = item else { return } + NotificationCenter.default.post(name: Notification.Name("processDeepLink"), object: DeepLink.item(item)) + } + + return true + } + + return false + } +} + +extension AppURLHandler { + func getItem(userID: String, itemID: String, completion: @escaping (BaseItemDto?) -> Void) { + UserLibraryAPI.getItem(userId: userID, itemId: itemID) + .sink(receiveCompletion: { innerCompletion in + switch innerCompletion { + case .failure: + completion(nil) + default: + break + } + }, receiveValue: { item in + completion(item) + }) + .store(in: &cancellables) + } +} diff --git a/JellyfinPlayer/SplashView.swift b/JellyfinPlayer/SplashView.swift index 1b264644..1235572e 100644 --- a/JellyfinPlayer/SplashView.swift +++ b/JellyfinPlayer/SplashView.swift @@ -7,19 +7,21 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Stinsen import SwiftUI struct SplashView: View { + @EnvironmentObject var mainRouter: MainCoordinator.Router @StateObject var viewModel = SplashViewModel() var body: some View { - if viewModel.isLoggedIn { - MainTabView() - } else { - NavigationView { - ConnectToServerView() + ProgressView() + .onReceive(viewModel.$isLoggedIn) { flag in + if flag { + mainRouter.root(\.mainTab) + } else { + mainRouter.root(\.connectToServer) + } } - .navigationViewStyle(StackNavigationViewStyle()) - } } } diff --git a/JellyfinPlayer/VideoPlayer.swift b/JellyfinPlayer/VideoPlayer.swift index 8bc7b121..cf29da12 100644 --- a/JellyfinPlayer/VideoPlayer.swift +++ b/JellyfinPlayer/VideoPlayer.swift @@ -5,14 +5,15 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI -import MobileVLCKit +import Combine +import Defaults +import GoogleCast import JellyfinAPI import MediaPlayer -import Combine -import GoogleCast +import MobileVLCKit +import Stinsen +import SwiftUI import SwiftyJSON -import Defaults enum PlayerDestination { case remote @@ -26,6 +27,8 @@ protocol PlayerViewControllerDelegate: AnyObject { } class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRemoteMediaClientListener { + @RouterObject + var main: MainCoordinator.Router? weak var delegate: PlayerViewControllerDelegate? @@ -64,9 +67,11 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe private var castDiscoveryManager: GCKDiscoveryManager { return GCKCastContext.sharedInstance().discoveryManager } + private var castSessionManager: GCKSessionManager { return GCKCastContext.sharedInstance().sessionManager } + var hasSentRemoteSeek: Bool = false var selectedPlaybackSpeedIndex: Int = 3 @@ -80,17 +85,19 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe var jumpForwardLength: VideoPlayerJumpLength { return Defaults[.videoPlayerJumpForward] } + var jumpBackwardLength: VideoPlayerJumpLength { return Defaults[.videoPlayerJumpBackward] } - var manifest: BaseItemDto = BaseItemDto() + var manifest = BaseItemDto() var playbackItem = PlaybackItem() var remoteTimeUpdateTimer: Timer? - var upNextViewModel: UpNextViewModel = UpNextViewModel() + var upNextViewModel = UpNextViewModel() var lastOri: UIInterfaceOrientation? // MARK: IBActions + @IBAction func seekSliderStart(_ sender: Any) { if playerDestination == .local { sendProgressReport(eventName: "pause") @@ -101,7 +108,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } @IBAction func seekSliderValueChanged(_ sender: Any) { - let videoDuration: Double = Double(manifest.runTimeTicks! / Int64(10_000_000)) + let videoDuration = Double(manifest.runTimeTicks! / Int64(10_000_000)) let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration) let secondsScrubbedRemaining = videoDuration - secondsScrubbedTo @@ -111,15 +118,17 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe private func calculateTimeText(from duration: Double) -> String { let hours = floor(duration / 3600) - let minutes = (duration.truncatingRemainder(dividingBy: 3600)) / 60 - let seconds = (duration.truncatingRemainder(dividingBy: 3600)).truncatingRemainder(dividingBy: 60) + let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 + let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) let timeText: String if hours != 0 { - timeText = "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" + timeText = + "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" } else { - timeText = "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" + timeText = + "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" } return timeText @@ -127,7 +136,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe @IBAction func seekSliderEnd(_ sender: Any) { isSeeking = false - let videoPosition = playerDestination == .local ? Double(mediaPlayer.time.intValue / 1000) : Double(remotePositionTicks / Int(10_000_000)) + let videoPosition = playerDestination == .local ? Double(mediaPlayer.time.intValue / 1000) : + Double(remotePositionTicks / Int(10_000_000)) let videoDuration = Double(manifest.runTimeTicks! / Int64(10_000_000)) // Scrub is value from 0..1 - find position in video and add / or remove. let secondsScrubbedTo = round(Double(seekSlider.value) * videoDuration) @@ -143,7 +153,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe sendProgressReport(eventName: "unpause") } else { sendJellyfinCommand(command: "Seek", options: [ - "position": Int(secondsScrubbedTo) + "position": Int(secondsScrubbedTo), ]) } } @@ -180,7 +190,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe if playerDestination == .local { mediaPlayer.jumpBackward(jumpBackwardLength.rawValue) } else { - self.sendJellyfinCommand(command: "Seek", options: ["position": (remotePositionTicks/10_000_000) - Int(jumpBackwardLength.rawValue)]) + sendJellyfinCommand(command: "Seek", + options: ["position": (remotePositionTicks / 10_000_000) - Int(jumpBackwardLength.rawValue)]) } } } @@ -190,7 +201,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe if playerDestination == .local { mediaPlayer.jumpForward(jumpForwardLength.rawValue) } else { - self.sendJellyfinCommand(command: "Seek", options: ["position": (remotePositionTicks/10_000_000) + Int(jumpForwardLength.rawValue)]) + sendJellyfinCommand(command: "Seek", + options: ["position": (remotePositionTicks / 10_000_000) + Int(jumpForwardLength.rawValue)]) } } } @@ -228,7 +240,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe optionsVC?.popoverPresentationController?.sourceView = playerSettingsButton // Present the view controller (in a popover). - self.present(optionsVC!, animated: true) { + present(optionsVC!, animated: true) { print("popover visible, pause playback") self.mediaPlayer.pause() self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) @@ -236,6 +248,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } // MARK: Cast methods + @IBAction func castButtonPressed(_ sender: Any) { if selectedCastDevice == nil { LogManager.shared.log.debug("Presenting Cast modal") @@ -246,7 +259,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe castDeviceVC?.popoverPresentationController?.sourceView = castButton // Present the view controller (in a popover). - self.present(castDeviceVC!, animated: true) { + present(castDeviceVC!, animated: true) { self.mediaPlayer.pause() self.mainActionButton.setImage(UIImage(systemName: "play"), for: .normal) } @@ -254,8 +267,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe LogManager.shared.log.info("Stopping casting session: button was pressed.") castSessionManager.endSessionAndStopCasting(true) selectedCastDevice = nil - self.castButton.isEnabled = true - self.castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) + castButton.isEnabled = true + castButton.setImage(UIImage(named: "CastDisconnected"), for: .normal) playerDestination = .local } } @@ -264,9 +277,9 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe LogManager.shared.log.debug("Cast modal dismissed") castDeviceVC?.dismiss(animated: true, completion: nil) if playerDestination == .local { - self.mediaPlayer.play() + mediaPlayer.play() } - self.mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) + mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) } func castDeviceChanged() { @@ -280,11 +293,12 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } // MARK: Cast End + func settingsPopoverDismissed() { optionsVC?.dismiss(animated: true, completion: nil) if playerDestination == .local { - self.mediaPlayer.play() - self.mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) + mediaPlayer.play() + mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) } } @@ -326,7 +340,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe self.mediaPlayer.jumpForward(30) self.sendProgressReport(eventName: "timeupdate") } else { - self.sendJellyfinCommand(command: "Seek", options: ["position": (self.remotePositionTicks/10_000_000)+30]) + self.sendJellyfinCommand(command: "Seek", options: ["position": (self.remotePositionTicks / 10_000_000) + 30]) } return .success } @@ -337,14 +351,14 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe self.mediaPlayer.jumpBackward(15) self.sendProgressReport(eventName: "timeupdate") } else { - self.sendJellyfinCommand(command: "Seek", options: ["position": (self.remotePositionTicks/10_000_000)-15]) + self.sendJellyfinCommand(command: "Seek", options: ["position": (self.remotePositionTicks / 10_000_000) - 15]) } return .success } // Scrubber - commandCenter.changePlaybackPositionCommand.addTarget { [weak self](remoteEvent) -> MPRemoteCommandHandlerStatus in - guard let self = self else {return .commandFailed} + commandCenter.changePlaybackPositionCommand.addTarget { [weak self] (remoteEvent) -> MPRemoteCommandHandlerStatus in + guard let self = self else { return .commandFailed } if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent { let targetSeconds = event.positionTime @@ -354,14 +368,12 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe if self.playerDestination == .local { if offset > 0 { - self.mediaPlayer.jumpForward(Int32(offset)/1000) + self.mediaPlayer.jumpForward(Int32(offset) / 1000) } else { - self.mediaPlayer.jumpBackward(Int32(abs(offset))/1000) + self.mediaPlayer.jumpBackward(Int32(abs(offset)) / 1000) } self.sendProgressReport(eventName: "unpause") - } else { - - } + } else {} return .success } else { @@ -383,17 +395,17 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video" - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 200)) { if let artworkImage = UIImage(data: imageData as Data) { - let artwork = MPMediaItemArtwork.init(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in - return artworkImage + let artwork = MPMediaItemArtwork(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in + artworkImage }) - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork } } @@ -403,6 +415,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } // MARK: viewDidLoad + override func viewDidLoad() { super.viewDidLoad() if manifest.type == "Movie" { @@ -426,7 +439,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } } - NotificationCenter.default.addObserver(self, selector: #selector(didChangedOrientation), name: UIDevice.orientationDidChangeNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(didChangedOrientation), + name: UIDevice.orientationDidChangeNotification, object: nil) } @objc func didChangedOrientation() { @@ -447,7 +461,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe let totalDevices = castDiscoveryManager.deviceCount discoveredCastDevices = [] if totalDevices > 0 { - for i in 0...totalDevices-1 { + for i in 0 ... totalDevices - 1 { let device = castDiscoveryManager.device(at: i) discoveredCastDevices.append(device) } @@ -466,8 +480,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - self.tabBarController?.tabBar.isHidden = false - self.navigationController?.isNavigationBarHidden = false + tabBarController?.tabBar.isHidden = false + navigationController?.isNavigationBarHidden = false overrideUserInterfaceStyle = .unspecified DispatchQueue.main.async { if self.lastOri != nil { @@ -479,11 +493,12 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } // MARK: viewDidAppear + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) overrideUserInterfaceStyle = .dark - self.tabBarController?.tabBar.isHidden = true - self.navigationController?.isNavigationBarHidden = true + tabBarController?.tabBar.isHidden = true + navigationController?.isNavigationBarHidden = true mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) // mediaPlayer.wrappedValue.perform(Selector(("setTextRendererFont:")), with: "Copperplate") @@ -496,7 +511,6 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } func setupMediaPlayer() { - // Fetch max bitrate from UserDefaults depending on current connection mode let maxBitrate = Defaults[.inNetworkBandwidth] print(maxBitrate) @@ -504,26 +518,31 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe let builder = DeviceProfileBuilder() builder.setMaxBitrate(bitrate: maxBitrate) let profile = builder.buildProfile() - let playbackInfo = PlaybackInfoDto(userId: SessionManager.current.user.user_id!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true) + let playbackInfo = PlaybackInfoDto(userId: SessionManager.current.user.user_id!, maxStreamingBitrate: Int(maxBitrate), + startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, + autoOpenLiveStream: true) DispatchQueue.global(qos: .userInitiated).async { [self] in delegate?.showLoadingView(self) - MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.current.user.user_id!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo) + MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.current.user.user_id!, + maxStreamingBitrate: Int(maxBitrate), + startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, + playbackInfoDto: playbackInfo) .sink(receiveCompletion: { completion in switch completion { case .finished: break - case .failure(let error): + case let .failure(error): if let err = error as? ErrorResponse { switch err { case .error(401, _, _, _): self.delegate?.exitPlayer(self) SessionManager.current.logout() + main?.root(\.connectToServer) case .error: self.delegate?.exitPlayer(self) } } - break } }, receiveValue: { [self] response in dump(response) @@ -536,7 +555,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe item.videoType = .transcode item.videoUrl = streamURL! - let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", languageCode: "") + let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", + languageCode: "") subtitleTrackArray.append(disableSubtitleTrack) // Loop through media streams and add to array @@ -548,7 +568,9 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } else { deliveryUrl = nil } - let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", languageCode: stream.language ?? "") + let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, + delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", + languageCode: stream.language ?? "") if subtitle.delivery != .encode { subtitleTrackArray.append(subtitle) @@ -556,7 +578,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } if stream.type == .audio { - let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", id: Int32(stream.index!)) + let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", + id: Int32(stream.index!)) if stream.isDefault! == true { selectedAudioTrack = Int32(stream.index!) } @@ -565,7 +588,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } if selectedAudioTrack == -1 { - if audioTrackArray.count > 0 { + if !audioTrackArray.isEmpty { selectedAudioTrack = audioTrackArray[0].id } } @@ -574,13 +597,15 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe playbackItem = item } else { // Item will be directly played by the client. - let streamURL: URL = URL(string: "\(ServerEnvironment.current.server.baseURI!)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag ?? "")")! + let streamURL = + URL(string: "\(ServerEnvironment.current.server.baseURI!)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag ?? "")")! let item = PlaybackItem() item.videoUrl = streamURL item.videoType = .directPlay - let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", languageCode: "") + let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: nil, delivery: .embed, codec: "", + languageCode: "") subtitleTrackArray.append(disableSubtitleTrack) // Loop through media streams and add to array @@ -592,7 +617,9 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } else { deliveryUrl = nil } - let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec!, languageCode: stream.language ?? "") + let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, + delivery: stream.deliveryMethod!, codec: stream.codec!, + languageCode: stream.language ?? "") if subtitle.delivery != .encode { subtitleTrackArray.append(subtitle) @@ -600,7 +627,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } if stream.type == .audio { - let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", id: Int32(stream.index!)) + let subtitle = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", + id: Int32(stream.index!)) if stream.isDefault! == true { selectedAudioTrack = Int32(stream.index!) } @@ -609,7 +637,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } if selectedAudioTrack == -1 { - if audioTrackArray.count > 0 { + if !audioTrackArray.isEmpty { selectedAudioTrack = audioTrackArray[0].id } } @@ -636,7 +664,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe subtitleTrackArray.forEach { subtitle in if Defaults[.isAutoSelectSubtitles] { if Defaults[.autoSelectSubtitlesLangCode] == "Auto", - subtitle.languageCode.contains(Locale.current.languageCode ?? "") { + subtitle.languageCode.contains(Locale.current.languageCode ?? "") + { selectedCaptionTrack = subtitle.id mediaPlayer.currentVideoSubTitleIndex = subtitle.id } else if subtitle.languageCode.contains(Defaults[.autoSelectSubtitlesLangCode]) { @@ -683,21 +712,21 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe subtitleTrackArray.forEach { sub in // stupid fxcking jeff decides to re-encode these when added. // only add playback streams when codec not supported by VLC. - if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" { + if sub.id != -1, sub.delivery == .external, sub.codec != "subrip" { mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false) } } } - self.mediaHasStartedPlaying() + mediaHasStartedPlaying() delegate?.hideLoadingView(self) videoContentView.setNeedsLayout() videoContentView.setNeedsDisplay() - self.view.setNeedsLayout() - self.view.setNeedsDisplay() - self.videoControlsView.setNeedsLayout() - self.videoControlsView.setNeedsDisplay() + view.setNeedsLayout() + view.setNeedsDisplay() + videoControlsView.setNeedsLayout() + videoControlsView.setNeedsDisplay() mediaPlayer.pause() mediaPlayer.play() @@ -705,6 +734,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } // MARK: VideoPlayerSettings Delegate + func subtitleTrackChanged(newTrackID: Int32) { selectedCaptionTrack = newTrackID mediaPlayer.currentVideoSubTitleIndex = newTrackID @@ -731,7 +761,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe // Create the swiftUI view let contentView = UIHostingController(rootView: VideoUpNextView(viewModel: upNextViewModel)) - self.upNextView.addSubview(contentView.view) + upNextView.addSubview(contentView.view) contentView.view.backgroundColor = .clear contentView.view.translatesAutoresizingMaskIntoConstraints = false contentView.view.topAnchor.constraint(equalTo: upNextView.topAnchor).isActive = true @@ -741,7 +771,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe } func getNextEpisode() { - TvShowsAPI.getEpisodes(seriesId: manifest.seriesId!, userId: SessionManager.current.user.user_id!, startItemId: manifest.id, limit: 2) + TvShowsAPI.getEpisodes(seriesId: manifest.seriesId!, userId: SessionManager.current.user.user_id!, startItemId: manifest.id, + limit: 2) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { [self] response in @@ -790,20 +821,20 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe setupMediaPlayer() getNextEpisode() } - } // MARK: - GCKGenericChannelDelegate + extension PlayerViewController: GCKGenericChannelDelegate { @objc func updateRemoteTime() { castButton.setImage(UIImage(named: "CastConnected"), for: .normal) if !paused { - remotePositionTicks = remotePositionTicks + 2_000_000; // add 0.2 secs every timer evt. + remotePositionTicks = remotePositionTicks + 2_000_000 // add 0.2 secs every timer evt. } if isSeeking == false { - let positiveSeconds = Double(remotePositionTicks/10_000_000) - let remainingSeconds = Double((manifest.runTimeTicks! - Int64(remotePositionTicks))/10_000_000) + let positiveSeconds = Double(remotePositionTicks / 10_000_000) + let remainingSeconds = Double((manifest.runTimeTicks! - Int64(remotePositionTicks)) / 10_000_000) timeText.text = calculateTimeText(from: positiveSeconds) timeLeftText.text = calculateTimeText(from: remainingSeconds) @@ -823,14 +854,15 @@ extension PlayerViewController: GCKGenericChannelDelegate { if hasSentRemoteSeek == false { hasSentRemoteSeek = true sendJellyfinCommand(command: "Seek", options: [ - "position": Int(Float(manifest.runTimeTicks! / 10_000_000) * mediaPlayer.position) + "position": Int(Float(manifest.runTimeTicks! / 10_000_000) * mediaPlayer.position), ]) } } paused = json["data"]["PlayState"]["IsPaused"].boolValue - self.remotePositionTicks = json["data"]["PlayState"]["PositionTicks"].int ?? 0 + remotePositionTicks = json["data"]["PlayState"]["PositionTicks"].int ?? 0 if remoteTimeUpdateTimer == nil { - remoteTimeUpdateTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(updateRemoteTime), userInfo: nil, repeats: true) + remoteTimeUpdateTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(updateRemoteTime), + userInfo: nil, repeats: true) } } } @@ -848,7 +880,7 @@ extension PlayerViewController: GCKGenericChannelDelegate { "serverId": ServerEnvironment.current.server.server_id!, "serverVersion": "10.8.0", "receiverName": castSessionManager.currentCastSession!.device.friendlyName!, - "subtitleBurnIn": false + "subtitleBurnIn": false, ] let jsonData = JSON(payload) @@ -857,7 +889,13 @@ extension PlayerViewController: GCKGenericChannelDelegate { if command == "Seek" { remotePositionTicks = remotePositionTicks + ((options["position"] as! Int) * 10_000_000) // Send playback report as Jellyfin Chromecast isn't smarter than a rock. - let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: paused, isMuted: false, positionTicks: Int64(remotePositionTicks), playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") + let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, + mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), + subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: paused, isMuted: false, + positionTicks: Int64(remotePositionTicks), playbackStartTimeTicks: Int64(startTime), + volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, + liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, + nowPlayingQueue: [], playlistItemId: "playlistItem0") PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) .sink(receiveCompletion: { result in @@ -871,9 +909,10 @@ extension PlayerViewController: GCKGenericChannelDelegate { } // MARK: - GCKSessionManagerListener + extension PlayerViewController: GCKSessionManagerListener { func sessionDidStart(manager: GCKSessionManager, didStart session: GCKCastSession) { - self.sendStopReport() + sendStopReport() mediaPlayer.stop() playerDestination = .remote @@ -891,25 +930,25 @@ extension PlayerViewController: GCKSessionManagerListener { let playNowOptions: [String: Any] = [ "items": [[ - "Id": self.manifest.id!, + "Id": manifest.id!, "ServerId": ServerEnvironment.current.server.server_id!, - "Name": self.manifest.name!, - "Type": self.manifest.type!, - "MediaType": self.manifest.mediaType!, - "IsFolder": self.manifest.isFolder! - ]] + "Name": manifest.name!, + "Type": manifest.type!, + "MediaType": manifest.mediaType!, + "IsFolder": manifest.isFolder!, + ]], ] sendJellyfinCommand(command: "PlayNow", options: playNowOptions) } func sessionManager(_ sessionManager: GCKSessionManager, didStart session: GCKCastSession) { - self.jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") - self.sessionDidStart(manager: sessionManager, didStart: session) + jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") + sessionDidStart(manager: sessionManager, didStart: session) } func sessionManager(_ sessionManager: GCKSessionManager, didResumeCastSession session: GCKCastSession) { - self.jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") - self.sessionDidStart(manager: sessionManager, didStart: session) + jellyfinCastChannel = GCKGenericChannel(namespace: "urn:x-cast:com.connectsdk") + sessionDidStart(manager: sessionManager, didStart: session) } func sessionManager(_ sessionManager: GCKSessionManager, didFailToStart session: GCKCastSession, withError error: Error) { @@ -938,30 +977,29 @@ extension PlayerViewController: GCKSessionManagerListener { } // MARK: - VLCMediaPlayer Delegates + extension PlayerViewController: VLCMediaPlayerDelegate { func mediaPlayerStateChanged(_ aNotification: Notification!) { let currentState: VLCMediaPlayerState = mediaPlayer.state switch currentState { - case .stopped : + case .stopped: LogManager.shared.log.debug("Player state changed: STOPPED") - break - case .ended : + case .ended: LogManager.shared.log.debug("Player state changed: ENDED") - break - case .playing : + case .playing: LogManager.shared.log.debug("Player state changed: PLAYING") sendProgressReport(eventName: "unpause") delegate?.hideLoadingView(self) paused = false - case .paused : + case .paused: LogManager.shared.log.debug("Player state changed: PAUSED") paused = true - case .opening : + case .opening: LogManager.shared.log.debug("Player state changed: OPENING") - case .buffering : + case .buffering: LogManager.shared.log.debug("Player state changed: BUFFERING") delegate?.showLoadingView(self) - case .error : + case .error: LogManager.shared.log.error("Video had error.") sendStopReport() case .esAdded: @@ -973,19 +1011,19 @@ extension PlayerViewController: VLCMediaPlayerDelegate { func mediaPlayerTimeChanged(_ aNotification: Notification!) { let time = mediaPlayer.position - if abs(time-lastTime) > 0.00005 { + if abs(time - lastTime) > 0.00005 { paused = false mainActionButton.setImage(UIImage(systemName: "pause"), for: .normal) seekSlider.setValue(mediaPlayer.position, animated: true) delegate?.hideLoadingView(self) - if manifest.type == "Episode" && upNextViewModel.item != nil { + if manifest.type == "Episode", upNextViewModel.item != nil { if time > 0.96 { upNextView.isHidden = false - self.jumpForwardButton.isHidden = true + jumpForwardButton.isHidden = true } else { upNextView.isHidden = true - self.jumpForwardButton.isHidden = false + jumpForwardButton.isHidden = false } } @@ -993,7 +1031,7 @@ extension PlayerViewController: VLCMediaPlayerDelegate { timeLeftText.text = String(mediaPlayer.remainingTime.stringValue.dropFirst()) if CACurrentMediaTime() - controlsAppearTime > 5 { - self.smallNextUpView() + smallNextUpView() UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: { self.videoControlsView.alpha = 0.0 }, completion: { (_: Bool) in @@ -1013,42 +1051,61 @@ extension PlayerViewController: VLCMediaPlayerDelegate { } } +struct VideoPlayerView: View { + var item: BaseItemDto + @State private var isLoading = false + + var body: some View { + // Loading UI needs to be moved into ViewController later + LoadingViewNoBlur(isShowing: $isLoading) { + VLCPlayerWithControls(item: item, loadBinding: $isLoading) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .statusBar(hidden: true) + .edgesIgnoringSafeArea(.all) + .prefersHomeIndicatorAutoHidden(true) + } + } +} + // MARK: End VideoPlayerVC + struct VLCPlayerWithControls: UIViewControllerRepresentable { var item: BaseItemDto - @Environment(\.presentationMode) var presentationMode + @RouterObject var playerRouter: VideoPlayerCoordinator.Router? - var loadBinding: Binding - var pBinding: Binding + let loadBinding: Binding class Coordinator: NSObject, PlayerViewControllerDelegate { + var parent: VLCPlayerWithControls let loadBinding: Binding - let pBinding: Binding - init(loadBinding: Binding, pBinding: Binding) { + init(parent: VLCPlayerWithControls, loadBinding: Binding) { + self.parent = parent self.loadBinding = loadBinding - self.pBinding = pBinding } func hideLoadingView(_ viewController: PlayerViewController) { - self.loadBinding.wrappedValue = false + loadBinding.wrappedValue = false } func showLoadingView(_ viewController: PlayerViewController) { - self.loadBinding.wrappedValue = true + loadBinding.wrappedValue = true } func exitPlayer(_ viewController: PlayerViewController) { - self.pBinding.wrappedValue = false + parent.playerRouter?.dismissCoordinator() } } func makeCoordinator() -> Coordinator { - Coordinator(loadBinding: self.loadBinding, pBinding: self.pBinding) + Coordinator(parent: self, loadBinding: loadBinding) } typealias UIViewControllerType = PlayerViewController - func makeUIViewController(context: UIViewControllerRepresentableContext) -> VLCPlayerWithControls.UIViewControllerType { + func makeUIViewController(context: UIViewControllerRepresentableContext) -> VLCPlayerWithControls + .UIViewControllerType + { let storyboard = UIStoryboard(name: "VideoPlayer", bundle: nil) let customViewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! PlayerViewController customViewController.manifest = item @@ -1056,20 +1113,27 @@ struct VLCPlayerWithControls: UIViewControllerRepresentable { return customViewController } - func updateUIViewController(_ uiViewController: VLCPlayerWithControls.UIViewControllerType, context: UIViewControllerRepresentableContext) { - } + func updateUIViewController(_ uiViewController: VLCPlayerWithControls.UIViewControllerType, + context: UIViewControllerRepresentableContext) {} } // MARK: - Play State Update Methods + extension PlayerViewController { func sendProgressReport(eventName: String) { if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" { - var ticks: Int64 = Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)) + var ticks = Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)) if ticks == 0 { ticks = manifest.userData?.playbackPositionTicks ?? 0 } - let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: (mediaPlayer.state == .paused), isMuted: false, positionTicks: ticks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") + let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, + mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), + subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: mediaPlayer.state == .paused, + isMuted: false, positionTicks: ticks, playbackStartTimeTicks: Int64(startTime), + volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, + liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, + nowPlayingQueue: [], playlistItemId: "playlistItem0") PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo) .sink(receiveCompletion: { result in @@ -1082,7 +1146,10 @@ extension PlayerViewController { } func sendStopReport() { - let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), liveStreamId: nil, playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", nowPlayingQueue: []) + let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, + positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), liveStreamId: nil, + playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", + nowPlayingQueue: []) PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) .sink(receiveCompletion: { result in @@ -1098,7 +1165,13 @@ extension PlayerViewController { print("sending play report!") - let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0") + let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, + mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), + subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, + positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), + volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, + liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], + playlistItemId: "playlistItem0") PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) .sink(receiveCompletion: { result in @@ -1111,7 +1184,7 @@ extension PlayerViewController { } extension UINavigationController { - open override var childForHomeIndicatorAutoHidden: UIViewController? { + override open var childForHomeIndicatorAutoHidden: UIViewController? { return nil } } diff --git a/Shared/Extensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions.swift new file mode 100644 index 00000000..f93a5ee0 --- /dev/null +++ b/Shared/Extensions/ViewExtensions.swift @@ -0,0 +1,17 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import SwiftUI + +extension View { + func eraseToAnyView() -> AnyView { + return AnyView(self) + } +} diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index d31bbfdc..04929d81 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -10,8 +10,11 @@ import Combine import Foundation import JellyfinAPI +import Stinsen final class ConnectToServerViewModel: ViewModel { + @RouterObject + var main: MainCoordinator.Router? @Published var isConnectedServer = false @@ -23,13 +26,14 @@ final class ConnectToServerViewModel: ViewModel { @Published var publicUsers = [UserDto]() @Published var selectedPublicUser = UserDto() - private let discovery: ServerDiscovery = ServerDiscovery() + private let discovery = ServerDiscovery() @Published var servers: [ServerDiscovery.ServerLookupResponse] = [] @Published var searching = false func getPublicUsers() { if ServerEnvironment.current.server != nil { - LogManager.shared.log.debug("Attempting to read public users from \(ServerEnvironment.current.server.baseURI!)", tag: "getPublicUsers") + LogManager.shared.log.debug("Attempting to read public users from \(ServerEnvironment.current.server.baseURI!)", + tag: "getPublicUsers") UserAPI.getPublicUsers() .trackActivity(loading) .sink(receiveCompletion: { completion in @@ -46,28 +50,28 @@ final class ConnectToServerViewModel: ViewModel { } func hidePublicUsers() { - self.lastPublicUsers = publicUsers + lastPublicUsers = publicUsers publicUsers = [] } func showPublicUsers() { - self.publicUsers = lastPublicUsers + publicUsers = lastPublicUsers lastPublicUsers = [] } func connectToServer() { - #if targetEnvironment(simulator) - if uriSubject.value == "localhost" { - uriSubject.value = "http://localhost:8096" - } + 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) .sink(receiveCompletion: { completion in - self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "connectToServer", completion: completion) + self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "connectToServer", + completion: completion) }, receiveValue: { _ in LogManager.shared.log.debug("Connected to server at \"\(self.uriSubject.value)\"", tag: "connectToServer") self.getPublicUsers() @@ -77,7 +81,7 @@ final class ConnectToServerViewModel: ViewModel { func connectToServer(at url: URL) { uriSubject.send(url.absoluteString) - self.connectToServer() + connectToServer() } func discoverServers() { @@ -88,7 +92,7 @@ final class ConnectToServerViewModel: ViewModel { self.searching = false } - discovery.locateServer { [self] (server) in + discovery.locateServer { [self] server in if let server = server, !servers.contains(server) { servers.append(server) } @@ -98,13 +102,16 @@ final class ConnectToServerViewModel: ViewModel { func login() { LogManager.shared.log.debug("Attempting to login to server at \"\(uriSubject.value)\"", tag: "login") - LogManager.shared.log.debug("username == \"\": \(usernameSubject.value.isEmpty), password == \"\": \(passwordSubject.value.isEmpty)", tag: "login") + LogManager.shared.log + .debug("username == \"\": \(usernameSubject.value.isEmpty), password == \"\": \(passwordSubject.value.isEmpty)", + tag: "login") SessionManager.current.login(username: usernameSubject.value, password: passwordSubject.value) .trackActivity(loading) .sink(receiveCompletion: { completion in - self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "login", completion: completion) - }, receiveValue: { _ in - + self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "login", + completion: completion) + }, receiveValue: { [weak self] _ in + self?.main?.root(\.mainTab) }) .store(in: &cancellables) } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 44571bbf..0f98e7a4 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -24,7 +24,6 @@ final class HomeViewModel: ViewModel { override init() { super.init() - refresh() } diff --git a/Shared/ViewModels/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel.swift index 49ba6707..f55b3171 100644 --- a/Shared/ViewModels/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel.swift @@ -7,7 +7,6 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import Foundation import Foundation import JellyfinAPI diff --git a/Shared/ViewModels/LatestMediaViewModel.swift b/Shared/ViewModels/LatestMediaViewModel.swift index 6f791653..d0ebbba8 100644 --- a/Shared/ViewModels/LatestMediaViewModel.swift +++ b/Shared/ViewModels/LatestMediaViewModel.swift @@ -26,15 +26,17 @@ final class LatestMediaViewModel: ViewModel { func requestLatestMedia() { LogManager.shared.log.debug("Requesting latest media for user id \(SessionManager.current.user.user_id ?? "NIL")") - UserLibraryAPI.getLatestMedia(userId: SessionManager.current.user.user_id!, parentId: libraryID, + UserLibraryAPI.getLatestMedia(userId: SessionManager.current.user.user_id!, + parentId: libraryID, fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people ], + includeItemTypes: ["Series", "Movie"], enableUserData: true, limit: 12) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in diff --git a/WidgetExtension/NextUpWidget.swift b/WidgetExtension/NextUpWidget.swift index 26f91c61..fbe1930a 100644 --- a/WidgetExtension/NextUpWidget.swift +++ b/WidgetExtension/NextUpWidget.swift @@ -142,14 +142,14 @@ struct NextUpEntryView: View { .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) } else { switch family { - case .systemSmall: - small(item: entry.items.first) - case .systemMedium: - medium(items: entry.items) - case .systemLarge: - large(items: entry.items) - default: - EmptyView() + case .systemSmall: + small(item: entry.items.first) + case .systemMedium: + medium(items: entry.items) + case .systemLarge: + large(items: entry.items) + default: + EmptyView() } } } @@ -198,51 +198,55 @@ extension NextUpEntryView { } func smallVideoView(item: (BaseItemDto, UIImage?)) -> some View { - VStack(alignment: .leading) { - if let image = item.1 { - Image(uiImage: image) - .resizable() - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .clipped() - .cornerRadius(8) - .shadow(radius: 8) - } - Text(item.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - Text("\(item.0.name ?? "") · S\(item.0.parentIndexNumber ?? 0):E\(item.0.indexNumber ?? 0)") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - - func largeVideoView(item: (BaseItemDto, UIImage?)) -> some View { - HStack(spacing: 20) { - if let image = item.1 { - Image(uiImage: image) - .resizable() - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .clipped() - .cornerRadius(8) - .shadow(radius: 8) - } - VStack(alignment: .leading, spacing: 8) { + Link(destination: URL(string: "widget-extension://Users/\(SessionManager.current.user.user_id!)/Items/\(item.0.id!)")!, label: { + VStack(alignment: .leading) { + if let image = item.1 { + Image(uiImage: image) + .resizable() + .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) + .clipped() + .cornerRadius(8) + .shadow(radius: 8) + } Text(item.0.seriesName ?? "") .font(.caption) .fontWeight(.semibold) .foregroundColor(.primary) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .lineLimit(1) Text("\(item.0.name ?? "") · S\(item.0.parentIndexNumber ?? 0):E\(item.0.indexNumber ?? 0)") .font(.caption) .fontWeight(.semibold) .foregroundColor(.secondary) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .lineLimit(1) } - } + }) + } + + func largeVideoView(item: (BaseItemDto, UIImage?)) -> some View { + Link(destination: URL(string: "widget-extension://Users/\(SessionManager.current.user.user_id!)/Items/\(item.0.id!)")!, label: { + HStack(spacing: 20) { + if let image = item.1 { + Image(uiImage: image) + .resizable() + .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) + .clipped() + .cornerRadius(8) + .shadow(radius: 8) + } + VStack(alignment: .leading, spacing: 8) { + Text(item.0.seriesName ?? "") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + Text("\(item.0.name ?? "") · S\(item.0.parentIndexNumber ?? 0):E\(item.0.indexNumber ?? 0)") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } + }) } } @@ -281,33 +285,36 @@ extension NextUpEntryView { func large(items: [(BaseItemDto, UIImage?)]) -> some View { VStack(spacing: 0) { if let firstItem = items[safe: 0] { - ZStack(alignment: .topTrailing) { - ZStack(alignment: .bottomLeading) { - if let image = firstItem.1 { - Image(uiImage: image) - .centerCropped() - .innerShadow(color: Color.black.opacity(0.5), radius: 0.5) - } - VStack(alignment: .leading, spacing: 8) { - Text(firstItem.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.white) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - Text("\(firstItem.0.name ?? "") · S\(firstItem.0.parentIndexNumber ?? 0):E\(firstItem.0.indexNumber ?? 0)") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.gray) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - .shadow(radius: 8) - .padding(12) - } - headerSymbol - .padding(12) - } - .clipped() - .shadow(radius: 8) + Link(destination: URL(string: "widget-extension://Users/\(SessionManager.current.user.user_id!)/Items/\(firstItem.0.id!)")!, + label: { + ZStack(alignment: .topTrailing) { + ZStack(alignment: .bottomLeading) { + if let image = firstItem.1 { + Image(uiImage: image) + .centerCropped() + .innerShadow(color: Color.black.opacity(0.5), radius: 0.5) + } + VStack(alignment: .leading, spacing: 8) { + Text(firstItem.0.seriesName ?? "") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + Text("\(firstItem.0.name ?? "") · S\(firstItem.0.parentIndexNumber ?? 0):E\(firstItem.0.indexNumber ?? 0)") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.gray) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + .shadow(radius: 8) + .padding(12) + } + headerSymbol + .padding(12) + } + .clipped() + .shadow(radius: 8) + }) } VStack(spacing: 8) { if let secondItem = items[safe: 1] { @@ -354,7 +361,7 @@ struct NextUpWidget_Previews: PreviewProvider { (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), UIImage(named: "WidgetHeaderSymbol")), (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")) + UIImage(named: "WidgetHeaderSymbol")), ], error: nil)) .previewContext(WidgetPreviewContext(family: .systemMedium)) @@ -365,7 +372,7 @@ struct NextUpWidget_Previews: PreviewProvider { (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), UIImage(named: "WidgetHeaderSymbol")), (.init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), - UIImage(named: "WidgetHeaderSymbol")) + UIImage(named: "WidgetHeaderSymbol")), ], error: nil)) .previewContext(WidgetPreviewContext(family: .systemLarge)) @@ -380,7 +387,7 @@ struct NextUpWidget_Previews: PreviewProvider { (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), UIImage(named: "WidgetHeaderSymbol")), (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")) + UIImage(named: "WidgetHeaderSymbol")), ], error: nil)) .previewContext(WidgetPreviewContext(family: .systemMedium)) @@ -392,7 +399,7 @@ struct NextUpWidget_Previews: PreviewProvider { (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), UIImage(named: "WidgetHeaderSymbol")), (.init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), - UIImage(named: "WidgetHeaderSymbol")) + UIImage(named: "WidgetHeaderSymbol")), ], error: nil)) .previewContext(WidgetPreviewContext(family: .systemLarge)) @@ -405,7 +412,7 @@ struct NextUpWidget_Previews: PreviewProvider { NextUpEntryView(entry: .init(date: Date(), items: [ (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")) + UIImage(named: "WidgetHeaderSymbol")), ], error: nil)) .previewContext(WidgetPreviewContext(family: .systemMedium)) @@ -415,7 +422,7 @@ struct NextUpWidget_Previews: PreviewProvider { (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), UIImage(named: "WidgetHeaderSymbol")), (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")) + UIImage(named: "WidgetHeaderSymbol")), ], error: nil)) .previewContext(WidgetPreviewContext(family: .systemLarge))