Merge pull request #155 from jellyfin/PangMo5/coordinator-and-deep-link

Apply Coordinator Pattern and Add Deep-Links
This commit is contained in:
Anthony Lavado 2021-10-02 12:09:03 -04:00 committed by GitHub
commit 084f96b7e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1528 additions and 738 deletions

View File

@ -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 = "<group>"; };
621338922660107500A81A2A /* StringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensions.swift; sourceTree = "<group>"; };
621338B22660A07800A81A2A /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = "<group>"; };
6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = "<group>"; };
6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCoordinator.swift; sourceTree = "<group>"; };
6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryCoordinator.swift; sourceTree = "<group>"; };
6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCoordinator.swift; sourceTree = "<group>"; };
6220D0B926D6092100B8E046 /* FilterCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterCoordinator.swift; sourceTree = "<group>"; };
6220D0BC26D60D6600B8E046 /* ItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewModel.swift; sourceTree = "<group>"; };
6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCoordinator.swift; sourceTree = "<group>"; };
6220D0C526D62D8700B8E046 /* VideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerCoordinator.swift; sourceTree = "<group>"; };
6220D0CB26D640C400B8E046 /* AppURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLHandler.swift; sourceTree = "<group>"; };
6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeader.swift; sourceTree = "<group>"; };
624C21742685CF60007F1390 /* SearchablePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchablePickerView.swift; sourceTree = "<group>"; };
625CB5672678B6FB00530A6E /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
625CB5692678B71200530A6E /* SplashViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashViewModel.swift; sourceTree = "<group>"; };
625CB56B2678C0FD00530A6E /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
625CB56E2678C23300530A6E /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
625CB5722678C32A00530A6E /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
625CB5742678C33500530A6E /* LibraryListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListViewModel.swift; sourceTree = "<group>"; };
@ -434,6 +463,11 @@
628B952A2670CABE0091AF3B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
628B95362670CB800091AF3B /* JellyfinWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinWidget.swift; sourceTree = "<group>"; };
628B953B2670D1FC0091AF3B /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = "<group>"; };
62C29E9E26D1016600C1D2E7 /* MainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinator.swift; sourceTree = "<group>"; };
62C29EA026D102A500C1D2E7 /* MainTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabCoordinator.swift; sourceTree = "<group>"; };
62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerCoodinator.swift; sourceTree = "<group>"; };
62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCoordinator.swift; sourceTree = "<group>"; };
62C29EA726D103D500C1D2E7 /* LibraryListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListCoordinator.swift; sourceTree = "<group>"; };
62CB3F4A2685BB77003D0A6F /* DefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultsExtension.swift; sourceTree = "<group>"; };
62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaViewModel.swift; sourceTree = "<group>"; };
62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchViewModel.swift; sourceTree = "<group>"; };
@ -447,6 +481,7 @@
62EC352B26766675000E9F2D /* ServerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEnvironment.swift; sourceTree = "<group>"; };
62EC352E267666A5000E9F2D /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = "<group>"; };
62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = "<group>"; };
62ECA01726FA685A00E8EBB7 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = "<group>"; };
AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
@ -461,7 +496,7 @@
E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Stackable.swift"; sourceTree = "<group>"; };
E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewBody.swift; sourceTree = "<group>"; };
E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = "<group>"; };
E188460326DEF04800B0C5B7 /* CardVStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardVStackView.swift; sourceTree = "<group>"; };
E188460326DEF04800B0C5B7 /* EpisodeCardVStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeCardVStackView.swift; sourceTree = "<group>"; };
E1AD104926D94822003E4A08 /* DetailItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItem.swift; sourceTree = "<group>"; };
E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = "<group>"; };
E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHStackView.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
@ -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 = "<group>";
@ -923,7 +963,7 @@
E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */,
E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */,
53F866432687A45F00DCD1D7 /* PortraitItemView.swift */,
E188460326DEF04800B0C5B7 /* CardVStackView.swift */,
E188460326DEF04800B0C5B7 /* EpisodeCardVStackView.swift */,
);
path = Components;
sourceTree = "<group>";
@ -938,6 +978,7 @@
6267B3D92671138200A7371D /* ImageExtensions.swift */,
E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */,
621338922660107500A81A2A /* StringExtensions.swift */,
6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -954,6 +995,24 @@
path = WidgetExtension;
sourceTree = "<group>";
};
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 = "<group>";
};
62EC352A26766657000E9F2D /* Singleton */ = {
isa = PBXGroup;
children = (
@ -965,6 +1024,14 @@
path = Singleton;
sourceTree = "<group>";
};
62ECA01926FA6D6900E8EBB7 /* Singleton */ = {
isa = PBXGroup;
children = (
6220D0CB26D640C400B8E046 /* AppURLHandler.swift */,
);
path = Singleton;
sourceTree = "<group>";
};
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" */;

View File

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

View File

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

View File

@ -13,11 +13,12 @@ protocol PillStackable {
var title: String { get }
}
struct PillHStackView<NavigationView: View, ItemType: PillStackable>: View {
struct PillHStackView<ItemType: PillStackable>: 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,9 +31,9 @@ struct PillHStackView<NavigationView: View, ItemType: PillStackable>: 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)

View File

@ -17,20 +17,20 @@ public protocol PortraitImageStackable {
var failureInitials: String { get }
}
struct PortraitImageHStackView<TopBarView: View, NavigationView: View, ItemType: PortraitImageStackable>: View {
struct PortraitImageHStackView<TopBarView: View, ItemType: PortraitImageStackable>: 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,11 +45,9 @@ struct PortraitImageHStackView<TopBarView: View, NavigationView: View, ItemType:
Spacer().frame(width: 16)
ForEach(items, id: \.title) { item in
NavigationLink(
destination: LazyView {
navigationView(item)
},
label: {
Button {
selectedAction(item)
} label: {
VStack {
ImageView(src: item.imageURLContsructor(maxWidth: maxWidth),
bh: item.blurHash,
@ -76,7 +74,7 @@ struct PortraitImageHStackView<TopBarView: View, NavigationView: View, ItemType:
.lineLimit(2)
}
}
})
}
}
Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
}

View File

@ -1,5 +1,5 @@
//
/*
/*
* 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/.
@ -7,30 +7,26 @@
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
import JellyfinAPI
import SwiftUI
struct PortraitItemView: View {
var item: BaseItemDto
var body: some View {
NavigationLink(destination: LazyView { ItemNavigationView(item: item) }) {
VStack(alignment: .leading) {
ImageView(src: item.type != "Episode" ? item.getPrimaryImage(maxWidth: 100) : item.getSeriesPrimaryImage(maxWidth: 100), bh: item.type != "Episode" ? item.getPrimaryImageBlurHash() : item.getSeriesPrimaryImageBlurHash())
ImageView(src: item.type != "Episode" ? item.getPrimaryImage(maxWidth: 100) : item.getSeriesPrimaryImage(maxWidth: 100),
bh: item.type != "Episode" ? item.getPrimaryImageBlurHash() : item.getSeriesPrimaryImageBlurHash())
.frame(width: 100, height: 150)
.cornerRadius(10)
.shadow(radius: 4, y: 2)
.shadow(radius: 4, y: 2)
.overlay(
Rectangle()
.fill(Color(red: 172/255, green: 92/255, blue: 195/255))
.overlay(Rectangle()
.fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255))
.mask(ProgressBar())
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7)
.padding(0), alignment: .bottomLeading
)
.overlay(
ZStack {
.padding(0), alignment: .bottomLeading)
.overlay(ZStack {
if item.userData?.isFavorite ?? false {
Image(systemName: "circle.fill")
.foregroundColor(.white)
@ -43,8 +39,7 @@ struct PortraitItemView: View {
.padding(.leading, 2)
.padding(.bottom, item.userData?.playedPercentage == nil ? 2 : 9)
.opacity(1), alignment: .bottomLeading)
.overlay(
ZStack {
.overlay(ZStack {
if item.userData?.played ?? false {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
@ -87,5 +82,4 @@ struct PortraitItemView: View {
}
}.frame(width: 100)
}
}
}

View File

@ -6,8 +6,10 @@
*/
import SwiftUI
import Stinsen
struct ConnectToServerView: View {
@EnvironmentObject var mainRouter: MainCoordinator.Router
@StateObject var viewModel = ConnectToServerViewModel()
@State var username = ""
@State var password = ""
@ -59,6 +61,7 @@ struct ConnectToServerView: View {
if SessionManager.current.doesUserHaveSavedSession(userID: publicUser.id!) {
let user = SessionManager.current.getSavedSession(userID: publicUser.id!)
SessionManager.current.loginWithSavedSession(user: user)
mainRouter.root(\.mainTab)
} else {
username = publicUser.name ?? ""
viewModel.selectedPublicUser = publicUser
@ -174,5 +177,8 @@ struct ConnectToServerView: View {
dismissButton: .cancel())
}
.navigationTitle(NSLocalizedString("Connect to Server", comment: ""))
.onAppear {
AppURLHandler.shared.appURLState = .allowedInLogin
}
}
}

View File

@ -6,8 +6,8 @@
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
import JellyfinAPI
import SwiftUI
struct ProgressBar: Shape {
func path(in rect: CGRect) -> 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))
.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
)
.padding(0), alignment: .bottomLeading)
HStack {
Text("\(item.seriesName ?? item.name ?? "")")
.font(.callout)

View File

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

View File

@ -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<LibraryFilters>, 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<LibraryFilters>, enabledFilterType: [FilterType], parentId: String) {
_filters = filters
self.enabledFilterType = enabledFilterType
self.parentId = parentId
}
@ViewBuilder func makeStart() -> some View {
LibraryFilterView(filters: $filters, enabledFilterType: enabledFilterType, parentId: parentId)
}
}

