diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 7295069a..08590758 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -18,8 +18,6 @@ 53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; }; 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; }; 5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 5338F756263B7E2E0014BF09 /* KeychainSwift */; }; - 533A8E6626748B4F00719967 /* MobileVLCKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 533A8E6526748B4F00719967 /* MobileVLCKit.framework */; platformFilter = ios; }; - 533A8E6726748B4F00719967 /* MobileVLCKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 533A8E6526748B4F00719967 /* MobileVLCKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870622669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift */; }; 535870652669D21600D05A09 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870642669D21600D05A09 /* ContentView.swift */; }; 535870672669D21700D05A09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870662669D21700D05A09 /* Assets.xcassets */; }; @@ -30,8 +28,6 @@ 5358708D2669D7A800D05A09 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 5358708C2669D7A800D05A09 /* KeychainSwift */; }; 535870912669D7A800D05A09 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 535870902669D7A800D05A09 /* Introspect */; }; 5358709B2669D7A800D05A09 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5358709A2669D7A800D05A09 /* NukeUI */; }; - 5358709D2669D82900D05A09 /* TVVLCKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5358709C2669D82900D05A09 /* TVVLCKit.framework */; }; - 5358709E2669D82900D05A09 /* TVVLCKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5358709C2669D82900D05A09 /* TVVLCKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 535870A32669D89F00D05A09 /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFF263B596B003A4E83 /* Model.xcdatamodeld */; }; 535870A52669D8AE00D05A09 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; }; 535870A62669D8AE00D05A09 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338B22660A07800A81A2A /* LazyView.swift */; }; @@ -44,7 +40,6 @@ 5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; 5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */; }; - 5377CBF7263B596A003A4E83 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF6263B596A003A4E83 /* ContentView.swift */; }; 5377CBF9263B596B003A4E83 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; }; 5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */; }; 5377CBFE263B596B003A4E83 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFD263B596B003A4E83 /* PersistenceController.swift */; }; @@ -59,8 +54,6 @@ 53A431BF266B0FFE0016769F /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 53A431BE266B0FFE0016769F /* JellyfinAPI */; }; 53AD124D267029D60094A276 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53987CA526572F0700E7EA70 /* SeriesItemView.swift */; }; 53AD124E26702B8A0094A276 /* SeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53987CA326572C1300E7EA70 /* SeasonItemView.swift */; }; - 53C4404E266C75C70049424C /* HandleAPIRequestCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53C4404D266C75C70049424C /* HandleAPIRequestCompletion.swift */; }; - 53C4404F266C75C70049424C /* HandleAPIRequestCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53C4404D266C75C70049424C /* HandleAPIRequestCompletion.swift */; }; 53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */; }; 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; }; 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; }; @@ -74,6 +67,17 @@ 621C638026672A30004216EA /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 621C637F26672A30004216EA /* NukeUI */; }; 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; }; 6228B1C22670EB010067FD35 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFD263B596B003A4E83 /* PersistenceController.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 */; }; + 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */; }; + 625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 625CB5792678C4A400530A6E /* ActivityIndicator */; }; + 625CB57C2678CE1000530A6E /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; }; + 625CB57E2678E81E00530A6E /* TVVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */; }; + 625CB57F2678E81E00530A6E /* TVVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 6267B3D42671024A00A7371D /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; 6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; @@ -91,7 +95,15 @@ 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 */; }; - 62FA8A522671DE3C004BA2AB /* WidgetEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62FA8A512671DE3C004BA2AB /* WidgetEnvironment.swift */; }; + 62EC3527267665D8000E9F2D /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; }; + 62EC3528267665D8000E9F2D /* MobileVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352B26766675000E9F2D /* ServerEnvironment.swift */; }; + 62EC352D26766675000E9F2D /* ServerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352B26766675000E9F2D /* ServerEnvironment.swift */; }; + 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352E267666A5000E9F2D /* SessionManager.swift */; }; + 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352E267666A5000E9F2D /* SessionManager.swift */; }; + 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 */; }; AE8C3159265D6F90008AA076 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; /* End PBXBuildFile section */ @@ -115,24 +127,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 533A8E6826748B4F00719967 /* Embed Frameworks */ = { + 625CB5802678E81E00530A6E /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( - 533A8E6726748B4F00719967 /* MobileVLCKit.framework in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; - 5358709F2669D82900D05A09 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - 5358709E2669D82900D05A09 /* TVVLCKit.framework in Embed Frameworks */, + 625CB57F2678E81E00530A6E /* TVVLCKit.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -148,6 +149,17 @@ name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + 62EC3529267665D8000E9F2D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 62EC3528267665D8000E9F2D /* MobileVLCKit.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -156,7 +168,6 @@ 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 53313B8F265EEA6D00947AA3 /* VideoPlayer.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = VideoPlayer.storyboard; sourceTree = ""; }; 5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; - 533A8E6526748B4F00719967 /* MobileVLCKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileVLCKit.framework; path = "Carthage/Build/MobileVLCKit.xcframework/ios-arm64_armv7_armv7s/MobileVLCKit.framework"; sourceTree = ""; }; 535870602669D21600D05A09 /* JellyfinPlayer tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "JellyfinPlayer tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 535870622669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayer_tvOSApp.swift; sourceTree = ""; }; 535870642669D21600D05A09 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -165,14 +176,12 @@ 5358706B2669D21700D05A09 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; 5358706E2669D21700D05A09 /* JellyfinPlayer_tvOS.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = JellyfinPlayer_tvOS.xcdatamodel; sourceTree = ""; }; 535870702669D21700D05A09 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 5358709C2669D82900D05A09 /* TVVLCKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVVLCKit.framework; path = "Carthage/Build/TVVLCKit.xcframework/tvos-arm64/TVVLCKit.framework"; sourceTree = ""; }; 535870AC2669D8DD00D05A09 /* Typings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Typings.swift; sourceTree = ""; }; 535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; 535BAEA4264A151C005FA86D /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; 5364F454266CA0DC0026ECBA /* APIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIExtensions.swift; sourceTree = ""; }; 5377CBF1263B596A003A4E83 /* JellyfinPlayer iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "JellyfinPlayer iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayerApp.swift; sourceTree = ""; }; - 5377CBF6263B596A003A4E83 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 5377CBF8263B596B003A4E83 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 5377CBFD263B596B003A4E83 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; @@ -188,7 +197,6 @@ 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 53A089CF264DA9DA00D57806 /* MovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemView.swift; sourceTree = ""; }; 53AD124C2670278D0094A276 /* JellyfinPlayer.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = JellyfinPlayer.entitlements; sourceTree = ""; }; - 53C4404D266C75C70049424C /* HandleAPIRequestCompletion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandleAPIRequestCompletion.swift; sourceTree = ""; }; 53D5E3DA264B460200BADDC8 /* Cartfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Cartfile; sourceTree = ""; }; 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Carthage/Build/MobileVLCKit.xcframework; sourceTree = ""; }; 53DE4BD1267098F300739748 /* SearchBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarView.swift; sourceTree = ""; }; @@ -202,6 +210,15 @@ 621338922660107500A81A2A /* StringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensions.swift; sourceTree = ""; }; 621338B22660A07800A81A2A /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeader.swift; sourceTree = ""; }; + 625CB5672678B6FB00530A6E /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; + 625CB5692678B71200530A6E /* SplashViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashViewModel.swift; sourceTree = ""; }; + 625CB56B2678C0FD00530A6E /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; + 625CB56E2678C23300530A6E /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 625CB5722678C32A00530A6E /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + 625CB5742678C33500530A6E /* LibraryListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListViewModel.swift; sourceTree = ""; }; + 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerViewModel.swift; sourceTree = ""; }; + 625CB57B2678CE1000530A6E /* ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModel.swift; sourceTree = ""; }; + 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TVVLCKit.xcframework; path = Carthage/Build/TVVLCKit.xcframework; sourceTree = ""; }; 6267B3D526710B8900A7371D /* CollectionExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtensions.swift; sourceTree = ""; }; 6267B3D92671138200A7371D /* ImageExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageExtensions.swift; sourceTree = ""; }; 628B95202670CABD0091AF3B /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -212,7 +229,9 @@ 628B952A2670CABE0091AF3B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 628B95362670CB800091AF3B /* JellyfinWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinWidget.swift; sourceTree = ""; }; 628B953B2670D1FC0091AF3B /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = ""; }; - 62FA8A512671DE3C004BA2AB /* WidgetEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetEnvironment.swift; sourceTree = ""; }; + 62EC352B26766675000E9F2D /* ServerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEnvironment.swift; sourceTree = ""; }; + 62EC352E267666A5000E9F2D /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; + 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = ""; }; /* End PBXFileReference section */ @@ -223,8 +242,8 @@ files = ( 53A431BF266B0FFE0016769F /* JellyfinAPI in Frameworks */, 535870912669D7A800D05A09 /* Introspect in Frameworks */, + 625CB57E2678E81E00530A6E /* TVVLCKit.xcframework in Frameworks */, 5358708D2669D7A800D05A09 /* KeychainSwift in Frameworks */, - 5358709D2669D82900D05A09 /* TVVLCKit.framework in Frameworks */, 5358709B2669D7A800D05A09 /* NukeUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -233,11 +252,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 533A8E6626748B4F00719967 /* MobileVLCKit.framework in Frameworks */, 5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */, 53352571265EA0A0006CCA86 /* Introspect in Frameworks */, 621C638026672A30004216EA /* NukeUI in Frameworks */, + 625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */, 53A431BD266B0FF20016769F /* JellyfinAPI in Frameworks */, + 62EC3527267665D8000E9F2D /* MobileVLCKit.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -260,6 +280,11 @@ isa = PBXGroup; children = ( 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, + 625CB5692678B71200530A6E /* SplashViewModel.swift */, + 625CB5722678C32A00530A6E /* HomeViewModel.swift */, + 625CB5742678C33500530A6E /* LibraryListViewModel.swift */, + 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, + 625CB57B2678CE1000530A6E /* ViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -289,6 +314,7 @@ 535870752669D60C00D05A09 /* Shared */ = { isa = PBXGroup; children = ( + 62EC352A26766657000E9F2D /* Shared */, 532175392671BCED005491E6 /* ViewModel */, 621338912660106C00A81A2A /* Extensions */, AE8C3157265D6F5E008AA076 /* Resources */, @@ -331,10 +357,10 @@ 5377CBF3263B596A003A4E83 /* JellyfinPlayer */ = { isa = PBXGroup; children = ( + 625CB56D2678C1C400530A6E /* ViewModels */, 53AD124C2670278D0094A276 /* JellyfinPlayer.entitlements */, 5377CBF8263B596B003A4E83 /* Assets.xcassets */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, - 5377CBF6263B596A003A4E83 /* ContentView.swift */, 5389276D263C25100035E14B /* ContinueWatchingView.swift */, 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */, @@ -359,6 +385,9 @@ 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */, 53DE4BD1267098F300739748 /* SearchBarView.swift */, 531AC8BE26750DE20091C7EB /* ImageView.swift */, + 625CB5672678B6FB00530A6E /* SplashView.swift */, + 625CB56B2678C0FD00530A6E /* MainTabView.swift */, + 625CB56E2678C23300530A6E /* HomeView.swift */, ); path = JellyfinPlayer; sourceTree = ""; @@ -374,8 +403,7 @@ 53D5E3DB264B47EE00BADDC8 /* Frameworks */ = { isa = PBXGroup; children = ( - 533A8E6526748B4F00719967 /* MobileVLCKit.framework */, - 5358709C2669D82900D05A09 /* TVVLCKit.framework */, + 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */, 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */, 628B95212670CABD0091AF3B /* WidgetKit.framework */, 628B95232670CABD0091AF3B /* SwiftUI.framework */, @@ -388,17 +416,24 @@ children = ( 5364F454266CA0DC0026ECBA /* APIExtensions.swift */, 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */, - 53C4404D266C75C70049424C /* HandleAPIRequestCompletion.swift */, 621338B22660A07800A81A2A /* LazyView.swift */, 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */, 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */, 621338922660107500A81A2A /* StringExtensions.swift */, 6267B3D526710B8900A7371D /* CollectionExtensions.swift */, 6267B3D92671138200A7371D /* ImageExtensions.swift */, + 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */, ); path = Extensions; sourceTree = ""; }; + 625CB56D2678C1C400530A6E /* ViewModels */ = { + isa = PBXGroup; + children = ( + ); + path = ViewModels; + sourceTree = ""; + }; 628B95252670CABD0091AF3B /* WidgetExtension */ = { isa = PBXGroup; children = ( @@ -407,11 +442,19 @@ 628B95262670CABD0091AF3B /* NextUpWidget.swift */, 628B95282670CABE0091AF3B /* Assets.xcassets */, 628B952A2670CABE0091AF3B /* Info.plist */, - 62FA8A512671DE3C004BA2AB /* WidgetEnvironment.swift */, ); path = WidgetExtension; sourceTree = ""; }; + 62EC352A26766657000E9F2D /* Shared */ = { + isa = PBXGroup; + children = ( + 62EC352B26766675000E9F2D /* ServerEnvironment.swift */, + 62EC352E267666A5000E9F2D /* SessionManager.swift */, + ); + path = Shared; + sourceTree = ""; + }; AE8C3157265D6F5E008AA076 /* Resources */ = { isa = PBXGroup; children = ( @@ -431,7 +474,7 @@ 5358705C2669D21600D05A09 /* Sources */, 5358705D2669D21600D05A09 /* Frameworks */, 5358705E2669D21600D05A09 /* Resources */, - 5358709F2669D82900D05A09 /* Embed Frameworks */, + 625CB5802678E81E00530A6E /* Embed Frameworks */, ); buildRules = ( ); @@ -457,7 +500,7 @@ 5377CBEF263B596A003A4E83 /* Resources */, 5302F8322658B74800647A2E /* CopyFiles */, 628B95312670CABE0091AF3B /* Embed App Extensions */, - 533A8E6826748B4F00719967 /* Embed Frameworks */, + 62EC3529267665D8000E9F2D /* Embed Frameworks */, ); buildRules = ( ); @@ -470,6 +513,7 @@ 53352570265EA0A0006CCA86 /* Introspect */, 621C637F26672A30004216EA /* NukeUI */, 53A431BC266B0FF20016769F /* JellyfinAPI */, + 625CB5792678C4A400530A6E /* ActivityIndicator */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* JellyfinPlayer iOS.app */; @@ -534,6 +578,7 @@ 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */, 53A431BB266B0FF20016769F /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, + 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -584,6 +629,8 @@ buildActionMask = 2147483647; files = ( 6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */, + 62EC352D26766675000E9F2D /* ServerEnvironment.swift in Sources */, + 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, 535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, 535870A52669D8AE00D05A09 /* ParallaxHeader.swift in Sources */, @@ -593,7 +640,6 @@ 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */, 535870652669D21600D05A09 /* ContentView.swift in Sources */, 535870A62669D8AE00D05A09 /* LazyView.swift in Sources */, - 53C4404F266C75C70049424C /* HandleAPIRequestCompletion.swift in Sources */, 5358706F2669D21700D05A09 /* JellyfinPlayer_tvOS.xcdatamodeld in Sources */, 5321753E2671DE9C005491E6 /* Typings.swift in Sources */, 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, @@ -609,16 +655,20 @@ 5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */, 621338932660107500A81A2A /* StringExtensions.swift in Sources */, 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, + 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 5377CBFE263B596B003A4E83 /* PersistenceController.swift in Sources */, 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, 53AD124E26702B8A0094A276 /* SeasonItemView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, + 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, 53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */, 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */, + 625CB56F2678C23300530A6E /* HomeView.swift in Sources */, 53892770263C25230035E14B /* NextUpView.swift in Sources */, + 625CB5682678B6FB00530A6E /* SplashView.swift in Sources */, 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, @@ -626,21 +676,26 @@ 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */, + 625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */, + 625CB57C2678CE1000530A6E /* ViewModel.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, + 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */, 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */, - 53C4404E266C75C70049424C /* HandleAPIRequestCompletion.swift in Sources */, + 62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */, 6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */, - 5377CBF7263B596A003A4E83 /* ContentView.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 */, 53AD124D267029D60094A276 /* SeriesItemView.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */, + 625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -648,6 +703,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 62EC353126766848000E9F2D /* ServerEnvironment.swift in Sources */, 6267B3D42671024A00A7371D /* APIExtensions.swift in Sources */, 6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */, 628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */, @@ -655,8 +711,8 @@ 628B95372670CB800091AF3B /* JellyfinWidget.swift in Sources */, 6228B1C22670EB010067FD35 /* PersistenceController.swift in Sources */, 628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */, - 62FA8A522671DE3C004BA2AB /* WidgetEnvironment.swift in Sources */, 628B95382670CDAB0091AF3B /* Model.xcdatamodeld in Sources */, + 62EC353226766849000E9F2D /* SessionManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1045,6 +1101,14 @@ version = 0.3.0; }; }; + 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/duyquang91/ActivityIndicator"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1088,6 +1152,11 @@ package = 621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */; productName = NukeUI; }; + 625CB5792678C4A400530A6E /* ActivityIndicator */ = { + isa = XCSwiftPackageProductDependency; + package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */; + productName = ActivityIndicator; + }; 628B95322670CAEA0091AF3B /* NukeUI */ = { isa = XCSwiftPackageProductDependency; package = 621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */; diff --git a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4cf2aa37..cb410d09 100644 --- a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "ActivityIndicator", + "repositoryURL": "https://github.com/duyquang91/ActivityIndicator", + "state": { + "branch": null, + "revision": "0101a02196f6a67cf26f6434b007d3db6bd07fee", + "version": "1.1.0" + } + }, { "package": "AnyCodable", "repositoryURL": "https://github.com/Flight-School/AnyCodable", @@ -20,7 +29,7 @@ } }, { - "package": "jellyfin-sdk-swift", + "package": "JellyfinAPI", "repositoryURL": "https://github.com/jellyfin/jellyfin-sdk-swift", "state": { "branch": "main", @@ -29,7 +38,7 @@ } }, { - "package": "keychain-swift", + "package": "KeychainSwift", "repositoryURL": "https://github.com/evgenyneu/keychain-swift", "state": { "branch": null, @@ -56,7 +65,7 @@ } }, { - "package": "SwiftUI-Introspect", + "package": "Introspect", "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect", "state": { "branch": null, diff --git a/JellyfinPlayer/ConnectToServerView.swift b/JellyfinPlayer/ConnectToServerView.swift index 5bfd9064..a6e826c5 100644 --- a/JellyfinPlayer/ConnectToServerView.swift +++ b/JellyfinPlayer/ConnectToServerView.swift @@ -5,260 +5,46 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI import CoreData -import KeychainSwift import JellyfinAPI +import KeychainSwift +import SwiftUI struct ConnectToServerView: View { - @Environment(\.managedObjectContext) private var viewContext - @EnvironmentObject var globalData: GlobalData - @EnvironmentObject var jsi: justSignedIn + @StateObject + var viewModel = ConnectToServerViewModel() - @State private var uri = "" - @State private var isWorking = false - @State private var isErrored = false - @State private var isDone = false - @State private var isSignInErrored = false - @State private var isConnected = false - @State private var serverName = "" - @State private var usernameDisabled: Bool = false - @State private var publicUsers: [UserDto] = [] - @State private var lastPublicUsers: [UserDto] = [] - @State private var username = "" - @State private var password = "" - @State private var server_id = "" - @State private var serverSkipped: Bool = false - @State private var serverSkippedAlert: Bool = false - @State private var skip_server_bool: Bool = false - @State private var skip_server_obj: Server! - - @Binding var rootIsActive: Bool - - private var reauthDeviceID: String = "" - private let userUUID = UUID() - - init(skip_server: Bool, skip_server_prefill: Server, reauth_deviceId: String, isActive: Binding) { - _rootIsActive = isActive - skip_server_bool = skip_server - skip_server_obj = skip_server_prefill - reauthDeviceID = reauth_deviceId - } - - init(isActive: Binding) { - _rootIsActive = isActive - } - - func start() { - if skip_server_bool { - uri = skip_server_obj.baseURI! - - UserAPI.getPublicUsers() - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case .failure: - skip_server_bool = false - skip_server_obj = Server() - break - } - }, receiveValue: { response in - publicUsers = response - - serverSkipped = true - serverSkippedAlert = true - server_id = skip_server_obj.server_id! - serverName = skip_server_obj.name! - isConnected = true - }) - .store(in: &globalData.pendingAPIRequests) - } - } - - func doLogin() { - isWorking = true - - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - var deviceName = UIDevice.current.name - deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current) - deviceName = deviceName.removeRegexMatches(pattern: "[^\\w\\s]") - - let authHeader = "MediaBrowser Client=\"SwiftFin\", Device=\"\(deviceName)\", DeviceId=\"\(serverSkipped ? reauthDeviceID : userUUID.uuidString)\", Version=\"\(appVersion ?? "0.0.1")\"" - print(authHeader) - - JellyfinAPI.customHeaders["X-Emby-Authorization"] = authHeader - - let x: AuthenticateUserByName = AuthenticateUserByName(username: username, pw: password, password: nil) - - UserAPI.authenticateUserByName(authenticateUserByName: x) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case .failure(let error): - isWorking = false - if let err = error as? ErrorResponse { - switch err { - case .error(401, _, _, _): - isSignInErrored = true - case .error: - globalData.networkError = true - } - } - break - } - }, receiveValue: { response in - let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "Server") - let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) - - do { - try viewContext.execute(deleteRequest) - } catch _ as NSError { - - } - - let fetchRequest2: NSFetchRequest = NSFetchRequest(entityName: "SignedInUser") - let deleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2) - - do { - try viewContext.execute(deleteRequest2) - } catch _ as NSError { - - } - - let newServer = Server(context: viewContext) - newServer.baseURI = uri - newServer.name = serverName - newServer.server_id = server_id - - let newUser = SignedInUser(context: viewContext) - newUser.device_uuid = userUUID.uuidString - newUser.username = username - newUser.user_id = response.user!.id! - - let keychain = KeychainSwift() - keychain.set(response.accessToken!, forKey: "AccessToken_\(newUser.user_id!)") - - do { - try viewContext.save() - DispatchQueue.main.async { [self] in - globalData.authHeader = authHeader - _rootIsActive.wrappedValue = false - - globalData.expiredCredentials = false - globalData.networkError = false - globalData.user = newUser - globalData.server = newServer - - jsi.did = true - print("logged in") - } - } catch { - print("Couldn't store objects to CoreData") - } - }) - .store(in: &globalData.pendingAPIRequests) - } + @Binding + var isLoggedIn: Bool var body: some View { - Form { - if !isConnected { - Section(header: Text("Server Information")) { - TextField("Jellyfin Server URL", text: $uri) - .disableAutocorrection(true) - .autocapitalization(.none) - Button { - isWorking = true - if !uri.contains("http") { - uri = "https://" + uri - } - if uri.last == "/" { - uri = String(uri.dropLast()) + ZStack { + Form { + if viewModel.isConnectedServer { + if viewModel.publicUsers.isEmpty { + Section(header: Text("Login to \(ServerEnvironment.current.server.name ?? "")")) { + TextField("Username", text: $viewModel.username) + .disableAutocorrection(true) + .autocapitalization(.none) + SecureField("Password", text: $viewModel.password) + .disableAutocorrection(true) + .autocapitalization(.none) + Button { + viewModel.login() + } label: { + HStack { + Text("Login") + Spacer() + if viewModel.isLoading { + ProgressView() + } + } + }.disabled(viewModel.isLoading || viewModel.username.isEmpty) } - JellyfinAPI.basePath = uri - SystemAPI.getPublicSystemInfo() - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case .failure: - isErrored = true - isWorking = false - break - } - }, receiveValue: { response in - let server = response - serverName = server.serverName! - server_id = server.id! - if server.startupWizardCompleted ?? true { - isConnected = true - - UserAPI.getPublicUsers() - .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case .failure: - isErrored = true - isWorking = false - break - } - }, receiveValue: { response in - publicUsers = response - isWorking = false - }) - .store(in: &globalData.pendingAPIRequests) - } - }) - .store(in: &globalData.pendingAPIRequests) - } label: { - HStack { - Text("Connect") - Spacer() - if isWorking == true { - ProgressView() - } - } - }.disabled(isWorking || uri.isEmpty) - }.alert(isPresented: $isErrored) { - Alert(title: Text("Error"), message: Text("Couldn't connect to server"), dismissButton: .default(Text("Try again"))) - } - } else { - if publicUsers.count == 0 { - Section(header: Text("\(serverSkipped ? "Reauthenticate" : "Login") to \(serverName)")) { - TextField("Username", text: $username) - .disableAutocorrection(true) - .autocapitalization(.none) - .disabled(usernameDisabled) - SecureField("Password", text: $password) - .disableAutocorrection(true) - .autocapitalization(.none) - Button { - doLogin() - } label: { - HStack { - Text("Login") - Spacer() - if isWorking { - ProgressView() - } - } - }.disabled(isWorking || username.isEmpty) - .alert(isPresented: $isSignInErrored) { - Alert(title: Text("Error"), message: Text("Invalid credentials"), dismissButton: .default(Text("Back"))) - } - } - - if serverSkipped { Section { Button { - serverSkippedAlert = false - server_id = "" - serverName = "" - isConnected = false - serverSkipped = false + viewModel.isConnectedServer = false } label: { HStack { HStack { @@ -270,85 +56,85 @@ struct ConnectToServerView: View { } } } else { - Section { - Button { - publicUsers = lastPublicUsers - usernameDisabled = false - } label: { + Section(header: Text("Login to \(ServerEnvironment.current.server.name ?? "")")) { + ForEach(viewModel.publicUsers, id: \.id) { publicUser in HStack { - HStack { - Image(systemName: "chevron.left") - Text("Back") - } - Spacer() - } - } - } - } - } else { - Section(header: Text("\(serverSkipped ? "Reauthenticate" : "Login") to \(serverName)")) { - ForEach(publicUsers, id: \.id) { publicUser in - HStack { - Button() { - if publicUser.hasPassword ?? true { - lastPublicUsers = publicUsers - username = publicUser.name ?? "" - usernameDisabled = true - publicUsers = [] - } else { - publicUsers = [] - password = "" - username = publicUser.name ?? "" - doLogin() - } - } label: { - HStack { - Text(publicUser.name ?? "").font(.subheadline).fontWeight(.semibold) - Spacer() - if publicUser.primaryImageTag != nil { - ImageView(src: URL(string: "\(uri)/Users/\(publicUser.id ?? "")/Images/Primary?width=200&quality=80&tag=\(publicUser.primaryImageTag!)")!) - .frame(width: 60, height: 60) - .cornerRadius(30.0) - } else { - Image(systemName: "person.fill") - .foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8)) - .font(.system(size: 35)) - .frame(width: 60, height: 60) - .background(Color(red: 98/255, green: 121/255, blue: 205/255)) - .cornerRadius(30.0) - .shadow(radius: 6) + Button(action: { + viewModel.username = publicUser.name ?? "" + viewModel.publicUsers.removeAll() + if !(publicUser.hasPassword ?? true) { + viewModel.password = "" + viewModel.login() + } + }) { + HStack { + Text(publicUser.name ?? "").font(.subheadline).fontWeight(.semibold) + Spacer() + if publicUser.primaryImageTag != nil { + ImageView(src: URL(string: "\(ServerEnvironment.current.server.baseURI ?? "")/Users/\(publicUser.id ?? "")/Images/Primary?width=200&quality=80&tag=\(publicUser.primaryImageTag!)")!) + .frame(width: 60, height: 60) + .cornerRadius(30.0) + } else { + Image(systemName: "person.fill") + .foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8)) + .font(.system(size: 35)) + .frame(width: 60, height: 60) + .background(Color(red: 98 / 255, green: 121 / 255, blue: 205 / 255)) + .cornerRadius(30.0) + .shadow(radius: 6) + } } } } } } - } - Section { - Button() { - lastPublicUsers = publicUsers - publicUsers = [] - username = "" - } label: { - HStack { - Text("Other User").font(.subheadline).fontWeight(.semibold) - Spacer() - Image(systemName: "person.fill.questionmark") - .foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8)) - .font(.system(size: 35)) - .frame(width: 60, height: 60) - .background(Color(red: 98/255, green: 121/255, blue: 205/255)) - .cornerRadius(30.0) - .shadow(radius: 6) + Section { + Button { + viewModel.publicUsers.removeAll() + viewModel.username = "" + } label: { + HStack { + Text("Other User").font(.subheadline).fontWeight(.semibold) + Spacer() + Image(systemName: "person.fill.questionmark") + .foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8)) + .font(.system(size: 35)) + .frame(width: 60, height: 60) + .background(Color(red: 98 / 255, green: 121 / 255, blue: 205 / 255)) + .cornerRadius(30.0) + .shadow(radius: 6) + } } } } + } else { + Section(header: Text("Server Information")) { + TextField("Jellyfin Server URL", text: $viewModel.uri) + .disableAutocorrection(true) + .autocapitalization(.none) + Button { + viewModel.connectToServer() + } label: { + HStack { + Text("Connect") + Spacer() + } + if viewModel.isLoading { + ProgressView() + } + } + .disabled(viewModel.isLoading || viewModel.uri.isEmpty) + } } } - }.navigationTitle("Connect to Server") - .alert(isPresented: $serverSkippedAlert) { - Alert(title: Text("Error"), message: Text("Credentials have expired"), dismissButton: .default(Text("Sign in again"))) } - .onAppear(perform: start) + .alert(item: $viewModel.errorMessage) { _ in + Alert(title: Text("Error"), message: Text("message"), dismissButton: .default(Text("Try again"))) + } + .onReceive(viewModel.$isLoggedIn, perform: { flag in + isLoggedIn = flag + }) + .navigationTitle("Connect to Server") } } diff --git a/JellyfinPlayer/ContentView.swift b/JellyfinPlayer/ContentView.swift deleted file mode 100644 index 2d73f1f5..00000000 --- a/JellyfinPlayer/ContentView.swift +++ /dev/null @@ -1,236 +0,0 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI - -import KeychainSwift -import Nuke -import JellyfinAPI -import WidgetKit - -struct ContentView: View { - @Environment(\.managedObjectContext) private var viewContext - @EnvironmentObject var orientationInfo: OrientationInfo - @EnvironmentObject var jsi: justSignedIn - - @StateObject private var globalData = GlobalData() - - @State private var needsToSelectServer = false - @State private var isLoading = false - @State private var tabSelection: String = "Home" - @State private var libraries: [String] = [] - @State private var library_names: [String: String] = [:] - @State private var librariesShowRecentlyAdded: [String] = [] - @State private var libraryPrefillID: String = "" - @State private var showSettingsPopover: Bool = false - @State private var viewDidLoad: Bool = false - @State private var loadState: Int = 2 - - @FetchRequest(entity: Server.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Server.name, ascending: true)]) - var servers: FetchedResults - - @FetchRequest(entity: SignedInUser.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \SignedInUser.username, ascending: true)]) - var savedUsers: FetchedResults - - private var recentFilterSet: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: ["DateCreated"]) - - func startup() { - if viewDidLoad == true { - return - } - - let size = UIScreen.main.bounds.size - if size.width < size.height { - orientationInfo.orientation = .portrait - } else { - orientationInfo.orientation = .landscape - } - - ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory - DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk - - if servers.isEmpty { - isLoading = false - needsToSelectServer = true - } else { - isLoading = true - let savedUser = savedUsers[0] - - let keychain = KeychainSwift() - keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" - if keychain.get("AccessToken_\(savedUser.user_id ?? "")") != nil { - globalData.authToken = keychain.get("AccessToken_\(savedUser.user_id ?? "")") ?? "" - globalData.server = servers[0] - globalData.user = savedUser - } - - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - var deviceName = UIDevice.current.name - deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current) - deviceName = deviceName.removeRegexMatches(pattern: "[^\\w\\s]") - - var header = "MediaBrowser " - header.append("Client=\"SwiftFin\", ") - header.append("Device=\"\(deviceName)\", ") - header.append("DeviceId=\"\(globalData.user.device_uuid ?? "")\", ") - header.append("Version=\"\(appVersion ?? "0.0.1")\", ") - header.append("Token=\"\(globalData.authToken)\"") - - globalData.authHeader = header - JellyfinAPI.basePath = globalData.server.baseURI ?? "" - JellyfinAPI.customHeaders = ["X-Emby-Authorization": globalData.authHeader] - - DispatchQueue.global(qos: .userInitiated).async { - UserAPI.getCurrentUser() - .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) - loadState = loadState - 1 - }, receiveValue: { response in - libraries = response.configuration?.orderedViews ?? [] - librariesShowRecentlyAdded = libraries.filter { element in - return !(response.configuration?.latestItemsExcludes?.contains(element))! - } - - if loadState == 1 { - isLoading = false - viewDidLoad = true - } - }) - .store(in: &globalData.pendingAPIRequests) - - UserViewsAPI.getUserViews(userId: globalData.user.user_id ?? "") - .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) - loadState = loadState - 1 - }, receiveValue: { response in - response.items?.forEach({ item in - library_names[item.id ?? ""] = item.name - }) - - if loadState == 1 { - isLoading = false - viewDidLoad = true - } - }) - .store(in: &globalData.pendingAPIRequests) - } - - let defaults = UserDefaults.standard - if defaults.integer(forKey: "InNetworkBandwidth") == 0 { - defaults.setValue(40_000_000, forKey: "InNetworkBandwidth") - } - if defaults.integer(forKey: "OutOfNetworkBandwidth") == 0 { - defaults.setValue(40_000_000, forKey: "OutOfNetworkBandwidth") - } - } - WidgetCenter.shared.reloadAllTimelines() - } - - var body: some View { - if needsToSelectServer == true || globalData.user == nil || globalData.server == nil { - NavigationView { - ConnectToServerView(isActive: $needsToSelectServer) - } - .navigationViewStyle(StackNavigationViewStyle()) - .environmentObject(globalData) - .onAppear(perform: startup) - } else if globalData.expiredCredentials == true { - NavigationView { - ConnectToServerView(skip_server: true, skip_server_prefill: globalData.server, - reauth_deviceId: globalData.user.device_uuid!, isActive: $globalData.expiredCredentials) - } - .navigationViewStyle(StackNavigationViewStyle()) - .environmentObject(globalData) - .onAppear(perform: startup) - } else { - if !jsi.did { - if isLoading || globalData.user == nil || globalData.user.user_id == nil { - ProgressView() - .onAppear(perform: startup) - } else { - VStack { - TabView(selection: $tabSelection) { - NavigationView { - VStack(alignment: .leading) { - ScrollView { - Spacer().frame(height: orientationInfo.orientation == .portrait ? 0 : 16) - ContinueWatchingView() - NextUpView() - - ForEach(librariesShowRecentlyAdded, id: \.self) { library_id in - VStack(alignment: .leading) { - HStack { - Text("Latest \(library_names[library_id] ?? "")").font(.title2).fontWeight(.bold) - .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)) - Spacer() - NavigationLink(destination: LazyView { - LibraryView(usingParentID: library_id, - title: library_names[library_id] ?? "", usingFilters: recentFilterSet) - }) { - HStack { - Text("See All").font(.subheadline).fontWeight(.bold) - Image(systemName: "chevron.right").font(Font.subheadline.bold()) - } - } - }.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - LatestMediaView(usingParentID: library_id) - }.padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) - } - - Spacer().frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 20 : 30) - } - .navigationTitle("Home") - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - showSettingsPopover = true - } label: { - Image(systemName: "gear") - } - } - } - .fullScreenCover(isPresented: $showSettingsPopover) { - SettingsView(viewModel: SettingsViewModel(), close: $showSettingsPopover) - } - } - } - .navigationViewStyle(StackNavigationViewStyle()) - .tabItem { - Text("Home") - Image(systemName: "house") - } - .tag("Home") - NavigationView { - LibraryListView(libraries: library_names) - } - .navigationViewStyle(StackNavigationViewStyle()) - .tabItem { - Text("All Media") - Image(systemName: "folder") - } - .tag("All Media") - } - } - .environmentObject(globalData) - .onAppear(perform: startup) - .alert(isPresented: $globalData.networkError) { - Alert(title: Text("Network Error"), message: Text("An error occured while performing a network request"), dismissButton: .default(Text("Ok"))) - } - } - } else { - Text("Please wait...") - .onAppear(perform: { - DispatchQueue.main.async { [self] in - _viewDidLoad.wrappedValue = false - sleep(1) - self.jsi.did = false - } - }) - } - } - } -} diff --git a/JellyfinPlayer/ContinueWatchingView.swift b/JellyfinPlayer/ContinueWatchingView.swift index 2d75908b..2882781e 100644 --- a/JellyfinPlayer/ContinueWatchingView.swift +++ b/JellyfinPlayer/ContinueWatchingView.swift @@ -8,6 +8,7 @@ import SwiftUI import JellyfinAPI +import Combine struct ProgressBar: Shape { func path(in rect: CGRect) -> Path { @@ -31,21 +32,7 @@ struct ProgressBar: Shape { } struct ContinueWatchingView: View { - @EnvironmentObject var globalData: GlobalData - - @State private var items: [BaseItemDto] = [] - - func onAppear() { - DispatchQueue.global(qos: .userInitiated).async { - ItemsAPI.getResumeItems(userId: globalData.user.user_id!, limit: 12, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], mediaTypes: ["Video"], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) - .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) - }, receiveValue: { response in - items = response.items ?? [] - }) - .store(in: &globalData.pendingAPIRequests) - } - } + var items: [BaseItemDto] var body: some View { ScrollView(.horizontal, showsIndicators: false) { @@ -56,7 +43,7 @@ struct ContinueWatchingView: View { NavigationLink(destination: ItemView(item: item)) { VStack(alignment: .leading) { Spacer().frame(height: 10) - ImageView(src: item.getBackdropImage(baseURL: globalData.server.baseURI!, maxWidth: 320), bh: item.getBackdropImageBlurHash()) + ImageView(src: item.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 320), bh: item.getBackdropImageBlurHash()) .frame(width: 320, height: 180) .cornerRadius(10) .overlay( @@ -96,6 +83,6 @@ struct ContinueWatchingView: View { } else { EmptyView() } - }.onAppear(perform: onAppear) + } } } diff --git a/JellyfinPlayer/EpisodeItemView.swift b/JellyfinPlayer/EpisodeItemView.swift index b20743ae..cf9e4a36 100644 --- a/JellyfinPlayer/EpisodeItemView.swift +++ b/JellyfinPlayer/EpisodeItemView.swift @@ -7,10 +7,14 @@ import SwiftUI import JellyfinAPI +import Combine struct EpisodeItemView: View { - @EnvironmentObject private var globalData: GlobalData - @EnvironmentObject private var orientationInfo: OrientationInfo + @StateObject + var tempViewModel = ViewModel() + @State private var orientation = UIDeviceOrientation.unknown + @Environment(\.horizontalSizeClass) var hSizeClass + @Environment(\.verticalSizeClass) var vSizeClass @EnvironmentObject private var playbackInfo: VideoPlayerItem var item: BaseItemDto @@ -20,19 +24,19 @@ struct EpisodeItemView: View { didSet { if !settingState { if watched == true { - PlaystateAPI.markPlayedItem(userId: globalData.user.user_id!, itemId: item.id!) + PlaystateAPI.markPlayedItem(userId: SessionManager.current.userID!, itemId: item.id!) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { _ in }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } else { - PlaystateAPI.markUnplayedItem(userId: globalData.user.user_id!, itemId: item.id!) + PlaystateAPI.markUnplayedItem(userId: SessionManager.current.userID!, itemId: item.id!) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { _ in }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } } } @@ -43,26 +47,26 @@ struct EpisodeItemView: View { didSet { if !settingState { if favorite == true { - UserLibraryAPI.markFavoriteItem(userId: globalData.user.user_id!, itemId: item.id!) + UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { _ in }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } else { - UserLibraryAPI.unmarkFavoriteItem(userId: globalData.user.user_id!, itemId: item.id!) + UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { _ in }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } } } } var portraitHeaderView: some View { - ImageView(src: item.getBackdropImage(baseURL: globalData.server.baseURI!, maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: item.getBackdropImageBlurHash()) + ImageView(src: item.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: item.getBackdropImageBlurHash()) .opacity(0.4) .blur(radius: 2.0) } @@ -70,7 +74,7 @@ struct EpisodeItemView: View { var portraitHeaderOverlayView: some View { VStack(alignment: .leading) { HStack(alignment: .bottom, spacing: 12) { - ImageView(src: item.getSeriesPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 120), bh: item.getSeriesPrimaryImageBlurHash()) + ImageView(src: item.getSeriesPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 120), bh: item.getSeriesPrimaryImageBlurHash()) .frame(width: 120, height: 180) .cornerRadius(10) VStack(alignment: .leading) { @@ -150,7 +154,7 @@ struct EpisodeItemView: View { var body: some View { VStack(alignment: .leading) { - if orientationInfo.orientation == .portrait { + if hSizeClass == .compact && vSizeClass == .regular { ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitHeaderOverlayView, overlayAlignment: .bottomLeading, headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) { VStack(alignment: .leading) { Spacer() @@ -190,7 +194,7 @@ struct EpisodeItemView: View { LibraryView(withPerson: person) }) { VStack { - ImageView(src: person.getImage(baseURL: globalData.server.baseURI!, maxWidth: 100), bh: person.getBlurHash()) + ImageView(src: person.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: person.getBlurHash()) .frame(width: 100, height: 100) .cornerRadius(10) Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1) @@ -229,7 +233,7 @@ struct EpisodeItemView: View { } else { GeometryReader { geometry in ZStack { - ImageView(src: item.getBackdropImage(baseURL: globalData.server.baseURI!, maxWidth: 200), bh: item.getBackdropImageBlurHash()) + ImageView(src: item.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 200), bh: item.getBackdropImageBlurHash()) .opacity(0.3) .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) @@ -237,7 +241,7 @@ struct EpisodeItemView: View { .blur(radius: 4) HStack { VStack { - ImageView(src: item.getSeriesPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 120), bh: item.getSeriesPrimaryImageBlurHash()) + ImageView(src: item.getSeriesPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 120), bh: item.getSeriesPrimaryImageBlurHash()) .frame(width: 120, height: 180) .cornerRadius(10) Spacer().frame(height: 15) @@ -361,7 +365,7 @@ struct EpisodeItemView: View { LibraryView(withPerson: person) }) { VStack { - ImageView(src: person.getImage(baseURL: globalData.server.baseURI!, maxWidth: 100), bh: person.getBlurHash()) + ImageView(src: person.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: person.getBlurHash()) .frame(width: 100, height: 100) .cornerRadius(10) Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1) @@ -410,6 +414,9 @@ struct EpisodeItemView: View { watched = item.userData?.played ?? false settingState = false }) + .onRotate(perform: { orientation in + self.orientation = orientation + }) .navigationBarTitleDisplayMode(.inline) .navigationTitle("\(item.seriesName ?? "") - S\(String(item.parentIndexNumber ?? 0)):E\(String(item.indexNumber ?? 0))") .supportedOrientations(.allButUpsideDown) diff --git a/JellyfinPlayer/HomeView.swift b/JellyfinPlayer/HomeView.swift new file mode 100644 index 00000000..b01803cb --- /dev/null +++ b/JellyfinPlayer/HomeView.swift @@ -0,0 +1,85 @@ +// +/* + * 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 HomeView: View { + @StateObject + var viewModel = HomeViewModel() + @State + private var orientation = UIDevice.current.orientation + @Environment(\.horizontalSizeClass) + var hSizeClass + @Environment(\.verticalSizeClass) + var vSizeClass + @State + var showingSettings = false + + var body: some View { + ZStack { + ScrollView { + LazyVStack(alignment: .leading) { + Spacer().frame(height: hSizeClass == .compact && vSizeClass == .regular ? 0 : 16) + if !viewModel.resumeItems.isEmpty { + ContinueWatchingView(items: viewModel.resumeItems) + } + if !viewModel.nextUpItems.isEmpty { + NextUpView(items: viewModel.nextUpItems) + } + if !viewModel.librariesShowRecentlyAddedIDs.isEmpty { + ForEach(viewModel.librariesShowRecentlyAddedIDs, id: \.self) { libraryID in + VStack(alignment: .leading) { + let library = viewModel.libraries.first(where: { $0.id == libraryID }) + HStack { + Text("Latest \(library?.name ?? "")") + .font(.title2) + .fontWeight(.bold) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)) + Spacer() + NavigationLink(destination: LazyView { + LibraryView(usingParentID: libraryID, + title: library?.name ?? "", usingFilters: viewModel.recentFilterSet) + }) { + HStack { + Text("See All").font(.subheadline).fontWeight(.bold) + Image(systemName: "chevron.right").font(Font.subheadline.bold()) + } + } + }.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + LatestMediaView(usingParentID: libraryID) + }.padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) + } + } + + Spacer().frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 20 : 30) + } + } + if viewModel.isLoading { + ProgressView() + } + } + .onRotate { + orientation = $0 + } + .navigationTitle(MainTabView.Tab.home.localized) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + showingSettings = true + } label: { + Image(systemName: "gear") + } + } + } + .fullScreenCover(isPresented: $showingSettings) { + SettingsView(viewModel: SettingsViewModel(), close: $showingSettings) + } + } +} diff --git a/JellyfinPlayer/ItemView.swift b/JellyfinPlayer/ItemView.swift index 01677170..a2fb880c 100644 --- a/JellyfinPlayer/ItemView.swift +++ b/JellyfinPlayer/ItemView.swift @@ -15,7 +15,6 @@ class VideoPlayerItem: ObservableObject { } struct ItemView: View { - @EnvironmentObject private var globalData: GlobalData private var item: BaseItemDto @StateObject private var videoPlayerItem: VideoPlayerItem = VideoPlayerItem() diff --git a/JellyfinPlayer/JellyfinPlayerApp.swift b/JellyfinPlayer/JellyfinPlayerApp.swift index c85c1033..1afbf8a2 100644 --- a/JellyfinPlayer/JellyfinPlayerApp.swift +++ b/JellyfinPlayer/JellyfinPlayerApp.swift @@ -14,36 +14,6 @@ extension UIDevice { } } -class OrientationInfo: ObservableObject { - enum Orientation { - case portrait - case landscape - } - - @Published var orientation: Orientation = .portrait - - private var _observer: NSObjectProtocol? - - init() { - _observer = NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: nil) { [weak self] note in - guard let device = note.object as? UIDevice else { - return - } - if device.orientation.isPortrait { - self?.orientation = .portrait - } else if device.orientation.isLandscape { - self?.orientation = .landscape - } - } - } - - deinit { - if let observer = _observer { - NotificationCenter.default.removeObserver(observer) - } - } -} - extension View { func withHostingWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View { self.background(HostingWindowFinder(callback: callback)) @@ -171,16 +141,13 @@ extension View { @main struct JellyfinPlayerApp: App { let persistenceController = PersistenceController.shared - @StateObject private var jsi = justSignedIn() var body: some Scene { WindowGroup { - ContentView() + SplashView() .environment(\.managedObjectContext, persistenceController.container.viewContext) - .environmentObject(OrientationInfo()) - .environmentObject(jsi) .withHostingWindow { window in - window?.rootViewController = PreferenceUIHostingController(wrappedView: ContentView().environment(\.managedObjectContext, persistenceController.container.viewContext).environmentObject(OrientationInfo()).environmentObject(jsi)) + window?.rootViewController = PreferenceUIHostingController(wrappedView: SplashView().environment(\.managedObjectContext, persistenceController.container.viewContext)) } } } diff --git a/JellyfinPlayer/LatestMediaView.swift b/JellyfinPlayer/LatestMediaView.swift index d44f6de5..dd75a03f 100644 --- a/JellyfinPlayer/LatestMediaView.swift +++ b/JellyfinPlayer/LatestMediaView.swift @@ -7,10 +7,12 @@ import SwiftUI import JellyfinAPI +import Combine struct LatestMediaView: View { - @EnvironmentObject var globalData: GlobalData + @StateObject + var tempViewModel = ViewModel() @State var items: [BaseItemDto] = [] private var library_id: String = "" @State private var viewDidLoad: Bool = false @@ -26,13 +28,13 @@ struct LatestMediaView: View { viewDidLoad = true DispatchQueue.global(qos: .userInitiated).async { - UserLibraryAPI.getLatestMedia(userId: globalData.user.user_id!, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true, limit: 12) + UserLibraryAPI.getLatestMedia(userId: SessionManager.current.userID!, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true, limit: 12) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { response in items = response }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } } @@ -45,7 +47,7 @@ struct LatestMediaView: View { NavigationLink(destination: ItemView(item: item)) { VStack(alignment: .leading) { Spacer().frame(height: 10) - ImageView(src: item.getPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 100), bh: item.getPrimaryImageBlurHash()) + ImageView(src: item.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: item.getPrimaryImageBlurHash()) .frame(width: 100, height: 150) .cornerRadius(10) Spacer().frame(height: 5) diff --git a/JellyfinPlayer/LibraryFilterView.swift b/JellyfinPlayer/LibraryFilterView.swift index 66dfcd50..335a93f3 100644 --- a/JellyfinPlayer/LibraryFilterView.swift +++ b/JellyfinPlayer/LibraryFilterView.swift @@ -9,7 +9,6 @@ import SwiftUI import JellyfinAPI struct LibraryFilterView: View { - @EnvironmentObject var globalData: GlobalData @Binding var filter: LibraryFilters var body: some View { diff --git a/JellyfinPlayer/LibraryListView.swift b/JellyfinPlayer/LibraryListView.swift index 70574c4e..f916894a 100644 --- a/JellyfinPlayer/LibraryListView.swift +++ b/JellyfinPlayer/LibraryListView.swift @@ -9,52 +9,33 @@ import Foundation import SwiftUI struct LibraryListView: View { - @EnvironmentObject var globalData: GlobalData - - @State var library_ids: [String] = ["favorites", "genres"] - @State var library_names: [String: String] = ["favorites": "Favorites", "genres": "Genres"] - var libraries: [String: String] = [:] // input libraries - var withFavorites: LibraryFilters = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: []) - - init(libraries: [String: String]) { - self.libraries = libraries - } - - func onAppear() { - if library_ids.count == 2 { - libraries.forEach { k, v in - print("\(k): \(v)") - _library_ids.wrappedValue.append(k) - _library_names.wrappedValue[k] = v - } - } - } + @StateObject + var viewModel = LibraryListViewModel() var body: some View { - List(library_ids, id: \.self) { key in - switch key { - case "favorites": - NavigationLink(destination: LazyView { - LibraryView(usingParentID: "", title: library_names[key] ?? "", usingFilters: withFavorites) - }) { - Text(library_names[key] ?? "") - } - case "genres": - NavigationLink(destination: LazyView { - EmptyView() - }) { - Text(library_names[key] ?? "") - } - default: - NavigationLink(destination: LazyView { - LibraryView(usingParentID: key, title: library_names[key] ?? "") - }) { - Text(library_names[key] ?? "") - } + List(viewModel.libraries, id: \.self) { library in + switch library.id { + case "favorites": + NavigationLink(destination: LazyView { + LibraryView(usingParentID: "", title: library.name ?? "", usingFilters: viewModel.withFavorites) + }) { + Text(library.name ?? "") + } + case "genres": + NavigationLink(destination: LazyView { + EmptyView() + }) { + Text(library.name ?? "") + } + default: + NavigationLink(destination: LazyView { + LibraryView(usingParentID: library.id ?? "", title: library.name ?? "") + }) { + Text(library.name ?? "") + } } } .navigationTitle("All Media") - .onAppear(perform: onAppear) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { NavigationLink(destination: LazyView { diff --git a/JellyfinPlayer/LibrarySearchView.swift b/JellyfinPlayer/LibrarySearchView.swift index 53870cf2..353211b7 100644 --- a/JellyfinPlayer/LibrarySearchView.swift +++ b/JellyfinPlayer/LibrarySearchView.swift @@ -7,11 +7,12 @@ import SwiftUI import JellyfinAPI +import Combine struct LibrarySearchView: View { - @EnvironmentObject var globalData: GlobalData - @EnvironmentObject var orientationInfo: OrientationInfo + @StateObject + var tempViewModel = ViewModel() @State private var items: [BaseItemDto] = [] @State private var searchQuery: String = "" @State private var isLoading: Bool = false @@ -29,16 +30,15 @@ struct LibrarySearchView: View { func requestSearch(query: String) { isLoading = true - DispatchQueue.global(qos: .userInitiated).async { - ItemsAPI.getItemsByUserId(userId: globalData.user.user_id!, limit: 60, recursive: true, searchTerm: query, sortOrder: [.ascending], parentId: (usingParentID != "" ? usingParentID : nil), fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: ["Movie", "Series"], sortBy: ["SortName"], enableUserData: true, enableImages: true) + ItemsAPI.getItemsByUserId(userId: SessionManager.current.userID!, limit: 60, recursive: true, searchTerm: query, sortOrder: [.ascending], parentId: (usingParentID != "" ? usingParentID : nil), fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: ["Movie", "Series"], sortBy: ["SortName"], enableUserData: true, enableImages: true) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { response in items = response.items ?? [] isLoading = false }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } } @@ -68,7 +68,7 @@ struct LibrarySearchView: View { ForEach(items, id: \.id) { item in NavigationLink(destination: ItemView(item: item)) { VStack(alignment: .leading) { - ImageView(src: item.getPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 100), bh: item.getPrimaryImageBlurHash()) + ImageView(src: item.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: item.getPrimaryImageBlurHash()) .frame(width: 100, height: 150) .cornerRadius(10) Text(item.name ?? "") @@ -89,7 +89,7 @@ struct LibrarySearchView: View { } } Spacer().frame(height: 16) - .onChange(of: orientationInfo.orientation) { _ in + .onRotate { _ in recalcTracks() } } diff --git a/JellyfinPlayer/LibraryView.swift b/JellyfinPlayer/LibraryView.swift index 1273953a..a6709f9a 100644 --- a/JellyfinPlayer/LibraryView.swift +++ b/JellyfinPlayer/LibraryView.swift @@ -9,11 +9,12 @@ import SwiftUI import NukeUI import JellyfinAPI +import Combine struct LibraryView: View { - @EnvironmentObject var globalData: GlobalData - @EnvironmentObject var orientationInfo: OrientationInfo + @StateObject + var tempViewModel = ViewModel() @State private var items: [BaseItemDto] = [] @State private var isLoading: Bool = false @@ -69,9 +70,9 @@ struct LibraryView: View { items = [] DispatchQueue.global(qos: .userInitiated).async { - ItemsAPI.getItemsByUserId(userId: globalData.user.user_id!, startIndex: currentPage * 100, limit: 100, recursive: true, searchTerm: nil, sortOrder: filters.sortOrder, parentId: (usingParentID != "" ? usingParentID : nil), fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: ["Movie", "Series"], filters: filters.filters, sortBy: filters.sortBy, enableUserData: true, personIds: (personId == "" ? nil : [personId]), studioIds: (studio == "" ? nil : [studio]), genreIds: (genre == "" ? nil : [genre]), enableImages: true) + ItemsAPI.getItemsByUserId(userId: SessionManager.current.userID!, startIndex: currentPage * 100, limit: 100, recursive: true, searchTerm: nil, sortOrder: filters.sortOrder, parentId: (usingParentID != "" ? usingParentID : nil), fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: ["Movie", "Series"], filters: filters.filters, sortBy: filters.sortBy, enableUserData: true, personIds: (personId == "" ? nil : [personId]), studioIds: (studio == "" ? nil : [studio]), genreIds: (genre == "" ? nil : [genre]), enableImages: true) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) isLoading = false }, receiveValue: { response in let x = ceil(Double(response.totalRecordCount!) / 100.0) @@ -80,7 +81,7 @@ struct LibraryView: View { isLoading = false viewDidLoad = true }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } } @@ -107,7 +108,7 @@ struct LibraryView: View { ForEach(items, id: \.id) { item in NavigationLink(destination: ItemView(item: item)) { VStack(alignment: .leading) { - ImageView(src: item.getPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 100), bh: item.getPrimaryImageBlurHash()) + ImageView(src: item.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: item.getPrimaryImageBlurHash()) .frame(width: 100, height: 150) .cornerRadius(10) Text(item.name ?? "") @@ -126,7 +127,7 @@ struct LibraryView: View { }.frame(width: 100) } } - }.onChange(of: orientationInfo.orientation) { _ in + }.onRotate { _ in recalcTracks() } if totalPages > 1 { diff --git a/JellyfinPlayer/MainTabView.swift b/JellyfinPlayer/MainTabView.swift new file mode 100644 index 00000000..c100d5a0 --- /dev/null +++ b/JellyfinPlayer/MainTabView.swift @@ -0,0 +1,55 @@ +// + /* + * 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(Tab.home.localized) + Image(systemName: "house") + } + .tag(Tab.home) + NavigationView { + LibraryListView() + } + .navigationViewStyle(StackNavigationViewStyle()) + .tabItem { + Text(Tab.allMedia.localized) + Image(systemName: "folder") + } + .tag(Tab.allMedia) + } + } +} + +extension MainTabView { + + enum Tab: String { + case home + case allMedia + + var localized: String { + switch self { + case .home: + return "Home" + case .allMedia: + return "All Media" + } + } + } +} diff --git a/JellyfinPlayer/MovieItemView.swift b/JellyfinPlayer/MovieItemView.swift index e132ef36..441065ac 100644 --- a/JellyfinPlayer/MovieItemView.swift +++ b/JellyfinPlayer/MovieItemView.swift @@ -5,34 +5,44 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI +import Combine import JellyfinAPI +import SwiftUI struct MovieItemView: View { - @EnvironmentObject private var globalData: GlobalData - @EnvironmentObject private var orientationInfo: OrientationInfo - @EnvironmentObject private var playbackInfo: VideoPlayerItem + @StateObject + var tempViewModel = ViewModel() + @State + private var orientation = UIDeviceOrientation.unknown + @Environment(\.horizontalSizeClass) + var hSizeClass + @Environment(\.verticalSizeClass) + var vSizeClass + @EnvironmentObject + private var playbackInfo: VideoPlayerItem var item: BaseItemDto - @State private var settingState: Bool = true - @State private var watched: Bool = false { + @State + private var settingState: Bool = true + @State + private var watched: Bool = false { didSet { if !settingState { if watched == true { - PlaystateAPI.markPlayedItem(userId: globalData.user.user_id!, itemId: item.id!) + PlaystateAPI.markPlayedItem(userId: SessionManager.current.userID!, itemId: item.id!) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { _ in }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } else { - PlaystateAPI.markUnplayedItem(userId: globalData.user.user_id!, itemId: item.id!) + PlaystateAPI.markUnplayedItem(userId: SessionManager.current.userID!, itemId: item.id!) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { _ in }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } } } @@ -43,26 +53,29 @@ struct MovieItemView: View { didSet { if !settingState { if favorite == true { - UserLibraryAPI.markFavoriteItem(userId: globalData.user.user_id!, itemId: item.id!) + UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { _ in }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } else { - UserLibraryAPI.unmarkFavoriteItem(userId: globalData.user.user_id!, itemId: item.id!) + UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { _ in }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } } } } var portraitHeaderView: some View { - ImageView(src: item.getBackdropImage(baseURL: globalData.server.baseURI!, maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: item.getBackdropImageBlurHash()) + ImageView(src: item + .getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, + maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), + bh: item.getBackdropImageBlurHash()) .opacity(0.4) .blur(radius: 2.0) } @@ -70,7 +83,7 @@ struct MovieItemView: View { var portraitHeaderOverlayView: some View { VStack(alignment: .leading) { HStack(alignment: .bottom, spacing: 12) { - ImageView(src: item.getPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 120)) + ImageView(src: item.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 120)) .frame(width: 120, height: 180) .cornerRadius(10) VStack(alignment: .leading) { @@ -152,8 +165,11 @@ struct MovieItemView: View { var body: some View { VStack(alignment: .leading) { - if orientationInfo.orientation == .portrait { - ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitHeaderOverlayView, overlayAlignment: .bottomLeading, headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) { + if hSizeClass == .compact && vSizeClass == .regular { + ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitHeaderOverlayView, + overlayAlignment: .bottomLeading, + headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds + .width * 0.5625) { VStack(alignment: .leading) { Spacer() .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) @@ -192,7 +208,9 @@ struct MovieItemView: View { LibraryView(withPerson: person) }) { VStack { - ImageView(src: person.getImage(baseURL: globalData.server.baseURI!, maxWidth: 100), bh: person.getBlurHash()) + ImageView(src: person + .getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), + bh: person.getBlurHash()) .frame(width: 100, height: 100) .cornerRadius(10) Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1) @@ -231,7 +249,8 @@ struct MovieItemView: View { } else { GeometryReader { geometry in ZStack { - ImageView(src: item.getBackdropImage(baseURL: globalData.server.baseURI!, maxWidth: 200), bh: item.getBackdropImageBlurHash()) + ImageView(src: item.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 200), + bh: item.getBackdropImageBlurHash()) .opacity(0.3) .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) @@ -239,7 +258,8 @@ struct MovieItemView: View { .blur(radius: 4) HStack { VStack { - ImageView(src: item.getPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 120), bh: item.getPrimaryImageBlurHash()) + ImageView(src: item.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 120), + bh: item.getPrimaryImageBlurHash()) .frame(width: 120, height: 180) .cornerRadius(10) Spacer().frame(height: 15) @@ -365,10 +385,14 @@ struct MovieItemView: View { LibraryView(withPerson: person) }) { VStack { - ImageView(src: person.getImage(baseURL: globalData.server.baseURI!, maxWidth: 100), bh: person.getBlurHash()) + ImageView(src: person + .getImage(baseURL: ServerEnvironment.current.server.baseURI!, + maxWidth: 100), + bh: person.getBlurHash()) .frame(width: 100, height: 100) .cornerRadius(10) - Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1) + Text(person.name ?? "").font(.footnote).fontWeight(.regular) + .lineLimit(1) .frame(width: 100).foregroundColor(Color.primary) if person.role != "" { Text(person.role!).font(.caption).fontWeight(.medium).lineLimit(1) @@ -414,6 +438,9 @@ struct MovieItemView: View { watched = item.userData?.played ?? false settingState = false }) + .onRotate { + orientation = $0 + } .navigationBarTitleDisplayMode(.inline) .navigationTitle(item.name ?? "") .supportedOrientations(.allButUpsideDown) diff --git a/JellyfinPlayer/NextUpView.swift b/JellyfinPlayer/NextUpView.swift index f0fd9400..d77e0038 100644 --- a/JellyfinPlayer/NextUpView.swift +++ b/JellyfinPlayer/NextUpView.swift @@ -6,30 +6,12 @@ */ import SwiftUI +import Combine import JellyfinAPI struct NextUpView: View { - @EnvironmentObject var globalData: GlobalData - @State private var items: [BaseItemDto] = [] - @State private var viewDidLoad: Bool = false - - func onAppear() { - if viewDidLoad == true { - return - } - viewDidLoad = true - - DispatchQueue.global(qos: .userInitiated).async { - TvShowsAPI.getNextUp(userId: globalData.user.user_id!, limit: 12, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) - .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) - }, receiveValue: { response in - items = response.items ?? [] - }) - .store(in: &globalData.pendingAPIRequests) - } - } + var items: [BaseItemDto] var body: some View { VStack(alignment: .leading) { @@ -44,7 +26,7 @@ struct NextUpView: View { ForEach(items, id: \.id) { item in NavigationLink(destination: ItemView(item: item)) { VStack(alignment: .leading) { - ImageView(src: item.getSeriesPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 100), bh: item.getSeriesPrimaryImageBlurHash()) + ImageView(src: item.getSeriesPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: item.getSeriesPrimaryImageBlurHash()) .frame(width: 100, height: 150) .cornerRadius(10) Spacer().frame(height: 5) @@ -67,7 +49,6 @@ struct NextUpView: View { .frame(height: 200) } } - .onAppear(perform: onAppear) .padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) } } diff --git a/JellyfinPlayer/SeasonItemView.swift b/JellyfinPlayer/SeasonItemView.swift index 0c6b0b80..988dbb40 100644 --- a/JellyfinPlayer/SeasonItemView.swift +++ b/JellyfinPlayer/SeasonItemView.swift @@ -6,14 +6,19 @@ */ import SwiftUI +import Combine import JellyfinAPI struct SeasonItemView: View { - @EnvironmentObject var globalData: GlobalData - @EnvironmentObject var orientationInfo: OrientationInfo + @StateObject + var tempViewModel = ViewModel() + @State private var orientation = UIDeviceOrientation.unknown + @Environment(\.horizontalSizeClass) var hSizeClass + @Environment(\.verticalSizeClass) var vSizeClass var item: BaseItemDto = BaseItemDto() @State private var episodes: [BaseItemDto] = [] + @State private var isLoading: Bool = true @State private var viewDidLoad: Bool = false @@ -28,15 +33,15 @@ struct SeasonItemView: View { } DispatchQueue.global(qos: .userInitiated).async { - TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: globalData.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seasonId: item.id ?? "") + TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.current.userID!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seasonId: item.id ?? "") .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) isLoading = false }, receiveValue: { response in viewDidLoad = true episodes = response.items ?? [] }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } } @@ -45,7 +50,7 @@ struct SeasonItemView: View { if isLoading { EmptyView() } else { - ImageView(src: item.getSeriesBackdropImage(baseURL: globalData.server.baseURI!, maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: item.getSeriesBackdropImageBlurHash()) + ImageView(src: item.getSeriesBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: item.getSeriesBackdropImageBlurHash()) .opacity(0.4) .blur(radius: 2.0) } @@ -53,7 +58,7 @@ struct SeasonItemView: View { var portraitHeaderOverlayView: some View { HStack(alignment: .bottom, spacing: 12) { - ImageView(src: item.getPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 120), bh: item.getPrimaryImageBlurHash()) + ImageView(src: item.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 120), bh: item.getPrimaryImageBlurHash()) .frame(width: 120, height: 180) .cornerRadius(10) VStack(alignment: .leading) { @@ -75,7 +80,7 @@ struct SeasonItemView: View { @ViewBuilder var innerBody: some View { - if orientationInfo.orientation == .portrait { + if hSizeClass == .compact && vSizeClass == .regular { ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitHeaderOverlayView, overlayAlignment: .bottomLeading, @@ -92,7 +97,7 @@ struct SeasonItemView: View { ForEach(episodes, id: \.id) { episode in NavigationLink(destination: ItemView(item: episode)) { HStack { - ImageView(src: episode.getPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 150), bh: episode.getPrimaryImageBlurHash()) + ImageView(src: episode.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 150), bh: episode.getPrimaryImageBlurHash()) .shadow(radius: 5) .frame(width: 150, height: 90) .cornerRadius(10) @@ -151,7 +156,7 @@ struct SeasonItemView: View { } else { GeometryReader { geometry in ZStack { - ImageView(src: item.getSeriesBackdropImage(baseURL: globalData.server.baseURI!, maxWidth: 200), bh: item.getSeriesBackdropImageBlurHash()) + ImageView(src: item.getSeriesBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 200), bh: item.getSeriesBackdropImageBlurHash()) .opacity(0.4) .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) @@ -160,7 +165,7 @@ struct SeasonItemView: View { HStack { VStack(alignment: .leading) { Spacer().frame(height: 16) - ImageView(src: item.getPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 120), bh: item.getPrimaryImageBlurHash()) + ImageView(src: item.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 120), bh: item.getPrimaryImageBlurHash()) .frame(width: 120, height: 180) .cornerRadius(10) Spacer().frame(height: 4) @@ -185,7 +190,7 @@ struct SeasonItemView: View { ForEach(episodes, id: \.id) { episode in NavigationLink(destination: ItemView(item: episode)) { HStack { - ImageView(src: episode.getPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 150), bh: episode.getPrimaryImageBlurHash()) + ImageView(src: episode.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 150), bh: episode.getPrimaryImageBlurHash()) .shadow(radius: 5) .frame(width: 150, height: 90) .cornerRadius(10) @@ -244,15 +249,18 @@ struct SeasonItemView: View { } } } - + var body: some View { if isLoading { ProgressView() - .onAppear(perform: onAppear) + .onAppear(perform: onAppear) } else { innerBody - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("\(item.name ?? "") - \(item.seriesName ?? "")") + .onRotate { + orientation = $0 + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("\(item.name ?? "") - \(item.seriesName ?? "")") } } } diff --git a/JellyfinPlayer/SeriesItemView.swift b/JellyfinPlayer/SeriesItemView.swift index f84d97b0..63892b94 100644 --- a/JellyfinPlayer/SeriesItemView.swift +++ b/JellyfinPlayer/SeriesItemView.swift @@ -7,10 +7,11 @@ import SwiftUI import JellyfinAPI +import Combine struct SeriesItemView: View { - @EnvironmentObject private var globalData: GlobalData - @EnvironmentObject private var orientationInfo: OrientationInfo + @StateObject + var tempViewModel = ViewModel() var item: BaseItemDto @@ -25,17 +26,18 @@ struct SeriesItemView: View { } isLoading = true + DispatchQueue.global(qos: .userInitiated).async { TvShowsAPI.getSeasons(seriesId: item.id ?? "", fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: globalData, completion: completion) + print(completion) }, receiveValue: { response in isLoading = false viewDidLoad = true seasons = response.items ?? [] }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &tempViewModel.cancellables) } } @@ -60,7 +62,7 @@ struct SeriesItemView: View { ForEach(seasons, id: \.id) { season in NavigationLink(destination: ItemView(item: season)) { VStack(alignment: .leading) { - ImageView(src: season.getPrimaryImage(baseURL: globalData.server.baseURI!, maxWidth: 100), bh: season.getPrimaryImageBlurHash()) + ImageView(src: season.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: season.getPrimaryImageBlurHash()) .frame(width: 100, height: 150) .cornerRadius(10) .shadow(radius: 5) @@ -79,7 +81,7 @@ struct SeriesItemView: View { } } Spacer().frame(height: 2) - }.onChange(of: orientationInfo.orientation) { _ in + }.onRotate { _ in recalcTracks() } } diff --git a/JellyfinPlayer/SettingsView.swift b/JellyfinPlayer/SettingsView.swift index c38d2c49..b42c0fd4 100644 --- a/JellyfinPlayer/SettingsView.swift +++ b/JellyfinPlayer/SettingsView.swift @@ -10,28 +10,25 @@ import SwiftUI struct SettingsView: View { @Environment(\.managedObjectContext) private var viewContext - - @EnvironmentObject var globalData: GlobalData - @EnvironmentObject var jsi: justSignedIn - + @ObservedObject var viewModel: SettingsViewModel - + @Binding var close: Bool @State private var inNetworkStreamBitrate: Int = 40_000_000 @State private var outOfNetworkStreamBitrate: Int = 40_000_000 @State private var autoSelectSubtitles: Bool = false @State private var autoSelectSubtitlesLangcode: String = "none" @State private var username: String = "" - + func onAppear() { let defaults = UserDefaults.standard - username = globalData.user.username! + username = SessionManager.current.user.username! inNetworkStreamBitrate = defaults.integer(forKey: "InNetworkBandwidth") outOfNetworkStreamBitrate = defaults.integer(forKey: "OutOfNetworkBandwidth") autoSelectSubtitles = defaults.bool(forKey: "AutoSelectSubtitles") autoSelectSubtitlesLangcode = defaults.string(forKey: "AutoSelectSubtitlesLangcode") ?? "" } - + var body: some View { NavigationView { Form { @@ -44,7 +41,7 @@ struct SettingsView: View { let defaults = UserDefaults.standard defaults.setValue(_inNetworkStreamBitrate.wrappedValue, forKey: "InNetworkBandwidth") } - + Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) { ForEach(self.viewModel.bitrates, id: \.self) { bitrate in Text(bitrate.name).tag(bitrate.value) @@ -54,7 +51,7 @@ struct SettingsView: View { defaults.setValue(_outOfNetworkStreamBitrate.wrappedValue, forKey: "OutOfNetworkBandwidth") } } - + Section(header: Text("Accessibility")) { Toggle("Automatically show subtitles", isOn: $autoSelectSubtitles).onChange(of: autoSelectSubtitles, perform: { _ in let defaults = UserDefaults.standard @@ -62,7 +59,7 @@ struct SettingsView: View { }) Picker("Language preferences", selection: $autoSelectSubtitlesLangcode) {} } - + Section { HStack { Text("Signed in as \(username)").foregroundColor(.primary) @@ -70,27 +67,28 @@ struct SettingsView: View { Button { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "Server") let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) - + do { try viewContext.execute(deleteRequest) } catch _ as NSError { // TODO: handle the error } - + let fetchRequest2: NSFetchRequest = NSFetchRequest(entityName: "SignedInUser") let deleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2) - + do { try viewContext.execute(deleteRequest2) } catch _ as NSError { // TODO: handle the error } - - globalData.server = Server() - globalData.user = SignedInUser() - globalData.authToken = "" - globalData.authHeader = "" - jsi.did = true + + do { + try SessionManager.current.logout() + try ServerEnvironment.current.reset() + } catch { + print(error) + } // TODO: This should redirect to the server selection screen exit(-1) } label: { diff --git a/JellyfinPlayer/SplashView.swift b/JellyfinPlayer/SplashView.swift new file mode 100644 index 00000000..099a3a27 --- /dev/null +++ b/JellyfinPlayer/SplashView.swift @@ -0,0 +1,26 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI + +struct SplashView: View { + @StateObject + var viewModel = SplashViewModel() + + var body: some View { + if viewModel.isLoggedIn { + MainTabView() + } else { + NavigationView { + ConnectToServerView(isLoggedIn: $viewModel.isLoggedIn) + } + .navigationViewStyle(StackNavigationViewStyle()) + } + } +} diff --git a/JellyfinPlayer/VideoPlayer.swift b/JellyfinPlayer/VideoPlayer.swift index f5cd4a46..448fbe82 100644 --- a/JellyfinPlayer/VideoPlayer.swift +++ b/JellyfinPlayer/VideoPlayer.swift @@ -9,6 +9,7 @@ import SwiftUI import MobileVLCKit import JellyfinAPI import MediaPlayer +import Combine struct Subtitle { var name: String @@ -38,8 +39,8 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe weak var delegate: PlayerViewControllerDelegate? + var cancellables = Set() var mediaPlayer = VLCMediaPlayer() - var globalData = GlobalData() @IBOutlet weak var timeText: UILabel! @IBOutlet weak var videoContentView: UIView! @@ -281,20 +282,21 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe // Fetch max bitrate from UserDefaults depending on current connection mode let defaults = UserDefaults.standard - let maxBitrate = globalData.isInNetwork ? defaults.integer(forKey: "InNetworkBandwidth") : defaults.integer(forKey: "OutOfNetworkBandwidth") + // globalData.isInNetwork ? defaults.integer(forKey: "InNetworkBandwidth") : defaults.integer(forKey: "OutOfNetworkBandwidth") + let maxBitrate = defaults.integer(forKey: "InNetworkBandwidth") // Build a device profile let builder = DeviceProfileBuilder() builder.setMaxBitrate(bitrate: maxBitrate) let profile = builder.buildProfile() - let playbackInfo = PlaybackInfoDto(userId: globalData.user.user_id!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true) + let playbackInfo = PlaybackInfoDto(userId: SessionManager.current.userID!, 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: globalData.user.user_id!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo) - .sink(receiveCompletion: { completion in - HandleAPIRequestCompletion(globalData: self.globalData, completion: completion) + MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.current.userID!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo) + .sink(receiveCompletion: { result in + print(result) }, receiveValue: { [self] response in playSessionId = response.playSessionId ?? "" @@ -306,7 +308,7 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe let mediaSource = response.mediaSources!.first.self! if mediaSource.transcodingUrl != nil { // Item is being transcoded by request of server - let streamURL = URL(string: "\(globalData.server.baseURI!)\(mediaSource.transcodingUrl!)") + let streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(mediaSource.transcodingUrl!)") let item = PlaybackItem() item.videoType = .transcode item.videoUrl = streamURL! @@ -319,7 +321,7 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe if stream.type == .subtitle { var deliveryUrl: URL? if stream.deliveryMethod == .external { - deliveryUrl = URL(string: "\(globalData.server.baseURI!)\(stream.deliveryUrl!)")! + deliveryUrl = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(stream.deliveryUrl!)")! } else { deliveryUrl = nil } @@ -346,7 +348,7 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe playbackItem = item } else { // Item will be directly played by the client. - let streamURL: URL = URL(string: "\(globalData.server.baseURI!)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(globalData.user.device_uuid!)&api_key=\(globalData.authToken)&Tag=\(mediaSource.eTag!)")! + 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.authToken)&Tag=\(mediaSource.eTag!)")! let item = PlaybackItem() item.videoUrl = streamURL @@ -360,7 +362,7 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe if stream.type == .subtitle { var deliveryUrl: URL? if stream.deliveryMethod == .external { - deliveryUrl = URL(string: "\(globalData.server.baseURI!)\(stream.deliveryUrl!)")! + deliveryUrl = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(stream.deliveryUrl!)")! } else { deliveryUrl = nil } @@ -416,7 +418,7 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe mediaPlayer.pause() mediaPlayer.play() }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &cancellables) } } @@ -521,12 +523,12 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe 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: Int64(mediaPlayer.position * Float(manifest.runTimeTicks!)), 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: { completion in - HandleAPIRequestCompletion(globalData: self.globalData, completion: completion) + .sink(receiveCompletion: { result in + print(result) }, receiveValue: { _ in print("Playback progress report sent!") }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &cancellables) } } @@ -534,12 +536,12 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe 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: { completion in - HandleAPIRequestCompletion(globalData: self.globalData, completion: completion) + .sink(receiveCompletion: { result in + print(result) }, receiveValue: { _ in print("Playback stop report sent!") }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &cancellables) } func sendPlayReport() { @@ -548,19 +550,18 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe 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: { completion in - HandleAPIRequestCompletion(globalData: self.globalData, completion: completion) + .sink(receiveCompletion: { result in + print(result) }, receiveValue: { _ in print("Playback start report sent!") }) - .store(in: &globalData.pendingAPIRequests) + .store(in: &cancellables) } } struct VLCPlayerWithControls: UIViewControllerRepresentable { var item: BaseItemDto @Environment(\.presentationMode) var presentationMode - @EnvironmentObject private var globalData: GlobalData var loadBinding: Binding var pBinding: Binding @@ -597,7 +598,6 @@ struct VLCPlayerWithControls: UIViewControllerRepresentable { let customViewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! PlayerViewController customViewController.manifest = item customViewController.delegate = context.coordinator - customViewController.globalData = globalData return customViewController } diff --git a/Shared/Extensions/DeviceRotationViewModifier.swift b/Shared/Extensions/DeviceRotationViewModifier.swift new file mode 100644 index 00000000..9543501c --- /dev/null +++ b/Shared/Extensions/DeviceRotationViewModifier.swift @@ -0,0 +1,33 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +// https://www.hackingwithswift.com/quick-start/swiftui/how-to-detect-device-rotation +import Foundation +import SwiftUI + +// Our custom view modifier to track rotation and +// call our action +struct DeviceRotationViewModifier: ViewModifier { + let action: (UIDeviceOrientation) -> Void + + func body(content: Content) -> some View { + content + .onAppear() + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + action(UIDevice.current.orientation) + } + } +} + +// A View wrapper to make the modifier easier to use +extension View { + func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View { + self.modifier(DeviceRotationViewModifier(action: action)) + } +} diff --git a/Shared/Extensions/HandleAPIRequestCompletion.swift b/Shared/Extensions/HandleAPIRequestCompletion.swift deleted file mode 100644 index 621eabfa..00000000 --- a/Shared/Extensions/HandleAPIRequestCompletion.swift +++ /dev/null @@ -1,27 +0,0 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -import Foundation -import Combine -import JellyfinAPI - -func HandleAPIRequestCompletion(globalData: GlobalData, completion: Subscribers.Completion) { - switch completion { - case .finished: - break - case .failure(let error): - if let err = error as? ErrorResponse { - switch err { - case .error(401, _, _, _): - globalData.expiredCredentials = true - case .error: - globalData.networkError = true - } - } - break - } -} diff --git a/Shared/Shared/ServerEnvironment.swift b/Shared/Shared/ServerEnvironment.swift new file mode 100644 index 00000000..67bf4691 --- /dev/null +++ b/Shared/Shared/ServerEnvironment.swift @@ -0,0 +1,59 @@ +// +/* + * 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 CoreData +import Foundation +import JellyfinAPI + +final class ServerEnvironment { + static let current = ServerEnvironment() + fileprivate(set) var server: Server! + + init() { + let serverRequest = NSFetchRequest(entityName: "Server") + let servers = try? PersistenceController.shared.container.viewContext.fetch(serverRequest) as? [Server] + server = servers?.first + guard let baseURI = server?.baseURI else { return } + JellyfinAPI.basePath = baseURI + } + + func setUp(with uri: String) -> AnyPublisher { + var uri = uri + if !uri.contains("http") { + uri = "https://" + uri + } + if uri.last == "/" { + uri = String(uri.dropLast()) + } + JellyfinAPI.basePath = uri + return SystemAPI.getPublicSystemInfo() + .map { response in + let server = Server(context: PersistenceController.shared.container.viewContext) + server.baseURI = uri + server.name = response.serverName + server.server_id = response.id + return server + } + .handleEvents(receiveOutput: { [unowned self] response in + server = response + _ = try? PersistenceController.shared.container.viewContext.save() + }).eraseToAnyPublisher() + } + + func reset() throws { + JellyfinAPI.basePath = "" + server = nil + + let serverRequest: NSFetchRequest = NSFetchRequest(entityName: "Server") + let deleteRequest = NSBatchDeleteRequest(fetchRequest: serverRequest) + + try PersistenceController.shared.container.viewContext.execute(deleteRequest) + } +} diff --git a/Shared/Shared/SessionManager.swift b/Shared/Shared/SessionManager.swift new file mode 100644 index 00000000..d18ff620 --- /dev/null +++ b/Shared/Shared/SessionManager.swift @@ -0,0 +1,108 @@ +// +/* + * 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 CoreData +import Foundation +import JellyfinAPI +import KeychainSwift +import UIKit + +final class SessionManager { + static let current = SessionManager() + fileprivate(set) var user: SignedInUser! + fileprivate(set) var authHeader: String! + fileprivate(set) var authToken: String! + fileprivate(set) var deviceID: String + var userID: String? { + user?.user_id + } + + init() { + let savedUserRequest = NSFetchRequest(entityName: "SignedInUser") + let savedUsers = try? PersistenceController.shared.container.viewContext.fetch(savedUserRequest) as? [SignedInUser] + user = savedUsers?.first + + let keychain = KeychainSwift() + keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" + if let deviceID = keychain.get("DeviceID") { + self.deviceID = deviceID + } else { + self.deviceID = UUID().uuidString + keychain.set(deviceID, forKey: "DeviceID") + } + + guard let authToken = keychain.get("AccessToken_\(user?.user_id ?? "")") else { + return + } + + updateHeader(with: authToken) + } + + fileprivate func updateHeader(with authToken: String?) { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + var deviceName = UIDevice.current.name + deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current) + deviceName = deviceName.removeRegexMatches(pattern: "[^\\w\\s]") + + var header = "MediaBrowser " + header.append("Client=\"SwiftFin\", ") + header.append("Device=\"\(deviceName)\", ") + header.append("DeviceId=\"\(deviceID)\", ") + header.append("Version=\"\(appVersion ?? "0.0.1")\", ") + if let token = authToken { + self.authToken = token + header.append("Token=\"\(token)\"") + } + + authHeader = header + JellyfinAPI.customHeaders["X-Emby-Authorization"] = authHeader + } + + func login(username: String, password: String) -> AnyPublisher { + updateHeader(with: nil) + + return UserAPI.authenticateUserByName(authenticateUserByName: AuthenticateUserByName(username: username, pw: password)) + .map { [unowned self] response -> (SignedInUser, String?) in + let user = SignedInUser(context: PersistenceController.shared.container.viewContext) + user.device_uuid = deviceID + user.username = response.user?.name + user.user_id = response.user?.id + return (user, response.accessToken) + } + .handleEvents(receiveOutput: { [unowned self] response, accessToken in + user = response + _ = try? PersistenceController.shared.container.viewContext.save() + if let userID = user.user_id, + let token = accessToken + { + let keychain = KeychainSwift() + keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" + keychain.set(token, forKey: "AccessToken_\(userID)") + } + updateHeader(with: accessToken) + }) + .map(\.0) + .eraseToAnyPublisher() + } + + func logout() throws { + let keychain = KeychainSwift() + keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" + keychain.delete("AccessToken_\(user.user_id ?? "")") + JellyfinAPI.customHeaders["X-Emby-Authorization"] = nil + user = nil + authHeader = nil + + let userRequest: NSFetchRequest = NSFetchRequest(entityName: "SignedInUser") + let deleteRequest = NSBatchDeleteRequest(fetchRequest: userRequest) + + try PersistenceController.shared.container.viewContext.execute(deleteRequest) + } +} diff --git a/Shared/Typings/Typings.swift b/Shared/Typings/Typings.swift index 915415e8..87d62223 100644 --- a/Shared/Typings/Typings.swift +++ b/Shared/Typings/Typings.swift @@ -22,28 +22,3 @@ public enum SortBy: String, Codable, CaseIterable { case name = "SortName" case dateAdded = "DateCreated" } - -class justSignedIn: ObservableObject { - @Published var did: Bool = false -} - -class GlobalData: ObservableObject { - @Published var user: SignedInUser! - @Published var authToken: String = "" - @Published var server: Server! - @Published var authHeader: String = "" - @Published var isInNetwork: Bool = true - @Published var networkError: Bool = false - @Published var expiredCredentials: Bool = false - var pendingAPIRequests = Set() -} - -extension GlobalData: Equatable { - - static func == (lhs: GlobalData, rhs: GlobalData) -> Bool { - lhs.user == rhs.user - && lhs.authToken == rhs.authToken - && lhs.server == rhs.server - && lhs.authHeader == rhs.authHeader - } -} diff --git a/Shared/ViewModel/ConnectToServerViewModel.swift b/Shared/ViewModel/ConnectToServerViewModel.swift new file mode 100644 index 00000000..64315bf4 --- /dev/null +++ b/Shared/ViewModel/ConnectToServerViewModel.swift @@ -0,0 +1,88 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import JellyfinAPI + +final class ConnectToServerViewModel: ViewModel { + @Published + var publicUsers = [UserDto]() + @Published + var isConnectedServer = false + @Published + var isLoggedIn = false + @Published + var uri = "" + @Published + var username = "" + @Published + var password = "" + + + override init() { + super.init() + + refresh() + } + + func refresh() { + if ServerEnvironment.current.server != nil { + UserAPI.getPublicUsers() + .sink(receiveCompletion: { completion in + switch completion { + case .finished: + break + case .failure: + self.isConnectedServer = false + } + }, receiveValue: { response in + self.publicUsers = response + self.isConnectedServer = true + }) + .store(in: &cancellables) + } + } + + func connectToServer() { + ServerEnvironment.current.setUp(with: uri) + .sink(receiveCompletion: { result in + switch result { + case let .failure(error): + self.errorMessage = error.localizedDescription + default: + break + } + }, receiveValue: { response in + guard response.server_id != nil else { + return + } + self.isConnectedServer = true + }) + .store(in: &cancellables) + } + + func login() { + SessionManager.current.login(username: username, password: password) + .sink(receiveCompletion: { result in + switch result { + case let .failure(error): + self.errorMessage = error.localizedDescription + default: + break + } + }, receiveValue: { response in + guard response.user_id != nil else { + return + } + self.isLoggedIn = true + }) + .store(in: &cancellables) + } +} diff --git a/Shared/ViewModel/HomeViewModel.swift b/Shared/ViewModel/HomeViewModel.swift new file mode 100644 index 00000000..c93bf8a6 --- /dev/null +++ b/Shared/ViewModel/HomeViewModel.swift @@ -0,0 +1,78 @@ +// +/* + * 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 ActivityIndicator +import Combine +import Foundation +import JellyfinAPI + +final class HomeViewModel: ViewModel { + + @Published + var librariesShowRecentlyAddedIDs = [String]() + @Published + var libraries = [BaseItemDto]() + @Published + var resumeItems = [BaseItemDto]() + @Published + var nextUpItems = [BaseItemDto]() + + // temp + var recentFilterSet: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: ["DateCreated"]) + + override init() { + super.init() + + refresh() + } + + func refresh() { + UserAPI.getCurrentUser() + .trackActivity(loading) + .sink(receiveCompletion: { completion in + print(completion) + }, receiveValue: { response in + let libraries = response.configuration?.orderedViews ?? [] + self.librariesShowRecentlyAddedIDs = libraries.filter { element in + !(response.configuration?.latestItemsExcludes?.contains(element))! + } + }) + .store(in: &cancellables) + + UserViewsAPI.getUserViews(userId: SessionManager.current.userID ?? "") + .trackActivity(loading) + .sink(receiveCompletion: { completion in + print(completion) + }, receiveValue: { response in + self.libraries = response.items ?? [] + }) + .store(in: &cancellables) + + ItemsAPI.getResumeItems(userId: SessionManager.current.userID!, limit: 12, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + mediaTypes: ["Video"], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) + .trackActivity(loading) + .sink(receiveCompletion: { completion in + print(completion) + }, receiveValue: { response in + self.resumeItems = response.items ?? [] + }) + .store(in: &cancellables) + + TvShowsAPI.getNextUp(userId: SessionManager.current.userID!, limit: 12, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) + .trackActivity(loading) + .sink(receiveCompletion: { result in + print(result) + }, receiveValue: { response in + self.nextUpItems = response.items ?? [] + }) + .store(in: &cancellables) + } +} diff --git a/Shared/ViewModel/LibraryListViewModel.swift b/Shared/ViewModel/LibraryListViewModel.swift new file mode 100644 index 00000000..0c6aa0aa --- /dev/null +++ b/Shared/ViewModel/LibraryListViewModel.swift @@ -0,0 +1,38 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import JellyfinAPI + +final class LibraryListViewModel: ViewModel { + @Published + var libraries = [BaseItemDto]() + + // temp + var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: []) + + override init() { + super.init() + + libraries.append(.init(name: "Favorites", id: "favorites")) + libraries.append(.init(name: "Genres", id: "genres")) + refresh() + } + + func refresh() { + UserViewsAPI.getUserViews(userId: SessionManager.current.userID ?? "") + .trackActivity(loading) + .sink(receiveCompletion: { completion in + print(completion) + }, receiveValue: { response in + self.libraries.append(contentsOf: response.items ?? []) + }) + .store(in: &cancellables) + } +} diff --git a/Shared/ViewModel/SplashViewModel.swift b/Shared/ViewModel/SplashViewModel.swift new file mode 100644 index 00000000..34d80867 --- /dev/null +++ b/Shared/ViewModel/SplashViewModel.swift @@ -0,0 +1,37 @@ +// + /* + * 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 Combine +import Nuke +import WidgetKit + +final class SplashViewModel: ViewModel { + + @Published + var isLoggedIn: Bool + + override init() { + isLoggedIn = ServerEnvironment.current.server != nil && SessionManager.current.user != nil + super.init() + + ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory + DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk + + WidgetCenter.shared.reloadAllTimelines() + + let defaults = UserDefaults.standard + if defaults.integer(forKey: "InNetworkBandwidth") == 0 { + defaults.setValue(40_000_000, forKey: "InNetworkBandwidth") + } + if defaults.integer(forKey: "OutOfNetworkBandwidth") == 0 { + defaults.setValue(40_000_000, forKey: "OutOfNetworkBandwidth") + } + } +} diff --git a/Shared/ViewModel/ViewModel.swift b/Shared/ViewModel/ViewModel.swift new file mode 100644 index 00000000..c13eee7f --- /dev/null +++ b/Shared/ViewModel/ViewModel.swift @@ -0,0 +1,33 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import ActivityIndicator + +typealias ErrorMessage = String + +extension ErrorMessage: Identifiable { + public var id: String { + self + } +} + +class ViewModel: ObservableObject { + var cancellables = Set() + @Published + var isLoading = true + let loading = ActivityIndicator() + @Published + var errorMessage: ErrorMessage? + + init() { + loading.loading.assign(to: \.isLoading, on: self).store(in: &cancellables) + } +} diff --git a/WidgetExtension/NextUpWidget.swift b/WidgetExtension/NextUpWidget.swift index fcc66227..87107b5b 100644 --- a/WidgetExtension/NextUpWidget.swift +++ b/WidgetExtension/NextUpWidget.swift @@ -25,18 +25,17 @@ struct NextUpWidgetProvider: TimelineProvider { func getSnapshot(in context: Context, completion: @escaping (NextUpEntry) -> Void) { let currentDate = Date() - WidgetEnvironment.shared.update() - guard let server = WidgetEnvironment.shared.server else { return + guard let server = ServerEnvironment.current.server else { return DispatchQueue.main.async { completion(NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyServer)) } } - guard let savedUser = WidgetEnvironment.shared.user else { return + guard let savedUser = SessionManager.current.user else { return DispatchQueue.main.async { completion(NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyUser)) } } - guard let header = WidgetEnvironment.shared.header else { return + guard let header = SessionManager.current.authHeader else { return DispatchQueue.main.async { completion(NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyHeader)) } @@ -81,20 +80,19 @@ struct NextUpWidgetProvider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let currentDate = Date() let entryDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! - WidgetEnvironment.shared.update() - guard let server = WidgetEnvironment.shared.server else { return + guard let server = ServerEnvironment.current.server else { return DispatchQueue.main.async { completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyServer)], policy: .after(entryDate))) } } - guard let savedUser = WidgetEnvironment.shared.user else { return + guard let savedUser = SessionManager.current.user else { return DispatchQueue.main.async { completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyUser)], policy: .after(entryDate))) } } - guard let header = WidgetEnvironment.shared.header else { return + guard let header = SessionManager.current.authHeader else { return DispatchQueue.main.async { completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyHeader)], policy: .after(entryDate))) diff --git a/WidgetExtension/WidgetEnvironment.swift b/WidgetExtension/WidgetEnvironment.swift deleted file mode 100644 index 48c17eda..00000000 --- a/WidgetExtension/WidgetEnvironment.swift +++ /dev/null @@ -1,56 +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 CoreData -import KeychainSwift -import UIKit - -final class WidgetEnvironment { - static let shared = WidgetEnvironment() - - var server: Server? - var user: SignedInUser? - var header: String? - - init() { - update() - } - - func update() { - let serverRequest = NSFetchRequest(entityName: "Server") - let servers = try? PersistenceController.shared.container.viewContext.fetch(serverRequest) as? [Server] - let savedUserRequest = NSFetchRequest(entityName: "SignedInUser") - let savedUsers = try? PersistenceController.shared.container.viewContext.fetch(savedUserRequest) as? [SignedInUser] - - server = servers?.first - user = savedUsers?.first - - let keychain = KeychainSwift() - // need prefix - keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" - guard let authToken = keychain.get("AccessToken_\(user?.user_id ?? "")") else { - return - } - - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - var deviceName = UIDevice.current.name - deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current) - deviceName = deviceName.removeRegexMatches(pattern: "[^\\w\\s]") - - var header = "MediaBrowser " - header.append("Client=\"SwiftFin\", ") - header.append("Device=\"\(deviceName)\", ") - header.append("DeviceId=\"\(user?.device_uuid ?? "")\", ") - header.append("Version=\"\(appVersion ?? "0.0.1")\", ") - header.append("Token=\"\(authToken)\"") - - self.header = header - } -}