View File

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

View File

@ -0,0 +1,44 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Foundation
import JellyfinAPI
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<VideoPlayerCoordinator> {
NavigationViewCoordinator(VideoPlayerCoordinator(item: item))
}
@ViewBuilder func makeStart() -> some View {
ItemNavigationView(item: itemDto)
}
}

View File

@ -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<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(filters: params.filters,
enabledFilterType: params.enabledFilterType,
parentId: params.parentId))
}
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
}

View File

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

View File

@ -0,0 +1,88 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Foundation
import Nuke
import Stinsen
import SwiftUI
#if !os(tvOS)
import WidgetKit
#endif
#if os(iOS)
final class MainCoordinator: NavigationCoordinatable {
var stack: NavigationStack<MainCoordinator>
@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<ConnectToServerCoodinator> {
NavigationViewCoordinator(ConnectToServerCoodinator())
}
}
#elseif os(tvOS)
// temp for fixing build error
final class MainCoordinator: NavigationCoordinatable {
var stack = NavigationStack<MainCoordinator>(initial: \MainCoordinator.mainTab)
@Root var mainTab = makeEmpty
@ViewBuilder func makeEmpty() -> some View {
EmptyView()
}
}
#endif

View File

@ -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<HomeCoordinator> {
return NavigationViewCoordinator(HomeCoordinator())
}
@ViewBuilder func makeHomeTab(isActive: Bool) -> some View {
Image(systemName: "house")
Text("Home")
}
func makeTodos() -> NavigationViewCoordinator<LibraryListCoordinator> {
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()
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -11,8 +11,8 @@ 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")
@ -43,9 +43,12 @@ 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())
@ -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)
}
}
}

View File

@ -18,6 +18,17 @@
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>jellyfin</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@ -5,18 +5,17 @@
* 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) {
@ -29,12 +28,13 @@ struct ItemNavigationView: View {
}
}
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
@ -56,6 +56,7 @@ fileprivate struct ItemView: View {
}
var body: some View {
Group {
if hSizeClass == .compact && vSizeClass == .regular {
ItemPortraitMainView(videoIsLoading: $videoIsLoading)
.environmentObject(videoPlayerItem)
@ -66,4 +67,9 @@ fileprivate struct ItemView: View {
.environmentObject(viewModel)
}
}
.onReceive(videoPlayerItem.$shouldShowPlayer) { flag in
guard flag else { return }
self.itemRouter.route(to: \.videoPlayer, viewModel.item)
}
}
}

View File

@ -1,5 +1,5 @@
//
/*
/*
* 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/.
@ -7,23 +7,24 @@
* 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)
})
}
}

View File

@ -1,5 +1,5 @@
//
/*
/*
* 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/.
@ -7,10 +7,11 @@
* 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
@ -20,10 +21,11 @@ struct ItemLandscapeMainView: View {
}
// MARK: innerBody
private var innerBody: some View {
HStack {
// MARK: Sidebar Image
VStack {
ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 130),
bh: viewModel.item.getPrimaryImageBlurHash())
@ -38,8 +40,8 @@ struct ItemLandscapeMainView: View {
self.videoPlayerItem.shouldShowPlayer = true
}
} label: {
// MARK: Play
HStack {
Image(systemName: "play.fill")
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white)
@ -59,14 +61,17 @@ struct ItemLandscapeMainView: View {
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)
@ -77,24 +82,12 @@ 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)

View File

@ -1,5 +1,5 @@
//
/*
/*
* 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/.
@ -7,11 +7,11 @@
* 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
@ -21,6 +21,7 @@ struct ItemPortraitMainView: View {
}
// MARK: portraitHeaderView
var portraitHeaderView: some View {
ImageView(src: viewModel.item.getBackdropImage(maxWidth: Int(UIScreen.main.bounds.width)),
bh: viewModel.item.getBackdropImageBlurHash())
@ -29,40 +30,31 @@ struct ItemPortraitMainView: View {
}
// 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)
EpisodeCardVStackView(items: episodeViewModel.episodes) { episode in
itemRouter.route(to: \.item, episode)
}
.padding(.top, 5)
} else {
ItemViewBody()

View File

@ -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,7 +17,7 @@ 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)
}
@ -27,7 +28,7 @@ extension UIWindow {
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 {
@ -111,12 +111,11 @@ class PreferenceUIHostingController: UIHostingController<AnyView> {
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<AnyView> {
public var _prefersHomeIndicatorAutoHidden = false {
didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() }
}
override var prefersHomeIndicatorAutoHidden: Bool {
_prefersHomeIndicatorAutoHidden
}
@ -146,6 +146,7 @@ class PreferenceUIHostingController: UIHostingController<AnyView> {
}
}
}
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 <swiftfin-bugs@jellyfin.org>"])
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 {

View File

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

View File

@ -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,11 +20,11 @@ struct LibraryFilterView: View {
init(filters: Binding<LibraryFilters>, 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()
@ -64,7 +66,7 @@ struct LibraryFilterView: View {
Button {
viewModel.resetFilters()
self.filters = viewModel.modifiedFilters
presentationMode.wrappedValue.dismiss()
filterRouter.dismissCoordinator()
} label: {
Text("Reset")
}
@ -74,7 +76,7 @@ struct LibraryFilterView: View {
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
Button {
presentationMode.wrappedValue.dismiss()
filterRouter.dismissCoordinator()
} label: {
Image(systemName: "xmark")
}
@ -83,12 +85,11 @@ struct LibraryFilterView: View {
Button {
viewModel.updateModifiedFilter()
self.filters = viewModel.modifiedFilters
presentationMode.wrappedValue.dismiss()
filterRouter.dismissCoordinator()
} label: {
Text("Apply")
}
}
}
}
}
}

View File

@ -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)
@ -96,9 +101,9 @@ struct LibraryListView: View {
.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")
}
}

View File

@ -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,9 +80,13 @@ struct LibrarySearchView: View {
if !items.isEmpty {
LazyVGrid(columns: tracks) {
ForEach(items, id: \.id) { item in
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:

View File

@ -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,9 +35,13 @@ struct LibraryView: View {
LazyVGrid(columns: tracks) {
ForEach(viewModel.items, id: \.id) { item in
if item.type != "Folder" {
Button {
libraryRouter.route(to: \.item, item)
} label: {
PortraitItemView(item: item)
}
}
}
}.onRotate { _ in
recalcTracks()
}
@ -91,24 +95,17 @@ struct LibraryView: View {
Label("Icon One", systemImage: "line.horizontal.3.decrease.circle")
.foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange))
.onTapGesture {
isShowingFilterView = true
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()
}
)
}
}

View File

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

View File

@ -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
Button {
homeRouter.route(to: \.item, item)
} label: {
PortraitItemView(item: item)
}
}.padding(.trailing, 16)
}
.padding(.leading, 20)

View File

@ -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,29 +26,30 @@ struct SettingsView: View {
@Default(.videoPlayerJumpBackward) var jumpBackwardLength
var body: some View {
NavigationView {
Form {
Section(header: EmptyView()) {
HStack {
Text("User")
Spacer()
Text(SessionManager.current.user.username ?? "")
Text(SessionManager.current.user?.username ?? "")
.foregroundColor(.jellyfinPurple)
}
NavigationLink(
destination: ServerDetailView(),
label: {
Button {
settingsRouter.route(to: \.serverDetail)
} label: {
HStack {
Text("Server")
Spacer()
Text(ServerEnvironment.current.server.name ?? "")
Text(ServerEnvironment.current.server?.name ?? "")
.foregroundColor(.jellyfinPurple)
Image(systemName: "chevron.right")
}
}
})
Button {
close = false
settingsRouter.dismissCoordinator()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
SessionManager.current.logout()
let nc = NotificationCenter.default
@ -89,24 +91,27 @@ struct SettingsView: View {
SearchablePicker(label: "Preferred subtitle language",
options: viewModel.langs,
optionToString: { $0.name },
selected: Binding<TrackLanguage>(
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectSubtitlesLangcode }) ?? .auto },
set: {autoSelectSubtitlesLangcode = $0.isoCode}
)
)
selected: Binding<TrackLanguage>(get: {
viewModel.langs
.first(where: { $0.isoCode == autoSelectSubtitlesLangcode
}) ??
.auto
},
set: { autoSelectSubtitlesLangcode = $0.isoCode }))
SearchablePicker(label: "Preferred audio language",
options: viewModel.langs,
optionToString: { $0.name },
selected: Binding<TrackLanguage>(
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectAudioLangcode }) ?? .auto },
set: { autoSelectAudioLangcode = $0.isoCode}
)
)
selected: Binding<TrackLanguage>(get: {
viewModel.langs
.first(where: { $0.isoCode == autoSelectAudioLangcode }) ??
.auto
},
set: { autoSelectAudioLangcode = $0.isoCode }))
Picker(NSLocalizedString("Appearance", comment: ""), selection: $appAppearance) {
ForEach(self.viewModel.appearances, id: \.self) { appearance in
Text(appearance.localizedName).tag(appearance.rawValue)
}
}.onChange(of: appAppearance, perform: { value in
}.onChange(of: appAppearance, perform: { _ in
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style
})
}
@ -115,12 +120,11 @@ struct SettingsView: View {
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
Button {
close = false
settingsRouter.dismissCoordinator()
} label: {
Image(systemName: "xmark")
}
}
}
}
}
}

View File

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

View File

@ -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()
ProgressView()
.onReceive(viewModel.$isLoggedIn) { flag in
if flag {
mainRouter.root(\.mainTab)
} else {
NavigationView {
ConnectToServerView()
mainRouter.root(\.connectToServer)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}

View File

@ -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 {
@ -390,8 +402,8 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
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
}
@ -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<Bool>
var pBinding: Binding<Bool>
let loadBinding: Binding<Bool>
class Coordinator: NSObject, PlayerViewControllerDelegate {
var parent: VLCPlayerWithControls
let loadBinding: Binding<Bool>
let pBinding: Binding<Bool>
init(loadBinding: Binding<Bool>, pBinding: Binding<Bool>) {
init(parent: VLCPlayerWithControls, loadBinding: Binding<Bool>) {
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>) -> VLCPlayerWithControls.UIViewControllerType {
func makeUIViewController(context: UIViewControllerRepresentableContext<VLCPlayerWithControls>) -> 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<VLCPlayerWithControls>) {
}
func updateUIViewController(_ uiViewController: VLCPlayerWithControls.UIViewControllerType,
context: UIViewControllerRepresentableContext<VLCPlayerWithControls>) {}
}
// 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
}
}

View File

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

View File

@ -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,17 +50,16 @@ 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"
@ -67,7 +70,8 @@ final class ConnectToServerViewModel: ViewModel {
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)
}

View File

@ -24,7 +24,6 @@ final class HomeViewModel: ViewModel {
override init() {
super.init()
refresh()
}

View File

@ -7,7 +7,6 @@
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Foundation
import Foundation
import JellyfinAPI

View File

@ -26,7 +26,8 @@ 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,
@ -35,6 +36,7 @@ final class LatestMediaViewModel: ViewModel {
.genres,
.people
],
includeItemTypes: ["Series", "Movie"],
enableUserData: true, limit: 12)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in

View File

@ -198,6 +198,7 @@ extension NextUpEntryView {
}
func smallVideoView(item: (BaseItemDto, UIImage?)) -> some View {
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)
@ -218,9 +219,11 @@ extension NextUpEntryView {
.foregroundColor(.secondary)
.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)
@ -243,6 +246,7 @@ extension NextUpEntryView {
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
}
})
}
}
@ -281,6 +285,8 @@ extension NextUpEntryView {
func large(items: [(BaseItemDto, UIImage?)]) -> some View {
VStack(spacing: 0) {
if let firstItem = items[safe: 0] {
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 {
@ -308,6 +314,7 @@ extension NextUpEntryView {
}
.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))