diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..f66bac1b Binary files /dev/null and b/.DS_Store differ diff --git a/Cartfile b/Cartfile new file mode 100644 index 00000000..7c69ffb1 --- /dev/null +++ b/Cartfile @@ -0,0 +1 @@ +binary "https://code.videolan.org/videolan/VLCKit/raw/master/Packaging/MobileVLCKit.json" ~> 3.3.0 diff --git a/Cartfile.resolved b/Cartfile.resolved new file mode 100644 index 00000000..23fc6ef2 --- /dev/null +++ b/Cartfile.resolved @@ -0,0 +1 @@ +binary "https://code.videolan.org/videolan/VLCKit/raw/master/Packaging/MobileVLCKit.json" "3.3.16" diff --git a/Carthage/.DS_Store b/Carthage/.DS_Store new file mode 100644 index 00000000..02411894 Binary files /dev/null and b/Carthage/.DS_Store differ diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj new file mode 100644 index 00000000..9b52b4ac --- /dev/null +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -0,0 +1,613 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; }; + 5338F751263B62E80014BF09 /* HidingViews in Frameworks */ = {isa = PBXBuildFile; productRef = 5338F750263B62E80014BF09 /* HidingViews */; }; + 5338F754263B65E10014BF09 /* SwiftyRequest in Frameworks */ = {isa = PBXBuildFile; productRef = 5338F753263B65E10014BF09 /* SwiftyRequest */; }; + 5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 5338F756263B7E2E0014BF09 /* KeychainSwift */; }; + 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; }; + 535BAEA32649E96A005FA86D /* JellyfinHLSResourceLoaderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA22649E96A005FA86D /* JellyfinHLSResourceLoaderDelegate.swift */; }; + 535BAEA5264A151C005FA86D /* VLCPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VLCPlayer.swift */; }; + 535BAEA7264A18AA005FA86D /* PlayerDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA6264A18AA005FA86D /* PlayerDemo.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 */; }; + 5377CC01263B596B003A4E83 /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFF263B596B003A4E83 /* Model.xcdatamodeld */; }; + 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276D263C25100035E14B /* ContinueWatchingView.swift */; }; + 53892770263C25230035E14B /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276F263C25230035E14B /* NextUpView.swift */; }; + 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53892771263C8C6F0035E14B /* LoadingView.swift */; }; + 53892777263CBB000035E14B /* JellyApiTypings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53892776263CBB000035E14B /* JellyApiTypings.swift */; }; + 5389277A263CBFE70035E14B /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 53892779263CBFE70035E14B /* SwiftyJSON */; }; + 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; }; + 53892782263CC8770035E14B /* URLImage in Frameworks */ = {isa = PBXBuildFile; productRef = 53892781263CC8770035E14B /* URLImage */; }; + 538CD954263E3DC100BB5AF0 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 538CD953263E3DC100BB5AF0 /* SDWebImageSwiftUI */; }; + 538CD957263E441500BB5AF0 /* ExyteGrid in Frameworks */ = {isa = PBXBuildFile; productRef = 538CD956263E441500BB5AF0 /* ExyteGrid */; }; + 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */; }; + 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A089CF264DA9DA00D57806 /* MovieItemView.swift */; }; + 53D2F74A264C69F6005792BB /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53D2F749264C69F6005792BB /* Introspect */; }; + 53D5E3DD264B47EE00BADDC8 /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; }; + 53D5E3DE264B47EE00BADDC8 /* MobileVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; }; + 53E4E645263F6BC000F67C6B /* PartialSheet in Frameworks */ = {isa = PBXBuildFile; productRef = 53E4E644263F6BC000F67C6B /* PartialSheet */; }; + 53E4E647263F6CF100F67C6B /* LibraryFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */; }; + 53E4E649263F725B00F67C6B /* MultiSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelector.swift */; }; + 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 53D5E3DF264B47EE00BADDC8 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 53D5E3DE264B47EE00BADDC8 /* MobileVLCKit.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; + 535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; + 535BAEA22649E96A005FA86D /* JellyfinHLSResourceLoaderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinHLSResourceLoaderDelegate.swift; sourceTree = ""; }; + 535BAEA4264A151C005FA86D /* VLCPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayer.swift; sourceTree = ""; }; + 535BAEA6264A18AA005FA86D /* PlayerDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerDemo.swift; sourceTree = ""; }; + 5377CBF1263B596A003A4E83 /* JellyfinPlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = JellyfinPlayer.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 = ""; }; + 5377CC00263B596B003A4E83 /* JellyfinPlayer.xcdatamodel */ = {isa = PBXFileReference; explicitFileType = wrapper.xcdatamodel; path = JellyfinPlayer.xcdatamodel; sourceTree = ""; }; + 5377CC02263B596B003A4E83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 5389276D263C25100035E14B /* ContinueWatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; + 5389276F263C25230035E14B /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = ""; }; + 53892771263C8C6F0035E14B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; + 53892776263CBB000035E14B /* JellyApiTypings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyApiTypings.swift; sourceTree = ""; }; + 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; + 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 53A089CF264DA9DA00D57806 /* MovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemView.swift; sourceTree = ""; }; + 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 = ""; }; + 53DF641D263D9C0600A7CD1A /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; + 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = ""; }; + 53E4E648263F725B00F67C6B /* MultiSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelector.swift; sourceTree = ""; }; + 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5377CBEE263B596A003A4E83 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 538CD954263E3DC100BB5AF0 /* SDWebImageSwiftUI in Frameworks */, + 538CD957263E441500BB5AF0 /* ExyteGrid in Frameworks */, + 53E4E645263F6BC000F67C6B /* PartialSheet in Frameworks */, + 5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */, + 53D5E3DD264B47EE00BADDC8 /* MobileVLCKit.xcframework in Frameworks */, + 5338F754263B65E10014BF09 /* SwiftyRequest in Frameworks */, + 53892782263CC8770035E14B /* URLImage in Frameworks */, + 53D2F74A264C69F6005792BB /* Introspect in Frameworks */, + 5389277A263CBFE70035E14B /* SwiftyJSON in Frameworks */, + 5338F751263B62E80014BF09 /* HidingViews in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 5377CBE8263B596A003A4E83 = { + isa = PBXGroup; + children = ( + 53D5E3DA264B460200BADDC8 /* Cartfile */, + 5377CBF3263B596A003A4E83 /* JellyfinPlayer */, + 5377CBF2263B596A003A4E83 /* Products */, + 53D5E3DB264B47EE00BADDC8 /* Frameworks */, + ); + sourceTree = ""; + }; + 5377CBF2263B596A003A4E83 /* Products */ = { + isa = PBXGroup; + children = ( + 5377CBF1263B596A003A4E83 /* JellyfinPlayer.app */, + ); + name = Products; + sourceTree = ""; + }; + 5377CBF3263B596A003A4E83 /* JellyfinPlayer */ = { + isa = PBXGroup; + children = ( + 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */, + 5377CBF6263B596A003A4E83 /* ContentView.swift */, + 5377CBF8263B596B003A4E83 /* Assets.xcassets */, + 5377CBFD263B596B003A4E83 /* PersistenceController.swift */, + 5377CC02263B596B003A4E83 /* Info.plist */, + 5377CBFF263B596B003A4E83 /* Model.xcdatamodeld */, + 5377CBFA263B596B003A4E83 /* Preview Content */, + 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, + 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, + 5389276D263C25100035E14B /* ContinueWatchingView.swift */, + 5389276F263C25230035E14B /* NextUpView.swift */, + 53892771263C8C6F0035E14B /* LoadingView.swift */, + 53892776263CBB000035E14B /* JellyApiTypings.swift */, + 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */, + 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */, + 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, + 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */, + 53E4E648263F725B00F67C6B /* MultiSelector.swift */, + 535BAE9E2649E569005FA86D /* ItemView.swift */, + 53A089CF264DA9DA00D57806 /* MovieItemView.swift */, + 535BAEA22649E96A005FA86D /* JellyfinHLSResourceLoaderDelegate.swift */, + 535BAEA4264A151C005FA86D /* VLCPlayer.swift */, + 535BAEA6264A18AA005FA86D /* PlayerDemo.swift */, + ); + path = JellyfinPlayer; + sourceTree = ""; + }; + 5377CBFA263B596B003A4E83 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 53D5E3DB264B47EE00BADDC8 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 5377CBF0263B596A003A4E83 /* JellyfinPlayer */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5377CC1B263B596B003A4E83 /* Build configuration list for PBXNativeTarget "JellyfinPlayer" */; + buildPhases = ( + 5377CBED263B596A003A4E83 /* Sources */, + 5377CBEE263B596A003A4E83 /* Frameworks */, + 5377CBEF263B596A003A4E83 /* Resources */, + 53D5E3DF264B47EE00BADDC8 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = JellyfinPlayer; + packageProductDependencies = ( + 5338F750263B62E80014BF09 /* HidingViews */, + 5338F753263B65E10014BF09 /* SwiftyRequest */, + 5338F756263B7E2E0014BF09 /* KeychainSwift */, + 53892779263CBFE70035E14B /* SwiftyJSON */, + 53892781263CC8770035E14B /* URLImage */, + 538CD953263E3DC100BB5AF0 /* SDWebImageSwiftUI */, + 538CD956263E441500BB5AF0 /* ExyteGrid */, + 53E4E644263F6BC000F67C6B /* PartialSheet */, + 53D2F749264C69F6005792BB /* Introspect */, + ); + productName = JellyfinPlayer; + productReference = 5377CBF1263B596A003A4E83 /* JellyfinPlayer.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 5377CBE9263B596A003A4E83 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1250; + LastUpgradeCheck = 1250; + TargetAttributes = { + 5377CBF0263B596A003A4E83 = { + CreatedOnToolsVersion = 12.5; + }; + }; + }; + buildConfigurationList = 5377CBEC263B596A003A4E83 /* Build configuration list for PBXProject "JellyfinPlayer" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 5377CBE8263B596A003A4E83; + packageReferences = ( + 5338F74F263B62E80014BF09 /* XCRemoteSwiftPackageReference "HidingViews" */, + 5338F752263B65E10014BF09 /* XCRemoteSwiftPackageReference "SwiftyRequest" */, + 5338F755263B7E2E0014BF09 /* XCRemoteSwiftPackageReference "keychain-swift" */, + 53892778263CBFE70035E14B /* XCRemoteSwiftPackageReference "SwiftyJSON" */, + 53892780263CC8770035E14B /* XCRemoteSwiftPackageReference "url-image" */, + 538CD952263E3DC100BB5AF0 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, + 538CD955263E441500BB5AF0 /* XCRemoteSwiftPackageReference "Grid" */, + 53E4E643263F6BC000F67C6B /* XCRemoteSwiftPackageReference "PartialSheet" */, + 53D2F748264C69F6005792BB /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, + ); + productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 5377CBF0263B596A003A4E83 /* JellyfinPlayer */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 5377CBEF263B596A003A4E83 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */, + 5377CBF9263B596B003A4E83 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5377CBED263B596A003A4E83 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, + 5377CBFE263B596B003A4E83 /* PersistenceController.swift in Sources */, + 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, + 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, + 53892770263C25230035E14B /* NextUpView.swift in Sources */, + 535BAEA5264A151C005FA86D /* VLCPlayer.swift in Sources */, + 5377CC01263B596B003A4E83 /* Model.xcdatamodeld in Sources */, + 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, + 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */, + 53E4E649263F725B00F67C6B /* MultiSelector.swift in Sources */, + 535BAEA7264A18AA005FA86D /* PlayerDemo.swift in Sources */, + 53E4E647263F6CF100F67C6B /* LibraryFilterView.swift in Sources */, + 53892777263CBB000035E14B /* JellyApiTypings.swift in Sources */, + 5377CBF7263B596A003A4E83 /* ContentView.swift in Sources */, + 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, + 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, + 535BAEA32649E96A005FA86D /* JellyfinHLSResourceLoaderDelegate.swift in Sources */, + 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, + 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, + 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 5377CC19263B596B003A4E83 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 5377CC1A263B596B003A4E83 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 5377CC1C263B596B003A4E83 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer/Preview Content\""; + DEVELOPMENT_TEAM = 9R8RREG67J; + ENABLE_BITCODE = NO; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = JellyfinPlayer/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = me.vigue.JellyfinPlayer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 5377CC1D263B596B003A4E83 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"JellyfinPlayer/Preview Content\""; + DEVELOPMENT_TEAM = 9R8RREG67J; + ENABLE_BITCODE = NO; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = JellyfinPlayer/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = me.vigue.JellyfinPlayer; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 5377CBEC263B596A003A4E83 /* Build configuration list for PBXProject "JellyfinPlayer" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5377CC19263B596B003A4E83 /* Debug */, + 5377CC1A263B596B003A4E83 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5377CC1B263B596B003A4E83 /* Build configuration list for PBXNativeTarget "JellyfinPlayer" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5377CC1C263B596B003A4E83 /* Debug */, + 5377CC1D263B596B003A4E83 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 5338F74F263B62E80014BF09 /* XCRemoteSwiftPackageReference "HidingViews" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/GeorgeElsham/HidingViews"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.1; + }; + }; + 5338F752263B65E10014BF09 /* XCRemoteSwiftPackageReference "SwiftyRequest" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Kitura/SwiftyRequest"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.2.200; + }; + }; + 5338F755263B7E2E0014BF09 /* XCRemoteSwiftPackageReference "keychain-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/evgenyneu/keychain-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 19.0.0; + }; + }; + 53892778263CBFE70035E14B /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SwiftyJSON/SwiftyJSON"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.0.1; + }; + }; + 53892780263CC8770035E14B /* XCRemoteSwiftPackageReference "url-image" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/dmytro-anokhin/url-image"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.2.5; + }; + }; + 538CD952263E3DC100BB5AF0 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.2; + }; + }; + 538CD955263E441500BB5AF0 /* XCRemoteSwiftPackageReference "Grid" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/exyte/Grid"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.4.0; + }; + }; + 53D2F748264C69F6005792BB /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.3; + }; + }; + 53E4E643263F6BC000F67C6B /* XCRemoteSwiftPackageReference "PartialSheet" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/AndreaMiotto/PartialSheet"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.1.11; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 5338F750263B62E80014BF09 /* HidingViews */ = { + isa = XCSwiftPackageProductDependency; + package = 5338F74F263B62E80014BF09 /* XCRemoteSwiftPackageReference "HidingViews" */; + productName = HidingViews; + }; + 5338F753263B65E10014BF09 /* SwiftyRequest */ = { + isa = XCSwiftPackageProductDependency; + package = 5338F752263B65E10014BF09 /* XCRemoteSwiftPackageReference "SwiftyRequest" */; + productName = SwiftyRequest; + }; + 5338F756263B7E2E0014BF09 /* KeychainSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 5338F755263B7E2E0014BF09 /* XCRemoteSwiftPackageReference "keychain-swift" */; + productName = KeychainSwift; + }; + 53892779263CBFE70035E14B /* SwiftyJSON */ = { + isa = XCSwiftPackageProductDependency; + package = 53892778263CBFE70035E14B /* XCRemoteSwiftPackageReference "SwiftyJSON" */; + productName = SwiftyJSON; + }; + 53892781263CC8770035E14B /* URLImage */ = { + isa = XCSwiftPackageProductDependency; + package = 53892780263CC8770035E14B /* XCRemoteSwiftPackageReference "url-image" */; + productName = URLImage; + }; + 538CD953263E3DC100BB5AF0 /* SDWebImageSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 538CD952263E3DC100BB5AF0 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; + productName = SDWebImageSwiftUI; + }; + 538CD956263E441500BB5AF0 /* ExyteGrid */ = { + isa = XCSwiftPackageProductDependency; + package = 538CD955263E441500BB5AF0 /* XCRemoteSwiftPackageReference "Grid" */; + productName = ExyteGrid; + }; + 53D2F749264C69F6005792BB /* Introspect */ = { + isa = XCSwiftPackageProductDependency; + package = 53D2F748264C69F6005792BB /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; + productName = Introspect; + }; + 53E4E644263F6BC000F67C6B /* PartialSheet */ = { + isa = XCSwiftPackageProductDependency; + package = 53E4E643263F6BC000F67C6B /* XCRemoteSwiftPackageReference "PartialSheet" */; + productName = PartialSheet; + }; +/* End XCSwiftPackageProductDependency section */ + +/* Begin XCVersionGroup section */ + 5377CBFF263B596B003A4E83 /* Model.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 5377CC00263B596B003A4E83 /* JellyfinPlayer.xcdatamodel */, + ); + currentVersion = 5377CC00263B596B003A4E83 /* JellyfinPlayer.xcdatamodel */; + path = Model.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ + }; + rootObject = 5377CBE9263B596A003A4E83 /* Project object */; +} diff --git a/JellyfinPlayer.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/JellyfinPlayer.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/JellyfinPlayer.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..d310ae3d --- /dev/null +++ b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,160 @@ +{ + "object": { + "pins": [ + { + "package": "async-http-client", + "repositoryURL": "https://github.com/swift-server/async-http-client.git", + "state": { + "branch": null, + "revision": "037b70291941fe43de668066eb6fb802c5e181d2", + "version": "1.1.1" + } + }, + { + "package": "CircuitBreaker", + "repositoryURL": "https://github.com/Kitura/CircuitBreaker.git", + "state": { + "branch": null, + "revision": "915cd4ed17500784cf5bcbf2ef54a76830884c86", + "version": "5.0.200" + } + }, + { + "package": "ExyteGrid", + "repositoryURL": "https://github.com/exyte/Grid", + "state": { + "branch": null, + "revision": "585dc249126fda6ae675d78175b0c52a311f10c9", + "version": "1.4.0" + } + }, + { + "package": "HidingViews", + "repositoryURL": "https://github.com/GeorgeElsham/HidingViews", + "state": { + "branch": null, + "revision": "7fde89eaeb2f0d3a07f8bf517507c6e27af8e4c3", + "version": "1.1.1" + } + }, + { + "package": "KeychainSwift", + "repositoryURL": "https://github.com/evgenyneu/keychain-swift", + "state": { + "branch": null, + "revision": "96fb84f45a96630e7583903bd7e08cf095c7a7ef", + "version": "19.0.0" + } + }, + { + "package": "LoggerAPI", + "repositoryURL": "https://github.com/Kitura/LoggerAPI.git", + "state": { + "branch": null, + "revision": "e82d34eab3f0b05391082b11ea07d3b70d2f65bb", + "version": "1.9.200" + } + }, + { + "package": "PartialSheet", + "repositoryURL": "https://github.com/AndreaMiotto/PartialSheet", + "state": { + "branch": null, + "revision": "936465232b6399e402e79d5b031622af9a5e9960", + "version": "2.1.11" + } + }, + { + "package": "SDWebImage", + "repositoryURL": "https://github.com/SDWebImage/SDWebImage.git", + "state": { + "branch": null, + "revision": "76dd4b49110b8624317fc128e7fa0d8a252018bc", + "version": "5.11.1" + } + }, + { + "package": "SDWebImageSwiftUI", + "repositoryURL": "https://github.com/SDWebImage/SDWebImageSwiftUI", + "state": { + "branch": null, + "revision": "cd8625b7cf11a97698e180d28bb7d5d357196678", + "version": "2.0.2" + } + }, + { + "package": "swift-log", + "repositoryURL": "https://github.com/apple/swift-log.git", + "state": { + "branch": null, + "revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", + "version": "1.4.2" + } + }, + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio.git", + "state": { + "branch": null, + "revision": "21782f3bdc9581148d38d0ccaab6ec952ccda56b", + "version": "2.28.0" + } + }, + { + "package": "swift-nio-extras", + "repositoryURL": "https://github.com/apple/swift-nio-extras.git", + "state": { + "branch": null, + "revision": "de1c80ad1fdff1ba772bcef6b392c3ef735f39a6", + "version": "1.8.0" + } + }, + { + "package": "swift-nio-ssl", + "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", + "state": { + "branch": null, + "revision": "3d576964a1ace80d2a3f8bab96cab03e5ee074dc", + "version": "2.12.0" + } + }, + { + "package": "Introspect", + "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect", + "state": { + "branch": null, + "revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2", + "version": "0.1.3" + } + }, + { + "package": "SwiftyJSON", + "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON", + "state": { + "branch": null, + "revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", + "version": "5.0.1" + } + }, + { + "package": "SwiftyRequest", + "repositoryURL": "https://github.com/Kitura/SwiftyRequest", + "state": { + "branch": null, + "revision": "2c543777a5088bed811503a68551a4b4eceac198", + "version": "3.2.200" + } + }, + { + "package": "URLImage", + "repositoryURL": "https://github.com/dmytro-anokhin/url-image", + "state": { + "branch": null, + "revision": "ccab89ad1cedb04f25dd4df1776dd8c8583b914a", + "version": "2.2.5" + } + } + ] + }, + "version": 1 +} diff --git a/JellyfinPlayer/.DS_Store b/JellyfinPlayer/.DS_Store new file mode 100644 index 00000000..aaedabfe Binary files /dev/null and b/JellyfinPlayer/.DS_Store differ diff --git a/JellyfinPlayer/Assets.xcassets/AccentColor.colorset/Contents.json b/JellyfinPlayer/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/JellyfinPlayer/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/JellyfinPlayer/Assets.xcassets/AppIcon.appiconset/Contents.json b/JellyfinPlayer/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9221b9bb --- /dev/null +++ b/JellyfinPlayer/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/JellyfinPlayer/Assets.xcassets/Contents.json b/JellyfinPlayer/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/JellyfinPlayer/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/JellyfinPlayer/BlurHashDecode.swift b/JellyfinPlayer/BlurHashDecode.swift new file mode 100644 index 00000000..aa942224 --- /dev/null +++ b/JellyfinPlayer/BlurHashDecode.swift @@ -0,0 +1,146 @@ +import UIKit + +extension UIImage { + public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { + guard blurHash.count >= 6 else { return nil } + + let sizeFlag = String(blurHash[0]).decode83() + let numY = (sizeFlag / 9) + 1 + let numX = (sizeFlag % 9) + 1 + + let quantisedMaximumValue = String(blurHash[1]).decode83() + let maximumValue = Float(quantisedMaximumValue + 1) / 166 + + guard blurHash.count == 4 + 2 * numX * numY else { return nil } + + let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in + if i == 0 { + let value = String(blurHash[2 ..< 6]).decode83() + return decodeDC(value) + } else { + let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83() + return decodeAC(value, maximumValue: maximumValue * punch) + } + } + + let width = Int(size.width) + let height = Int(size.height) + let bytesPerRow = width * 3 + guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil } + CFDataSetLength(data, bytesPerRow * height) + guard let pixels = CFDataGetMutableBytePtr(data) else { return nil } + + for y in 0 ..< height { + for x in 0 ..< width { + var r: Float = 0 + var g: Float = 0 + var b: Float = 0 + + for j in 0 ..< numY { + for i in 0 ..< numX { + let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height)) + let colour = colours[i + j * numX] + r += colour.0 * basis + g += colour.1 * basis + b += colour.2 * basis + } + } + + let intR = UInt8(linearTosRGB(r)) + let intG = UInt8(linearTosRGB(g)) + let intB = UInt8(linearTosRGB(b)) + + pixels[3 * x + 0 + y * bytesPerRow] = intR + pixels[3 * x + 1 + y * bytesPerRow] = intG + pixels[3 * x + 2 + y * bytesPerRow] = intB + } + } + + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + + guard let provider = CGDataProvider(data: data) else { return nil } + guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil } + + self.init(cgImage: cgImage) + } +} + +private func decodeDC(_ value: Int) -> (Float, Float, Float) { + let intR = value >> 16 + let intG = (value >> 8) & 255 + let intB = value & 255 + return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)) +} + +private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) { + let quantR = value / (19 * 19) + let quantG = (value / 19) % 19 + let quantB = value % 19 + + let rgb = ( + signPow((Float(quantR) - 9) / 9, 2) * maximumValue, + signPow((Float(quantG) - 9) / 9, 2) * maximumValue, + signPow((Float(quantB) - 9) / 9, 2) * maximumValue + ) + + return rgb +} + +private func signPow(_ value: Float, _ exp: Float) -> Float { + return copysign(pow(abs(value), exp), value) +} + +private func linearTosRGB(_ value: Float) -> Int { + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } + else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } +} + +private func sRGBToLinear(_ value: Type) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} + +private let encodeCharacters: [String] = { + return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } +}() + +private let decodeCharacters: [String: Int] = { + var dict: [String: Int] = [:] + for (index, character) in encodeCharacters.enumerated() { + dict[character] = index + } + return dict +}() + +extension String { + func decode83() -> Int { + var value: Int = 0 + for character in self { + if let digit = decodeCharacters[String(character)] { + value = value * 83 + digit + } + } + return value + } +} + +private extension String { + subscript (offset: Int) -> Character { + return self[index(startIndex, offsetBy: offset)] + } + + subscript (bounds: CountableClosedRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start...end] + } + + subscript (bounds: CountableRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start..) { + skip_server_bool = skip_server + skip_server_obj = skip_server_prefill + reauthDeviceID = reauth_deviceId + _rootIsActive = isActive + } + + init(isActive: Binding) { + _rootIsActive = isActive + } + + func start() { + if(skip_server_bool) { + _serverSkipped.wrappedValue = true; + _serverSkippedAlert.wrappedValue = true; + _server_id.wrappedValue = skip_server_obj?.server_id ?? "" + _serverName.wrappedValue = skip_server_obj?.name ?? "" + _uri.wrappedValue = skip_server_obj?.baseURI ?? "" + _isConnected.wrappedValue = true; + } + } + + var body: some View { + Form { + if(!isConnected) { + Section(header: Text("Server Information")) { + TextField("Server URL", text: $uri) + .disableAutocorrection(true) + .autocapitalization(.none) + Button { + _isWorking.wrappedValue = true; + + let request = RestRequest(method: .get, url: uri + "/System/Info/Public") + request.responseObject() { (result: Result, RestError>) in + switch result { + case .success(let response): + let server = response.body + print("Found server: " + server.ServerName) + _serverName.wrappedValue = server.ServerName + _server_id.wrappedValue = server.Id + if(!server.StartupWizardCompleted) { + print("Server needs configured") + } else { + _isConnected.wrappedValue = true; + } + case .failure( _): + _isErrored.wrappedValue = true; + } + _isWorking.wrappedValue = false; + } + } label: { + HStack { + Text("Connect") + Spacer() + ProgressView().isHidden(!isWorking) + } + }.disabled(isWorking || uri.isEmpty) + }.alert(isPresented: $isErrored) { + Alert(title: Text("Error"), message: Text("Couldn't connect to Jellyfin server"), dismissButton: .default(Text("Try again"))) + } + } else { + Section(header: Text("\(serverSkipped ? "re" : "")Authenticate to \(serverName)")) { + TextField("Username", text: $username) + .disableAutocorrection(true) + .autocapitalization(.none) + SecureField("Password", text: $password) + .disableAutocorrection(true) + .autocapitalization(.none) + Button { + _isWorking.wrappedValue = true + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String; + let authHeader = "MediaBrowser Client=\"SwiftFin\", Device=\"\(UIDevice.current.name)\", DeviceId=\"\(serverSkipped ? reauthDeviceID : userUUID.uuidString)\", Version=\"\(appVersion ?? "0.0.1")\""; + print(authHeader) + let authJson: [String: Any] = ["Username": _username.wrappedValue, "Pw": _password.wrappedValue] + let request = RestRequest(method: .post, url: uri + "/Users/authenticatebyname") + request.headerParameters["X-Emby-Authorization"] = authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + request.messageBodyDictionary = authJson + + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + do { + let json = try JSON(data: response.body) + dump(json) + + 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 + } + + let newServer = Server(context: viewContext) + newServer.baseURI = _uri.wrappedValue + newServer.name = _serverName.wrappedValue + newServer.server_id = _server_id.wrappedValue + + let newUser = SignedInUser(context: viewContext) + newUser.device_uuid = userUUID.uuidString + newUser.username = _username.wrappedValue + newUser.user_id = json["User"]["Id"].string ?? "" + + let keychain = KeychainSwift() + keychain.set(json["AccessToken"].string ?? "", forKey: "AccessToken_\(json["User"]["Id"].string ?? "")") + + do { + try viewContext.save() + print("Saved to Core Data Store") + jsi.did = true + _rootIsActive.wrappedValue = false + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + } catch { + + } + case .failure(let error): + print(error) + _isSignInErrored.wrappedValue = true; + } + _isWorking.wrappedValue = false; + } + } label: { + HStack { + Text("Login") + Spacer() + ProgressView().isHidden(!isWorking) + } + }.disabled(isWorking || username.isEmpty || password.isEmpty) + .alert(isPresented: $isSignInErrored) { + Alert(title: Text("Error"), message: Text("Invalid credentials"), dismissButton: .default(Text("Back"))) + } + } + } + }.navigationTitle("Connect to Server") + .navigationBarBackButtonHidden(true) + .alert(isPresented: $serverSkippedAlert) { + Alert(title: Text("Error"), message: Text("Credentials have expired"), dismissButton: .default(Text("Sign in again"))) + } + .onAppear(perform: start) + .transition(.move(edge:.bottom)) + } +} diff --git a/JellyfinPlayer/ContentView.swift b/JellyfinPlayer/ContentView.swift new file mode 100644 index 00000000..cc928130 --- /dev/null +++ b/JellyfinPlayer/ContentView.swift @@ -0,0 +1,337 @@ +// +// ContentView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 4/29/21. +// + +import SwiftUI +import KeychainSwift +import SwiftyRequest +import SwiftyJSON + +class GlobalData: ObservableObject { + @Published var user: SignedInUser? + @Published var authToken: String = "" + @Published var server: Server? + @Published var authHeader: String = "" +} + +extension View { + func withHostingWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View { + self.background(HostingWindowFinder(callback: callback)) + } +} + +struct HostingWindowFinder: UIViewRepresentable { + var callback: (UIWindow?) -> () + + func makeUIView(context: Context) -> UIView { + let view = UIView() + DispatchQueue.main.async { [weak view] in + self.callback(view?.window) + } + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + } +} + +struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey { + typealias Value = Bool + + static var defaultValue: Value = false + + static func reduce(value: inout Value, nextValue: () -> Value) { + value = nextValue() || value + } +} + +struct ViewPreferenceKey: PreferenceKey { + typealias Value = UIUserInterfaceStyle + + static var defaultValue: UIUserInterfaceStyle = .unspecified + + static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { + value = nextValue() + } +} + +struct SupportedOrientationsPreferenceKey: PreferenceKey { + typealias Value = UIInterfaceOrientationMask + static var defaultValue: UIInterfaceOrientationMask = .allButUpsideDown + + static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) { + // use the most restrictive set from the stack + value.formIntersection(nextValue()) + } +} + +extension View { + + /// Navigate to a new view. + /// - Parameters: + /// - view: View to navigate to. + /// - binding: Only navigates when this condition is `true`. + func navigate(to view: NewView, when binding: Binding) -> some View { + NavigationView { + ZStack { + self + .navigationBarTitle("") + .navigationBarHidden(true) + + NavigationLink( + destination: view + .navigationBarTitle("") + .navigationBarHidden(true), + isActive: binding + ) { + EmptyView() + } + } + } + } +} + +class PreferenceUIHostingController: UIHostingController { + init(wrappedView: V) { + let box = Box() + super.init(rootView: AnyView(wrappedView + .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) { + box.value?._prefersHomeIndicatorAutoHidden = $0 + }.onPreferenceChange(SupportedOrientationsPreferenceKey.self) { + box.value?._orientations = $0 + }.onPreferenceChange(ViewPreferenceKey.self) { + box.value?._viewPreference = $0 + } + )) + box.value = self + } + + @objc required dynamic init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + private class Box { + weak var value: PreferenceUIHostingController? + init() {} + } + + // MARK: Prefers Home Indicator Auto Hidden + + public var _prefersHomeIndicatorAutoHidden = false { + didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() } + } + override var prefersHomeIndicatorAutoHidden: Bool { + _prefersHomeIndicatorAutoHidden + } + + // MARK: Lock orientation + + public var _orientations: UIInterfaceOrientationMask = .allButUpsideDown { + didSet { UIViewController.attemptRotationToDeviceOrientation(); + if(_orientations == .landscapeRight) { + let value = UIInterfaceOrientation.landscapeRight.rawValue; + UIDevice.current.setValue(value, forKey: "orientation") + } else { + let value = UIInterfaceOrientation.portrait.rawValue; + UIDevice.current.setValue(value, forKey: "orientation") + } + } + }; + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + _orientations + } + + public var _viewPreference: UIUserInterfaceStyle = .unspecified { + didSet { + overrideUserInterfaceStyle = _viewPreference + } + }; +} + +extension View { + // Controls the application's preferred home indicator auto-hiding when this view is shown. + func prefersHomeIndicatorAutoHidden(_ value: Bool) -> some View { + preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: value) + } + + func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View { + // When rendered, export the requested orientations upward to Root + preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations) + } + + func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { + // When rendered, export the requested orientations upward to Root + preference(key: ViewPreferenceKey.self, value: viewPreference) + } +} + +struct ContentView: View { + @Environment(\.managedObjectContext) private var viewContext + @StateObject private var globalData = GlobalData() + @EnvironmentObject var jsi: justSignedIn + + @FetchRequest(entity: Server.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Server.name, ascending: true)]) private var servers: FetchedResults + + @FetchRequest(entity: SignedInUser.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \SignedInUser.username, ascending: true)]) private var savedUsers: FetchedResults + + @State private var needsToSelectServer = false; + @State private var isSignInErrored = 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 = ""; + + @Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass? + @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? + + var isPortrait: Bool { + let result = verticalSizeClass == .regular && horizontalSizeClass == .compact + return result + } + + func startup() { + _libraries.wrappedValue = [] + _library_names.wrappedValue = [:] + _librariesShowRecentlyAdded.wrappedValue = [] + if(servers.isEmpty) { + _isLoading.wrappedValue = false; + _needsToSelectServer.wrappedValue = true; + } else { + _isLoading.wrappedValue = true; + let savedUser = savedUsers[0]; + + let keychain = KeychainSwift(); + if(keychain.get("AccessToken_\(savedUser.user_id ?? "")") != nil) { + _globalData.wrappedValue.authToken = keychain.get("AccessToken_\(savedUser.user_id ?? "")") ?? "" + _globalData.wrappedValue.server = servers[0] + _globalData.wrappedValue.user = savedUser + } + + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String; + globalData.authHeader = "MediaBrowser Client=\"SwiftFin\", Device=\"\(UIDevice.current.name)\", DeviceId=\"\(globalData.user?.device_uuid ?? "")\", Version=\"\(appVersion ?? "0.0.1")\", Token=\"\(globalData.authToken)\""; + let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + "/Users/Me") + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + + request.responseData() { (result: Result, RestError>) in + switch result { + case .success( let resp): + do { + let json = try JSON(data: resp.body) + _libraries.wrappedValue = json["Configuration"]["OrderedViews"].arrayObject as? [String] ?? []; + let array2 = json["Configuration"]["LatestItemsExcludes"].arrayObject as? [String] ?? [] + _librariesShowRecentlyAdded.wrappedValue = _libraries.wrappedValue.filter { element in + return !array2.contains(element) + } + + let request2 = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/Views") + request2.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request2.contentType = "application/json" + request2.acceptType = "application/json" + + request2.responseData() { (result2: Result, RestError>) in + switch result2 { + case .success( let resp): + do { + let json2 = try JSON(data: resp.body) + for (_,item2):(String, JSON) in json2["Items"] { + _library_names.wrappedValue[item2["Id"].string ?? ""] = item2["Name"].string ?? "" + } + } catch { + + } + break + case .failure( _): + break + } + _isLoading.wrappedValue = false; + } + } catch { + + } + break + case .failure( _): + _isLoading.wrappedValue = false; + _isSignInErrored.wrappedValue = true; + } + } + } + } + + var body: some View { + LoadingView(isShowing: $isLoading) { + TabView(selection: $tabSelection) { + NavigationView() { + VStack { + NavigationLink(destination: ConnectToServerView(isActive: $needsToSelectServer), isActive: $needsToSelectServer) { + EmptyView() + }.isDetailLink(false) + NavigationLink(destination: ConnectToServerView(skip_server: true, skip_server_prefill: globalData.server, reauth_deviceId: globalData.user?.device_uuid ?? "", isActive: $isSignInErrored), isActive: $isSignInErrored) { + EmptyView() + }.isDetailLink(false) + if(!needsToSelectServer && !isSignInErrored) { + VStack(alignment: .leading) { + ScrollView() { + Spacer().frame(height: self.isPortrait ? 0 : 15) + ContinueWatchingView() + NextUpView().padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) + 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: LibraryView(prefill: library_id, names: library_names, libraries: libraries, filter: "&SortBy=DateCreated&SortOrder=Descending")) { + Text("See All").font(.subheadline).fontWeight(.bold) + } + }.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + LatestMediaView(library: library_id) + }.padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) + } + Spacer().frame(height: 7) + } + } + } + } + .navigationTitle("Home") + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + print("Settings tapped!") + } label: { + Image(systemName: "gear") + } + } + } + } + .tabItem({ + Text("Home") + Image(systemName: "house") + }) + .tag("Home") + NavigationView() { + LibraryView(prefill: "", names: library_names, libraries: libraries) + .navigationTitle("Library") + } + .tabItem({ + Text("All Media") + Image(systemName: "folder") + }) + .tag("All Media") + } + }.environmentObject(globalData) + .onAppear(perform: startup) + .navigationViewStyle(StackNavigationViewStyle()) + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) + } +} diff --git a/JellyfinPlayer/ContinueWatchingView.swift b/JellyfinPlayer/ContinueWatchingView.swift new file mode 100644 index 00000000..4c9dcc4b --- /dev/null +++ b/JellyfinPlayer/ContinueWatchingView.swift @@ -0,0 +1,155 @@ +// +// NextUpView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 4/30/21. +// + +import SwiftUI +import SwiftyRequest +import SwiftyJSON +import SDWebImageSwiftUI + +struct ContinueWatchingView: View { + @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject var globalData: GlobalData + + @State var resumeItems: [ResumeItem] = [] + @State private var viewDidLoad: Int = 0; + @State private var isLoading: Bool = true; + + func onAppear() { + if(globalData.server?.baseURI == "") { + return + } + if(viewDidLoad == 1) { + return + } + _viewDidLoad.wrappedValue = 1; + let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/Items/Resume?Limit=12&Recursive=true&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CThumb&MediaTypes=Video") + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + let body = response.body + do { + let json = try JSON(data: body) + for (_,item):(String, JSON) in json["Items"] { + // Do something you want + let itemObj = ResumeItem() + if(item["PrimaryImageAspectRatio"].double! < 1.0) { + //portrait; use backdrop instead + itemObj.Image = item["BackdropImageTags"][0].string ?? "" + itemObj.ImageType = "Backdrop" + itemObj.BlurHash = item["ImageBlurHashes"]["Backdrop"][itemObj.Image].string ?? "" + } else { + itemObj.Image = item["ImageTags"]["Primary"].string ?? "" + itemObj.ImageType = "Primary" + itemObj.BlurHash = item["ImageBlurHashes"]["Primary"][itemObj.Image].string ?? "" + } + itemObj.Name = item["Name"].string ?? "" + itemObj.Type = item["Type"].string ?? "" + itemObj.IndexNumber = item["IndexNumber"].int ?? nil + itemObj.Id = item["Id"].string ?? "" + itemObj.ParentIndexNumber = item["ParentIndexNumber"].int ?? nil + itemObj.SeasonId = item["SeasonId"].string ?? nil + itemObj.SeriesId = item["SeriesId"].string ?? nil + itemObj.SeriesName = item["SeriesName"].string ?? nil + itemObj.ItemProgress = item["UserData"]["PlayedPercentage"].double ?? 0.00 + _resumeItems.wrappedValue.append(itemObj) + } + _isLoading.wrappedValue = false; + } catch { + + } + break + case .failure(let error): + _viewDidLoad.wrappedValue = 0; + debugPrint(error) + break + } + } + } + + var body: some View { + VStack(alignment: .leading) { + ScrollView(.horizontal, showsIndicators: false) { + HStack() { + if(isLoading == false) { + Spacer().frame(width:16) + ForEach(resumeItems, id: \.Id) { item in + VStack(alignment: .leading) { + Spacer().frame(height: 10) + if(item.Type == "Episode") { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.Id)/Images/\(item.ImageType)?fillWidth=560&fillHeight=315&quality=90&tag=\(item.Image)")!) + .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size + .placeholder { + Image(uiImage: UIImage(blurHash: (item.BlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : item.BlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .scaledToFit() + .cornerRadius(10) + } + .frame(width: 320, height: 180) + .cornerRadius(10) + .overlay( + ZStack { + Text("S\(String(item.ParentIndexNumber ?? 0)):E\(String(item.IndexNumber ?? 0)) - \(item.Name)") + .font(.caption) + .padding(6) + .foregroundColor(.white) + }.background(Color.black) + .opacity(0.8) + .cornerRadius(10.0) + .padding(6), alignment: .topTrailing + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .circular) + .fill(Color(red: 172/255, green: 92/255, blue: 195/255).opacity(0.4)) + .frame(width: CGFloat((item.ItemProgress/100)*320), height: 180) + .padding(0), alignment: .bottomLeading + ) + .shadow(radius: 5) + } else { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.Id)/Images/\(item.ImageType)?fillWidth=560&fillHeight=315&quality=90&tag=\(item.Image)")!) + .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size + .placeholder { + Image(uiImage: UIImage(blurHash: (item.BlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : item.BlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .scaledToFit() + .cornerRadius(10) + } + .frame(width: 320, height: 180) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .circular) + .fill(Color(red: 172/255, green: 92/255, blue: 195/255).opacity(0.4)) + .frame(width: CGFloat((item.ItemProgress/100)*320), height: 180) + .padding(0), alignment: .bottomLeading + ) + .shadow(radius: 5) + } + Text("\(item.Type == "Episode" ? item.SeriesName ?? "" : item.Name)") + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + Spacer().frame(height: 5) + }.padding(.trailing, 5) + } + Spacer().frame(width:14) + } + } + } + .frame(height: 200) + .padding(.bottom, 10) + }.onAppear(perform: onAppear) + } +} + +struct ContinueWatchingView_Previews: PreviewProvider { + static var previews: some View { + ContinueWatchingView() + } +} diff --git a/JellyfinPlayer/Info.plist b/JellyfinPlayer/Info.plist new file mode 100644 index 00000000..1655e670 --- /dev/null +++ b/JellyfinPlayer/Info.plist @@ -0,0 +1,54 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Jellyfin iOS + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/JellyfinPlayer/ItemView.swift b/JellyfinPlayer/ItemView.swift new file mode 100644 index 00000000..cbe2b0b7 --- /dev/null +++ b/JellyfinPlayer/ItemView.swift @@ -0,0 +1,30 @@ +// +// ItemView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 5/10/21. +// + +import SwiftUI +import SwiftyRequest +import SwiftyJSON +import Introspect +import SDWebImageSwiftUI + +struct ItemView: View { + @EnvironmentObject var globalData: GlobalData + @State private var isLoading: Bool = false; + var item: ResumeItem; + + init(item: ResumeItem) { + self.item = item; + } + + var body: some View { + if(item.Type == "Movie") { + MovieItemView(item: self.item) + } else { + Text("Type: \(item.Type) not implemented yet :(") + } + } +} diff --git a/JellyfinPlayer/JellyApiTypings.swift b/JellyfinPlayer/JellyApiTypings.swift new file mode 100644 index 00000000..de79b647 --- /dev/null +++ b/JellyfinPlayer/JellyApiTypings.swift @@ -0,0 +1,76 @@ +// +// JellyApiTypings.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 4/30/21. +// + +import Foundation +import SwiftUI + +extension View { + func rectReader(_ binding: Binding, in space: CoordinateSpace) -> some View { + self.background(GeometryReader { (geometry) -> AnyView in + let rect = geometry.frame(in: space) + DispatchQueue.main.async { + binding.wrappedValue = rect + } + return AnyView(Rectangle().fill(Color.clear)) + }) + } +} + +extension View { + func ifVisible(in rect: CGRect, in space: CoordinateSpace, execute: @escaping (CGRect) -> Void) -> some View { + self.background(GeometryReader { (geometry) -> AnyView in + let frame = geometry.frame(in: space) + if frame.intersects(rect) { + execute(frame) + } + return AnyView(Rectangle().fill(Color.clear)) + }) + } +} + +struct ServerPublicInfoResponse: Codable { + var LocalAddress: String + var ServerName: String + var Version: String + var ProductName: String + var OperatingSystem: String + var Id: String + var StartupWizardCompleted: Bool +} + +struct ServerUserResponse: Codable { + var Name: String + var Id: String + var PrimaryImageTag: String +} + +struct ServerAuthByNameResponse: Codable { + var User: ServerUserResponse + var AccessToken: String +} + +class ResumeItem: ObservableObject { + @Published var Name: String = ""; + @Published var Id: String = ""; + @Published var IndexNumber: Int? = nil; + @Published var ParentIndexNumber: Int? = nil; + @Published var Image: String = ""; + @Published var ImageType: String = ""; + @Published var BlurHash: String = ""; + @Published var `Type`: String = ""; + @Published var SeasonId: String? = nil; + @Published var SeriesId: String? = nil; + @Published var SeriesName: String? = nil; + @Published var ItemProgress: Double = 0; + @Published var ItemBadge: Int? = 0; + @Published var ProductionYear: Int = 1999; + @Published var Watched: Bool = false; +} + +struct ServerMeResponse: Codable { + +} diff --git a/JellyfinPlayer/JellyfinHLSResourceLoaderDelegate.swift b/JellyfinPlayer/JellyfinHLSResourceLoaderDelegate.swift new file mode 100644 index 00000000..4a32f447 --- /dev/null +++ b/JellyfinPlayer/JellyfinHLSResourceLoaderDelegate.swift @@ -0,0 +1,148 @@ +// +// JellyfinHLSResourceLoaderDelegate.swift + +import Foundation +import AVFoundation + +class JellyfinHLSResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDataDelegate, URLSessionTaskDelegate { + + typealias Completion = (URL?) -> Void + + private static let SchemeSuffix = "icpt" + + // MARK: - Properties + // MARK: Public + + var completion: Completion? + + lazy var streamingAssetURL: URL = { + guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { + fatalError() + } + components.scheme = (components.scheme ?? "") + JellyfinHLSResourceLoaderDelegate.SchemeSuffix + guard let retURL = components.url else { + fatalError() + } + return retURL + }() + + // MARK: Private + + private let url: URL + private var infoResponse: URLResponse? + private var urlSession: URLSession? + private lazy var mediaData = Data() + private var loadingRequests = [AVAssetResourceLoadingRequest]() + + // MARK: - Life Cycle Methods + + init(withURL url: URL) { + self.url = url + + super.init() + } + + // MARK: - Public Methods + + func invalidate() { + self.loadingRequests.forEach { $0.finishLoading() } + self.invalidateURLSession() + } + + // MARK: - AVAssetResourceLoaderDelegate + + func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { + if self.urlSession == nil { + self.urlSession = self.createURLSession() + let task = self.urlSession!.dataTask(with: self.url) + task.resume() + } + + self.loadingRequests.append(loadingRequest) + + return true + } + + func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) { + if let index = self.loadingRequests.firstIndex(of: loadingRequest) { + self.loadingRequests.remove(at: index) + } + } + + // MARK: - URLSessionDataDelegate + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + self.infoResponse = response + self.processRequests() + + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + self.mediaData.append(data) + self.processRequests() + } + + // MARK: - Private Methods + + private func createURLSession() -> URLSession { + let config = URLSessionConfiguration.default + let operationQueue = OperationQueue() + operationQueue.maxConcurrentOperationCount = 1 + return URLSession(configuration: config, delegate: self, delegateQueue: operationQueue) + } + + private func invalidateURLSession() { + self.urlSession?.invalidateAndCancel() + self.urlSession = nil + } + + private func isInfo(request: AVAssetResourceLoadingRequest) -> Bool { + return request.contentInformationRequest != nil + } + + private func fillInfoRequest(request: inout AVAssetResourceLoadingRequest, response: URLResponse) { + request.contentInformationRequest?.isByteRangeAccessSupported = true + request.contentInformationRequest?.contentType = response.mimeType + request.contentInformationRequest?.contentLength = response.expectedContentLength + } + + private func processRequests() { + var finishedRequests = Set() + self.loadingRequests.forEach { + var request = $0 + if self.isInfo(request: request), let response = self.infoResponse { + self.fillInfoRequest(request: &request, response: response) + } + if let dataRequest = request.dataRequest, self.checkAndRespond(forRequest: dataRequest) { + finishedRequests.insert(request) + request.finishLoading() + } + } + + self.loadingRequests = self.loadingRequests.filter { !finishedRequests.contains($0) } + } + + private func checkAndRespond(forRequest dataRequest: AVAssetResourceLoadingDataRequest) -> Bool { + let downloadedData = self.mediaData + let downloadedDataLength = Int64(downloadedData.count) + + let requestRequestedOffset = dataRequest.requestedOffset + let requestRequestedLength = Int64(dataRequest.requestedLength) + let requestCurrentOffset = dataRequest.currentOffset + + if downloadedDataLength < requestCurrentOffset { + return false + } + + let downloadedUnreadDataLength = downloadedDataLength - requestCurrentOffset + let requestUnreadDataLength = requestRequestedOffset + requestRequestedLength - requestCurrentOffset + let respondDataLength = min(requestUnreadDataLength, downloadedUnreadDataLength) + + dataRequest.respond(with: downloadedData.subdata(in: Range(NSMakeRange(Int(requestCurrentOffset), Int(respondDataLength)))!)) + + let requestEndOffset = requestRequestedOffset + requestRequestedLength + + return requestCurrentOffset >= requestEndOffset + } +} diff --git a/JellyfinPlayer/JellyfinPlayerApp.swift b/JellyfinPlayer/JellyfinPlayerApp.swift new file mode 100644 index 00000000..49292e08 --- /dev/null +++ b/JellyfinPlayer/JellyfinPlayerApp.swift @@ -0,0 +1,39 @@ +// +// JellyfinPlayerApp.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 4/29/21. +// + +import SwiftUI + +class justSignedIn: ObservableObject { + @Published var did: Bool = false +} + +@main +struct JellyfinPlayerApp: App { + let persistenceController = PersistenceController.shared + @StateObject private var jsi = justSignedIn() + + var body: some Scene { + WindowGroup { + if(!jsi.did) { + ContentView() + .environment(\.managedObjectContext, persistenceController.container.viewContext) + .environmentObject(jsi) + .withHostingWindow() { window in + window?.rootViewController = PreferenceUIHostingController(wrappedView: ContentView().environment(\.managedObjectContext, persistenceController.container.viewContext) + .environmentObject(jsi)) + } + } else { + Text("Please wait...") + .onAppear(perform: { + print("Signing in") + sleep(1) + jsi.did = false + }) + } + } + } +} diff --git a/JellyfinPlayer/LatestMediaView.swift b/JellyfinPlayer/LatestMediaView.swift new file mode 100644 index 00000000..ae932659 --- /dev/null +++ b/JellyfinPlayer/LatestMediaView.swift @@ -0,0 +1,147 @@ +// +// LatestMediaView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 4/30/21. +// + +import SwiftUI +import SwiftyRequest +import SwiftyJSON +import SDWebImageSwiftUI + +struct LatestMediaView: View { + @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject var globalData: GlobalData + + @State var resumeItems: [ResumeItem] = [] + private var library_id: String = ""; + @State private var viewDidLoad: Int = 0; + + init(library: String) { + library_id = library; + } + + init() { + library_id = ""; + } + + func onAppear() { + if(globalData.server?.baseURI == "") { + return + } + if(viewDidLoad == 1) { + return + } + _viewDidLoad.wrappedValue = 1; + let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/Items/Latest?IncludeItemTypes=Movie%2CSeries&Limit=16&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo%2CPath&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CThumb&ParentId=\(library_id)") + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + let body = response.body + do { + let json = try JSON(data: body) + for (_,item):(String, JSON) in json { + // Do something you want + let itemObj = ResumeItem() + itemObj.Image = item["ImageTags"]["Primary"].string ?? "" + itemObj.ImageType = "Primary" + itemObj.BlurHash = item["ImageBlurHashes"]["Primary"][itemObj.Image].string ?? "" + itemObj.Name = item["Name"].string ?? "" + itemObj.Type = item["Type"].string ?? "" + itemObj.IndexNumber = item["IndexNumber"].int ?? nil + itemObj.Id = item["Id"].string ?? "" + itemObj.ParentIndexNumber = item["ParentIndexNumber"].int ?? nil + itemObj.SeasonId = item["SeasonId"].string ?? nil + itemObj.SeriesId = item["SeriesId"].string ?? nil + itemObj.SeriesName = item["SeriesName"].string ?? nil + itemObj.Watched = item["UserData"]["Played"].bool ?? false + + if(itemObj.Type == "Series") { + itemObj.ItemBadge = item["UserData"]["UnplayedItemCount"].int ?? 0 + } + + if(itemObj.Type != "Episode") { + _resumeItems.wrappedValue.append(itemObj) + } + } + //print("latestmediaview done https") + } catch { + + } + break + case .failure(let error): + debugPrint(error) + _viewDidLoad.wrappedValue = 0; + break + } + } + } + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack() { + Spacer().frame(width:18) + ForEach(resumeItems, id: \.Id) { item in + VStack(alignment: .leading) { + if(item.Type == "Series") { + Spacer().frame(height:10) + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.Id)/Images/\(item.ImageType)?fillWidth=300&fillHeight=450&quality=90&tag=\(item.Image)")!) + .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size + .placeholder { + Image(uiImage: UIImage(blurHash: (item.BlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : item.BlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .scaledToFit() + .cornerRadius(10) + } + .frame(width: 100, height: 150) + .cornerRadius(10) + .overlay( + ZStack { + Text("\(String(item.ItemBadge ?? 0))") + .font(.caption) + .padding(3) + .foregroundColor(.white) + }.background(Color.black) + .opacity(0.8) + .cornerRadius(10.0) + .padding(3), alignment: .topTrailing + ).shadow(radius: 6) + } else { + Spacer().frame(height:10) + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.Id)/Images/\(item.ImageType)?fillWidth=300&fillHeight=450&quality=90&tag=\(item.Image)")!) + .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size + .placeholder { + Image(uiImage: UIImage(blurHash: (item.BlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : item.BlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .scaledToFit() + .cornerRadius(10) + } + .frame(width: 100, height: 150) + .cornerRadius(10) + .shadow(radius: 6) + } + Text(item.Name) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + Spacer().frame(height:5) + }.frame(width: 100) + Spacer().frame(width: 14) + } + Spacer().frame(width:18) + } + }.onAppear(perform: onAppear).padding(EdgeInsets(top: -2, leading: 0, bottom: 0, trailing: 0)) + } +} + +struct LatestMediaView_Previews: PreviewProvider { + static var previews: some View { + LatestMediaView() + } +} diff --git a/JellyfinPlayer/LibraryFilterView.swift b/JellyfinPlayer/LibraryFilterView.swift new file mode 100644 index 00000000..b7ae8a15 --- /dev/null +++ b/JellyfinPlayer/LibraryFilterView.swift @@ -0,0 +1,185 @@ +// +// LibraryFilterView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 5/2/21. +// + +import SwiftUI +import SwiftyJSON +import SwiftyRequest + +struct Genre: Hashable, Identifiable { + var name: String + var id: String { name } +} + + +struct LibraryFilterView: View { + @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject var globalData: GlobalData + + @State var library: String; + @Binding var output: String; + @State private var isLoading: Bool = true; + @State private var onlyUnplayed: Bool = false; + @State private var allGenres: [Genre] = []; + @State private var selectedGenres: Set = []; + + @State private var allRatings: [Genre] = []; + @State private var selectedRatings: Set = []; + @State private var sortBySelection: String = "SortName"; + @State private var sortOrder: String = "Descending"; + @State private var viewDidLoad: Bool = false; + @Binding var close: Bool; + + func onAppear() { + if(_output.wrappedValue.contains("&Filters=IsUnplayed")) { + _onlyUnplayed.wrappedValue = true; + } + if(_output.wrappedValue.contains("&Genres=")) { + let genreString = _output.wrappedValue.components(separatedBy: "&Genres=")[1].components(separatedBy: "&")[0]; + for genre in genreString.components(separatedBy: "%7C") { + _selectedGenres.wrappedValue.insert(Genre(name: genre.removingPercentEncoding ?? "")) + } + } + if(_output.wrappedValue.contains("&OfficialRatings=")) { + let ratingString = _output.wrappedValue.components(separatedBy: "&OfficialRatings=")[1].components(separatedBy: "&")[0]; + for rating in ratingString.components(separatedBy: "%7C") { + _selectedRatings.wrappedValue.insert(Genre(name: rating.removingPercentEncoding ?? "")) + } + } + let sortBy = _output.wrappedValue.components(separatedBy: "&SortBy=")[1].components(separatedBy: "&")[0]; + _sortBySelection.wrappedValue = sortBy; + let sortOrder = _output.wrappedValue.components(separatedBy: "&SortOrder=")[1].components(separatedBy: "&")[0]; + _sortOrder.wrappedValue = sortOrder; + + recalculateFilters() + if(_viewDidLoad.wrappedValue == true) { + return + } + _viewDidLoad.wrappedValue = true; + _allGenres.wrappedValue = [] + let url = "/Items/Filters?UserId=\(globalData.user?.user_id ?? "")&ParentId=\(library)" + let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + url) + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + let body = response.body + do { + let json = try JSON(data: body) + let arr = json["Genres"].arrayObject as? [String] ?? [] + for genreName in arr { + //print(genreName) + let genre = Genre(name: genreName) + allGenres.append(genre) + } + + let arr2 = json["OfficialRatings"].arrayObject as? [String] ?? [] + for genreName in arr2 { + //print(genreName) + let genre = Genre(name: genreName) + allRatings.append(genre) + } + } catch { + + } + break + case .failure(let error): + debugPrint(error) + break + } + isLoading = false; + } + } + + func recalculateFilters() { + output = ""; + if(_onlyUnplayed.wrappedValue) { + output = "&Filters=IsUnPlayed"; + } + + if(selectedGenres.count != 0) { + output += "&Genres=" + var genres: [String] = [] + for genre in selectedGenres { + genres.append(genre.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") + } + output += genres.joined(separator: "%7C") + } + + if(selectedRatings.count != 0) { + output += "&OfficialRatings=" + var genres: [String] = [] + for genre in selectedRatings { + genres.append(genre.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") + } + output += genres.joined(separator: "%7C") + } + output += "&SortBy=\(sortBySelection)&SortOrder=\(sortOrder)" + //print(output) + } + + var body: some View { + NavigationView() { + LoadingView(isShowing: $isLoading) { + Form { + Toggle("Only show unplayed items", isOn: $onlyUnplayed) + .onChange(of: onlyUnplayed) { tag in + recalculateFilters() + } + MultiSelector( + label: "Genres", + options: allGenres, + optionToString: { $0.name }, + selected: $selectedGenres + ).onChange(of: selectedGenres) { tag in + recalculateFilters() + } + MultiSelector( + label: "Parental Ratings", + options: allRatings, + optionToString: { $0.name }, + selected: $selectedRatings + ).onChange(of: selectedRatings) { tag in + recalculateFilters() + } + + Section(header: Text("Sort settings")) { + Picker("Sort by", selection: $sortBySelection) { + Text("Name").tag("SortName") + Text("Date Added").tag("DateCreated") + Text("Date Played").tag("DatePlayed") + Text("Date Released").tag("PremiereDate") + Text("Runtime").tag("Runtime") + }.onChange(of: sortBySelection) { tag in + recalculateFilters() + } + Picker("Sort order", selection: $sortOrder) { + Text("Ascending").tag("Ascending") + Text("Descending").tag("Descending") + }.onChange(of: sortOrder) { tag in + recalculateFilters() + } + } + } + }.onAppear(perform: onAppear) + .navigationBarTitle("Filters", displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + close = false + } label: { + HStack() { + Text("Back").font(.callout) + } + } + } + } + } + } +} diff --git a/JellyfinPlayer/LibraryView.swift b/JellyfinPlayer/LibraryView.swift new file mode 100644 index 00000000..5c8f6804 --- /dev/null +++ b/JellyfinPlayer/LibraryView.swift @@ -0,0 +1,269 @@ +// +// LibraryView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 5/1/21. +// + +import SwiftUI +import SwiftyRequest +import SwiftyJSON +import ExyteGrid +import SDWebImageSwiftUI + +struct LibraryView: View { + @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject var globalData: GlobalData + @State private var prefill_id: String = ""; + @State private var library_names: [String: String] = [:] + @State private var library_ids: [String] = [] + @State private var selected_library_id: String = ""; + @State private var isLoading: Bool = true; + + @State private var startIndex: Int = 0; + @State private var endIndex: Int = 60; + @State private var totalItems: Int = 0; + + @State private var viewDidLoad: Bool = false; + @State private var filterString: String = "&SortBy=SortName&SortOrder=Descending"; + @State private var showFiltersPopover: Bool = false + + + var gridItems: [GridItem] = [GridItem(.adaptive(minimum: 150, maximum: 400))] + + init(prefill: String?, names: [String: String], libraries: [String]) { + _prefill_id = State(wrappedValue: prefill ?? "") + _library_names = State(wrappedValue: names) + _library_ids = State(wrappedValue: libraries) + //print("prefilling w/ \(prefill ?? "") aka \(names[prefill ?? ""] ?? "nil")") + } + + init(prefill: String?, names: [String: String], libraries: [String], filter: String) { + _prefill_id = State(wrappedValue: prefill ?? "") + _library_names = State(wrappedValue: names) + _library_ids = State(wrappedValue: libraries) + _filterString = State(wrappedValue: filter); + //print("prefilling w/ \(prefill ?? "") aka \(names[prefill ?? ""] ?? "nil")") + } + + @State var items: [ResumeItem] = [] + + func listOnAppear() { + if(_viewDidLoad.wrappedValue == false) { + //print("running VDL") + _viewDidLoad.wrappedValue = true; + _library_ids.wrappedValue.append("favorites") + _library_names.wrappedValue["favorites"] = "Favorites" + + _library_ids.wrappedValue.append("genres") + _library_names.wrappedValue["genres"] = "Genres" + } + } + + func loadItems() { + _isLoading.wrappedValue = true; + let url = "/Users/\(globalData.user?.user_id ?? "")/Items?Limit=\(endIndex)&StartIndex=\(startIndex)&Recursive=true&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CThumb%2CBanner&IncludeItemTypes=Movie,Series\(selected_library_id == "favorites" ? "&Filters=IsFavorite" : "&ParentId=" + selected_library_id)\(filterString)" + + let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + url) + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + let body = response.body + do { + let json = try JSON(data: body) + _totalItems.wrappedValue = json["TotalRecordCount"].int ?? 0; + for (_,item):(String, JSON) in json["Items"] { + // Do something you want + let itemObj = ResumeItem() + itemObj.Type = item["Type"].string ?? "" + if(itemObj.Type == "Series") { + itemObj.ItemBadge = item["UserData"]["UnplayedItemCount"].int ?? 0 + itemObj.Image = item["ImageTags"]["Primary"].string ?? "" + itemObj.ImageType = "Primary" + itemObj.BlurHash = item["ImageBlurHashes"]["Primary"][itemObj.Image].string ?? "" + itemObj.Name = item["Name"].string ?? "" + itemObj.Type = item["Type"].string ?? "" + itemObj.IndexNumber = nil + itemObj.Id = item["Id"].string ?? "" + itemObj.ParentIndexNumber = nil + itemObj.SeasonId = nil + itemObj.SeriesId = nil + itemObj.SeriesName = nil + itemObj.ProductionYear = item["ProductionYear"].int ?? 0 + } else { + itemObj.ProductionYear = item["ProductionYear"].int ?? 0 + itemObj.Image = item["ImageTags"]["Primary"].string ?? "" + itemObj.ImageType = "Primary" + itemObj.BlurHash = item["ImageBlurHashes"]["Primary"][itemObj.Image].string ?? "" + itemObj.Name = item["Name"].string ?? "" + itemObj.Type = item["Type"].string ?? "" + itemObj.IndexNumber = item["IndexNumber"].int ?? nil + itemObj.Id = item["Id"].string ?? "" + itemObj.ParentIndexNumber = item["ParentIndexNumber"].int ?? nil + itemObj.SeasonId = item["SeasonId"].string ?? nil + itemObj.SeriesId = item["SeriesId"].string ?? nil + itemObj.SeriesName = item["SeriesName"].string ?? nil + } + itemObj.Watched = item["UserData"]["Played"].bool ?? false + + _items.wrappedValue.append(itemObj) + } + } catch { + + } + break + case .failure(let error): + debugPrint(error) + break + } + _isLoading.wrappedValue = false; + } + } + + func onAppear() { + if(_prefill_id.wrappedValue != "") { + _selected_library_id.wrappedValue = _prefill_id.wrappedValue; + } + if(_items.wrappedValue.count == 0) { + loadItems() + } + } + + @Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass? + @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? + + var isPortrait: Bool { + let result = verticalSizeClass == .regular && horizontalSizeClass == .compact + return result + } + + var tracks: [GridTrack] { + self.isPortrait ? 3 : 6 + } + + var body: some View { + if(prefill_id != "") { + LoadingView(isShowing: $isLoading) { + GeometryReader { geometry in + Grid(tracks: self.tracks, spacing: GridSpacing(horizontal: 0, vertical: 20)) { + ForEach(items, id: \.Id) { item in + NavigationLink(destination: ItemView(item: item )) { + VStack(alignment: .leading) { + if(item.Type == "Movie") { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.Id)/Images/\(item.ImageType)?fillWidth=300&fillHeight=450&quality=90&tag=\(item.Image)")) + .resizable() + .placeholder { + Image(uiImage: UIImage(blurHash: (item.BlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : item.BlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .frame(width: 100, height: 150) + .cornerRadius(10) + } + .frame(width:100, height: 150) + .cornerRadius(10) + .shadow(radius: 5) + } else { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.Id)/Images/\(item.ImageType)?fillWidth=300&fillHeight=450&quality=90&tag=\(item.Image)")) + .resizable() + .placeholder { + Image(uiImage: UIImage(blurHash: (item.BlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : item.BlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .frame(width: 100, height: 150) + .cornerRadius(10) + } + .frame(width:100, height: 150) + .cornerRadius(10).overlay( + ZStack { + Text("\(String(item.ItemBadge ?? 0))") + .font(.caption) + .padding(3) + .foregroundColor(.white) + }.background(Color.black) + .opacity(0.8) + .cornerRadius(10.0) + .padding(3), alignment: .topTrailing + ) + .shadow(radius: 5) + } + Text(item.Name) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + Text(String(item.ProductionYear)) + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + }.frame(width: 100) + } + } + if(startIndex + endIndex < totalItems) { + HStack() { + Spacer() + Button() { + startIndex += endIndex; + loadItems() + } label: { + HStack() { + Text("Load more").font(.callout) + Image(systemName: "arrow.clockwise") + } + } + Spacer() + }.gridSpan(column: self.isPortrait ? 3 : 6) + } + Spacer().frame(height: 2).gridSpan(column: self.isPortrait ? 3 : 6) + }.gridContentMode(.scroll) + } + } + .overrideViewPreference(.unspecified) + .onAppear(perform: onAppear) + .onChange(of: filterString) { tag in + isLoading = true; + startIndex = 0; + totalItems = 0; + items = []; + loadItems(); + } + .navigationTitle(library_names[prefill_id] ?? "Library") + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + } label: { + Image(systemName: "magnifyingglass") + } + Button { + showFiltersPopover = true + } label: { + Image(systemName: "line.horizontal.3.decrease") + } + } + }.popover( isPresented: self.$showFiltersPopover, arrowEdge: .bottom) { LibraryFilterView(library: selected_library_id, output: $filterString, close: $showFiltersPopover) } + } else { + List(library_ids, id:\.self) { id in + if(id != "genres") { + NavigationLink(destination: LibraryView(prefill: id, names: library_names, libraries: library_ids)) { + Text(library_names[id] ?? "").foregroundColor(Color.primary) + } + } else { + NavigationLink(destination: LibraryView(prefill: id, names: library_names, libraries: library_ids)) { + Text(library_names[id] ?? "").foregroundColor(Color.primary) + } + } + }.onAppear(perform: listOnAppear).overrideViewPreference(.unspecified).navigationTitle("All Media") + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + print("Search tapped!") + } label: { + Image(systemName: "magnifyingglass") + } + } + } + + } + } +} diff --git a/JellyfinPlayer/LoadingView.swift b/JellyfinPlayer/LoadingView.swift new file mode 100644 index 00000000..738ea724 --- /dev/null +++ b/JellyfinPlayer/LoadingView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct LoadingView: View where Content: View { + @Environment(\.colorScheme) var colorScheme + @Binding var isShowing: Bool // should the modal be visible? + var content: () -> Content + var text: String? // the text to display under the ProgressView - defaults to "Loading..." + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .center) { + // the content to display - if the modal is showing, we'll blur it + content() + .disabled(isShowing) + .blur(radius: isShowing ? 2 : 0) + + // all contents inside here will only be shown when isShowing is true + if isShowing { + // this Rectangle is a semi-transparent black overlay + Rectangle() + .fill(Color.black).opacity(isShowing ? 0.6 : 0) + .edgesIgnoringSafeArea(.all) + + // the magic bit - our ProgressView just displays an activity + // indicator, with some text underneath showing what we are doing + HStack() { + ProgressView() + Text(text ?? "Loading").fontWeight(.semibold).font(.callout).offset(x: 60) + Spacer() + } + .padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 10)) + .frame(width: 250) + .background(colorScheme == .dark ? Color(UIColor.systemGray6) : Color.white) + .foregroundColor(Color.primary) + .cornerRadius(16) + } + } + } + } +} diff --git a/JellyfinPlayer/Model.xcdatamodeld/.xccurrentversion b/JellyfinPlayer/Model.xcdatamodeld/.xccurrentversion new file mode 100644 index 00000000..ea5bdb72 --- /dev/null +++ b/JellyfinPlayer/Model.xcdatamodeld/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + JellyfinPlayer.xcdatamodel + + diff --git a/JellyfinPlayer/Model.xcdatamodeld/JellyfinPlayer.xcdatamodel/contents b/JellyfinPlayer/Model.xcdatamodeld/JellyfinPlayer.xcdatamodel/contents new file mode 100644 index 00000000..f81c5937 --- /dev/null +++ b/JellyfinPlayer/Model.xcdatamodeld/JellyfinPlayer.xcdatamodel/contents @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/JellyfinPlayer/MovieItemView.swift b/JellyfinPlayer/MovieItemView.swift new file mode 100644 index 00000000..d78bda3c --- /dev/null +++ b/JellyfinPlayer/MovieItemView.swift @@ -0,0 +1,266 @@ +// +// MovieItemView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 5/13/21. +// + +import SwiftUI +import SwiftyRequest +import SwiftyJSON +import Introspect +import SDWebImageSwiftUI + +class DetailItem: ObservableObject { + @Published var Name: String = ""; + @Published var Id: String = ""; + @Published var IndexNumber: Int? = nil; + @Published var ParentIndexNumber: Int? = nil; + @Published var Poster: String = ""; + @Published var Backdrop: String = "" + @Published var PosterBlurHash: String = ""; + @Published var BackdropBlurHash: String = ""; + @Published var `Type`: String = ""; + @Published var SeasonId: String? = nil; + @Published var SeriesId: String? = nil; + @Published var SeriesName: String? = nil; + @Published var ItemProgress: Double = 0; + @Published var ItemBadge: Int? = 0; + @Published var ProductionYear: Int = 1999; + @Published var Runtime: String = ""; + @Published var RuntimeTicks: Int = 0; + @Published var Cast: [CastMember] = []; + @Published var OfficialRating: String = ""; + @Published var Progress: Double = 0; + @Published var Watched: Bool = false; + @Published var Overview: String = ""; + @Published var Tagline: String = ""; +} + +class CastMember: ObservableObject { + @Published var Name: String = ""; + @Published var Role: String = ""; + @Published var ImageBlurHash: String = ""; + @Published var Id: String = ""; + @Published var Image: URL = URL(string: "https://example.com")!; +} + +struct MovieItemView: View { + @EnvironmentObject var globalData: GlobalData + @State private var isLoading: Bool = true; + var item: ResumeItem; + var fullItem: DetailItem; + @State private var playing: Bool = false; + @State private var vc: PreferenceUIHostingController? = nil; + @State private var progressString: String = ""; + @State private var watched: Bool = false; + @State private var favorite: Bool = false; + + init(item: ResumeItem) { + self.item = item; + self.fullItem = DetailItem(); + } + + func lockOrientations() { + if(_vc.wrappedValue != nil) { + _vc.wrappedValue?._prefersHomeIndicatorAutoHidden = true; + _vc.wrappedValue?._orientations = .landscapeRight; + _vc.wrappedValue?._viewPreference = .dark; + } + } + + func loadData() { + if(_vc.wrappedValue != nil) { + _vc.wrappedValue?._prefersHomeIndicatorAutoHidden = false; + _vc.wrappedValue?._orientations = .allButUpsideDown; + _vc.wrappedValue?._viewPreference = .unspecified; + } + let url = "/Users/\(globalData.user?.user_id ?? "")/Items/\(item.Id)" + + let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + url) + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + let body = response.body + do { + let json = try JSON(data: body) + fullItem.ProductionYear = json["ProductionYear"].int ?? 0 + fullItem.Poster = json["ImageTags"]["Primary"].string ?? "" + fullItem.PosterBlurHash = json["ImageBlurHashes"]["Primary"][fullItem.Poster].string ?? "" + fullItem.Backdrop = json["BackdropImageTags"][0].string ?? "" + fullItem.BackdropBlurHash = json["ImageBlurHashes"]["Backdrop"][fullItem.Backdrop].string ?? "" + fullItem.Name = json["Name"].string ?? "" + fullItem.Type = json["Type"].string ?? "" + fullItem.IndexNumber = json["IndexNumber"].int ?? nil + fullItem.Id = json["Id"].string ?? "" + fullItem.ParentIndexNumber = json["ParentIndexNumber"].int ?? nil + fullItem.SeasonId = json["SeasonId"].string ?? nil + fullItem.SeriesId = json["SeriesId"].string ?? nil + fullItem.Overview = json["Overview"].string ?? "" + fullItem.Tagline = json["Taglines"][0].string ?? "" + fullItem.SeriesName = json["SeriesName"].string ?? nil + fullItem.Progress = Double(json["UserData"]["PlaybackPositionTicks"].int ?? 0) + fullItem.OfficialRating = json["OfficialRating"].string ?? "PG-13" + fullItem.Watched = json["UserData"]["Played"].bool ?? false; + _watched.wrappedValue = fullItem.Watched + _favorite.wrappedValue = json["UserData"]["IsFavorite"].bool ?? false; + + //Process runtime + let seconds: Int = ((json["RunTimeTicks"].int ?? 0)/10000000) + fullItem.RuntimeTicks = json["RunTimeTicks"].int ?? 0; + let hours = (seconds/3600) + let minutes = ((seconds - (hours * 3600))/60) + if(hours != 0) { + fullItem.Runtime = "\(hours):\(String(minutes).leftPad(toWidth: 2, withString: "0"))" + } else { + fullItem.Runtime = "\(String(minutes).leftPad(toWidth: 2, withString: "0"))m" + } + + if(fullItem.Progress != 0) { + let remainingSecs = (Double(json["RunTimeTicks"].int ?? 0) - fullItem.Progress)/10000000 + let proghours = Int(remainingSecs/3600) + let progminutes = Int((Int(remainingSecs) - (proghours * 3600))/60) + if(proghours != 0) { + _progressString.wrappedValue = "\(proghours):\(String(progminutes).leftPad(toWidth: 2, withString: "0"))" + } else { + _progressString.wrappedValue = "\(String(progminutes).leftPad(toWidth: 2, withString: "0"))m" + } + } + } catch { + + } + break + case .failure(let error): + debugPrint(error) + break + } + _isLoading.wrappedValue = false; + } + } + + var body: some View { + if(playing) { + PlayerDemo(item: fullItem, playing: $playing).onAppear(perform: lockOrientations) + } else { + LoadingView(isShowing: $isLoading) { + ScrollView() { + VStack(alignment:.leading) { + if(!isLoading) { + GeometryReader { geometry in + VStack() { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.Id)/Images/Backdrop?maxWidth=3840&quality=90&tag=\(fullItem.Backdrop)")!) + .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size + .placeholder { + Image(uiImage: UIImage(blurHash: (fullItem.BackdropBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : fullItem.BackdropBlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) * 0.5625) + } + .opacity(0.3) + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) * 0.5625) + .shadow(radius: 5) + .overlay( + HStack() { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.Id)/Images/Primary?fillWidth=300&fillHeight=450&quality=90&tag=\(fullItem.Poster)")!) + .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size + .placeholder { + Image(uiImage: UIImage(blurHash: (fullItem.PosterBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : fullItem.PosterBlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .frame(width: 120, height: 180) + .cornerRadius(10) + } + .frame(width: 120, height: 180) + .cornerRadius(10) + VStack(alignment: .leading) { + Spacer() + Text(fullItem.Name).font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + .offset(y: -4) + HStack() { + Text(String(fullItem.ProductionYear)).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + Text(fullItem.Runtime).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + Text(fullItem.OfficialRating).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1) + ) + } + + }.offset(x: 0, y: -46) + }.offset(x: 16, y: 40) + , alignment: .bottomLeading) + VStack(alignment: .leading) { + HStack() { + //Play button + Button() { + playing = true; + } label: { + HStack() { + Text(fullItem.Progress == 0 ? "Play" : "\(progressString) left").foregroundColor(Color.white).font(.callout).fontWeight(.semibold) + Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) + } + .frame(width: 120, height: 35) + .background(Color(UIColor.systemBlue)) + .cornerRadius(10) + }.buttonStyle(PlainButtonStyle()) + .frame(width: 120, height: 25) + Spacer() + HStack() { + Button() { + favorite.toggle() + } label: { + if(!favorite) { + Image(systemName: "heart").foregroundColor(Color.primary).font(.system(size: 20)) + } else { + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)).font(.system(size: 20)) + } + } + Button() { + watched.toggle() + } label: { + if(watched) { + Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary).font(.system(size: 20)) + } else { + Image(systemName: "xmark.rectangle").foregroundColor(Color.primary).font(.system(size: 20)) + } + } + } + } + Text(fullItem.Tagline).font(.body).italic().padding(.top, 7).fixedSize(horizontal: false, vertical: true) + Text(fullItem.Overview).font(.footnote).padding(.top, 3).fixedSize(horizontal: false, vertical: true) + }.padding(EdgeInsets(top: 24, leading: 16, bottom: 0, trailing: 16)) + } + } + } + } + }.navigationBarTitleDisplayMode(.inline) + .navigationTitle("Details") + .supportedOrientations(.allButUpsideDown) + .prefersHomeIndicatorAutoHidden(false) + .withHostingWindow() { window in + let rootVC = window?.rootViewController; + let UIHostingcontroller: PreferenceUIHostingController = rootVC as! PreferenceUIHostingController; + vc = UIHostingcontroller; + } + .introspectTabBarController { (UITabBarController) in + UITabBarController.tabBar.isHidden = false + } + }.onAppear(perform: loadData) + } + } +} diff --git a/JellyfinPlayer/MultiSelector.swift b/JellyfinPlayer/MultiSelector.swift new file mode 100644 index 00000000..2d4b7a61 --- /dev/null +++ b/JellyfinPlayer/MultiSelector.swift @@ -0,0 +1,73 @@ +// +// MultiSelector.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 5/2/21. +// + +import SwiftUI + +private struct MultiSelectionView: View { + let options: [Selectable] + let optionToString: (Selectable) -> String + let label: String + + @Binding var selected: Set + + var body: some View { + List { + ForEach(options) { selectable in + Button(action: { toggleSelection(selectable: selectable) }) { + HStack { + Text(optionToString(selectable)).foregroundColor(Color.primary) + Spacer() + if selected.contains { $0.id == selectable.id } { + Image(systemName: "checkmark").foregroundColor(.accentColor) + } + } + }.tag(selectable.id) + } + }.listStyle(GroupedListStyle()) + } + + private func toggleSelection(selectable: Selectable) { + if let existingIndex = selected.firstIndex(where: { $0.id == selectable.id }) { + selected.remove(at: existingIndex) + } else { + selected.insert(selectable) + } + } +} + +struct MultiSelector: View { + let label: String + let options: [Selectable] + let optionToString: (Selectable) -> String + + var selected: Binding> + + private var formattedSelectedListString: String { + ListFormatter.localizedString(byJoining: selected.wrappedValue.map { optionToString($0) }) + } + + var body: some View { + NavigationLink(destination: multiSelectionView()) { + HStack { + Text(label) + Spacer() + Text(formattedSelectedListString) + .foregroundColor(.gray) + .multilineTextAlignment(.trailing) + } + } + } + + private func multiSelectionView() -> some View { + MultiSelectionView( + options: options, + optionToString: optionToString, + label: self.label, + selected: selected + ) + } +} diff --git a/JellyfinPlayer/NextUpView.swift b/JellyfinPlayer/NextUpView.swift new file mode 100644 index 00000000..70e50ae9 --- /dev/null +++ b/JellyfinPlayer/NextUpView.swift @@ -0,0 +1,107 @@ +// +// NextUpView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 4/30/21. +// + +import SwiftUI +import SwiftyRequest +import SwiftyJSON +import SDWebImageSwiftUI + +struct NextUpView: View { + @Environment(\.managedObjectContext) private var viewContext + @EnvironmentObject var globalData: GlobalData + + @State var resumeItems: [ResumeItem] = [] + @State private var viewDidLoad: Int = 0; + @State private var isLoading: Bool = false; + + func onAppear() { + if(globalData.server?.baseURI == "") { + return + } + if(viewDidLoad == 1) { + return + } + _viewDidLoad.wrappedValue = 1; + let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + "/Shows/NextUp?Limit=12&Recursive=true&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CThumb&MediaTypes=Video&UserId=\(globalData.user?.user_id ?? "")") + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + let body = response.body + do { + let json = try JSON(data: body) + for (_,item):(String, JSON) in json["Items"] { + // Do something you want + let itemObj = ResumeItem() + itemObj.Image = item["SeriesPrimaryImageTag"].string ?? "" + itemObj.ImageType = "Primary" + itemObj.BlurHash = item["ImageBlurHashes"]["Primary"][itemObj.Image].string ?? "" + itemObj.Name = item["Name"].string ?? "" + itemObj.Type = item["Type"].string ?? "" + itemObj.IndexNumber = item["IndexNumber"].int ?? nil + itemObj.Id = item["Id"].string ?? "" + itemObj.ParentIndexNumber = item["ParentIndexNumber"].int ?? nil + itemObj.SeasonId = item["SeasonId"].string ?? nil + itemObj.SeriesId = item["SeriesId"].string ?? nil + itemObj.SeriesName = item["SeriesName"].string ?? nil + + _resumeItems.wrappedValue.append(itemObj) + } + _isLoading.wrappedValue = false; + } catch { + + } + break + case .failure(let error): + debugPrint(error) + _viewDidLoad.wrappedValue = 0; + break + } + } + } + + var body: some View { + VStack(alignment: .leading) { + Text("Next Up").font(.title2).fontWeight(.bold).padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + ScrollView(.horizontal, showsIndicators: false) { + HStack() { + if(isLoading == false) { + Spacer().frame(width:18) + ForEach(resumeItems, id: \.Id) { item in + VStack(alignment: .leading) { + Spacer().frame(height:10) + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.SeriesId ?? "")/Images/\(item.ImageType)?fillWidth=300&fillHeight=450&quality=90&tag=\(item.Image)")!) + .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size + .placeholder { + Image(uiImage: UIImage(blurHash: (item.BlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : item.BlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .scaledToFit() + .cornerRadius(10) + } + .frame(width: 100, height: 150) + .cornerRadius(10) + .shadow(radius: 6) + Text(item.SeriesName ?? "") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + Spacer().frame(height:5) + } + .frame(width: 100) + Spacer().frame(width:12) + } + Spacer().frame(width:18) + } + } + }.padding(EdgeInsets(top: -2, leading: 0, bottom: 0, trailing: 0)) + }.onAppear(perform: onAppear).padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } +} diff --git a/JellyfinPlayer/PersistenceController.swift b/JellyfinPlayer/PersistenceController.swift new file mode 100644 index 00000000..8f179c60 --- /dev/null +++ b/JellyfinPlayer/PersistenceController.swift @@ -0,0 +1,53 @@ +// +// Persistence.swift +// JFPlayer +// +// Created by Aiden Vigue on 4/29/21. +// + +import CoreData + +struct PersistenceController { + static let shared = PersistenceController() + + static var preview: PersistenceController = { + let result = PersistenceController(inMemory: true) + let viewContext = result.container.viewContext + + + do { + try viewContext.save() + } catch { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + return result + }() + + let container: NSPersistentCloudKitContainer + + init(inMemory: Bool = false) { + container = NSPersistentCloudKitContainer(name: "Model") + if inMemory { + container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") + } + container.loadPersistentStores(completionHandler: { (storeDescription, error) in + if let error = error as NSError? { + // Replace this implementation with code to handle the error appropriately. + // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. + + /* + Typical reasons for an error here include: + * The parent directory does not exist, cannot be created, or disallows writing. + * The persistent store is not accessible, due to permissions or data protection when the device is locked. + * The device is out of space. + * The store could not be migrated to the current model version. + Check the error message to determine what the actual problem was. + */ + fatalError("Unresolved error \(error), \(error.userInfo)") + } + }) + } +} diff --git a/JellyfinPlayer/PlayerDemo.swift b/JellyfinPlayer/PlayerDemo.swift new file mode 100644 index 00000000..ef695656 --- /dev/null +++ b/JellyfinPlayer/PlayerDemo.swift @@ -0,0 +1,436 @@ +// +// PlayerDemo.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 5/10/21. +// + +import SwiftUI +import SwiftyJSON +import SwiftyRequest +import AVKit +import MobileVLCKit + +struct Subtitle { + var name: String; + var id: Int32; + var url: URL; +} + +extension String { + public func leftPad(toWidth width: Int, withString string: String?) -> String { + let paddingString = string ?? " " + + if self.count >= width { + return self + } + + let remainingLength: Int = width - self.count + var padString = String() + for _ in 0 ..< remainingLength { + padString += paddingString + } + + return "\(padString)\(self)" + } + +} +struct PlayerDemo: View { + @EnvironmentObject var globalData: GlobalData + @Environment(\.presentationMode) var presentationMode: Binding + var item: DetailItem; + @State private var pbitem: PlaybackItem = PlaybackItem(videoType: VideoType.direct, videoUrl: URL(string: "https://example.com")!, subtitles: []); + @State private var streamLoading = false; + @State private var vlcplayer: VLCMediaPlayer = VLCMediaPlayer(options: ["-vv", "--sub-margin=-50", "--network-caching=10000"]); + @State private var isPlaying = false; + @State private var subtitles: [Subtitle] = []; + @State private var inactivity: Bool = true; + @State private var lastActivityTime: Double = 0; + @State private var scrub: Double = 0; + @State private var timeText: String = "-:--:--"; + @State private var playPauseButtonSystemName: String = "pause"; + @State private var playSessionId: String = ""; + @State private var lastPosition: Double = 0; + @State private var iterations: Int = 0; + @State private var captionConfiguration: Bool = false { + didSet { + if(captionConfiguration == false) { + vlcplayer.play() + } + } + }; + @State private var selectedCaptionTrack: Int32 = -1; + var playing: Binding; + + init(item: DetailItem, playing: Binding) { + self.item = item; + self.playing = playing; + } + + @State var lastProgressReportSent: Double = CACurrentMediaTime() + + func keepUpWithPlayerState() { + if(!vlcplayer.isPlaying) { + while(!vlcplayer.isPlaying) {} + } + while(vlcplayer.state != VLCMediaPlayerState.stopped) { + _streamLoading.wrappedValue = false; + while(vlcplayer.isPlaying) { + vlcplayer.currentVideoSubTitleIndex = _selectedCaptionTrack.wrappedValue; + usleep(500000) + if(CACurrentMediaTime() - lastProgressReportSent > 10) { + sendProgressReport() + _lastProgressReportSent.wrappedValue = CACurrentMediaTime() + } + if(vlcplayer.time.intValue != 0) { + _scrub.wrappedValue = Double(Double(vlcplayer.time.intValue) / Double(vlcplayer.time.intValue + abs(vlcplayer.remainingTime.intValue))); + + //Turn remainingTime into text + let remainingTime = abs(vlcplayer.remainingTime.intValue)/1000; + let hours = remainingTime / 3600; + let minutes = (remainingTime % 3600) / 60; + let seconds = (remainingTime % 3600) % 60; + if(hours != 0) { + timeText = "\(Int(hours)):\(String(Int((minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int((seconds))).leftPad(toWidth: 2, withString: "0"))"; + } else { + timeText = "\(String(Int((minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int((seconds))).leftPad(toWidth: 2, withString: "0"))"; + } + } + if(CACurrentMediaTime() - _lastActivityTime.wrappedValue > 5 && vlcplayer.state != VLCMediaPlayerState.paused) { + _inactivity.wrappedValue = true + } + if((lastPosition == Double(vlcplayer.position) && vlcplayer.state != VLCMediaPlayerState.paused)) { + if(iterations > 3) { + _iterations.wrappedValue = 0; + _streamLoading.wrappedValue = true; + print("Buffering") + } + _iterations.wrappedValue+=1; + } else { + _iterations.wrappedValue = 0; + print("Not Buffering") + _streamLoading.wrappedValue = false; + } + if(vlcplayer.state == VLCMediaPlayerState.error) { + playing.wrappedValue = false; + } + _lastPosition.wrappedValue = Double(vlcplayer.position) + } + } + } + + func sendProgressReport() { + var progressBody: String = ""; + if(pbitem.videoType == VideoType.direct) { + progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":\(vlcplayer.state == VLCMediaPlayerState.paused ? "true" : "false"),\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":140000000,\"PositionTicks\":\(Int(vlcplayer.position * Float(item.RuntimeTicks))),\"PlaybackStartTimeTicks\":16209515560670000,\"AudioStreamIndex\":1,\"BufferedRanges\":[{\"start\":0,\"end\":569735888.888889}],\"PlayMethod\":\"\(pbitem.videoType == VideoType.direct ? "DirectStream" : "Transcode")\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem1\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"EventName\":\"timeupdate\"}"; + } else { + progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":\(vlcplayer.state == VLCMediaPlayerState.paused ? "true" : "false"),\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":140000000,\"PositionTicks\":\(Int(vlcplayer.position * Float(item.RuntimeTicks))),\"PlaybackStartTimeTicks\":16209515560670000,\"AudioStreamIndex\":1,\"BufferedRanges\":[{\"start\":0,\"end\":569735888.888889}],\"PlayMethod\":\"Transcode\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem1\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"EventName\":\"timeupdate\"}"; + } + + let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing/Progress") + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + request.messageBody = progressBody.data(using: .ascii); + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + let body = response.body + print(body) + break + case .failure(let error): + debugPrint(error) + break + } + } + } + + func sendStopReport() { + var progressBody: String = ""; + if(pbitem.videoType == VideoType.direct) { + progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":true,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":140000000,\"PositionTicks\":\(Int(vlcplayer.position * Float(item.RuntimeTicks))),\"PlaybackStartTimeTicks\":16209515560670000,\"AudioStreamIndex\":1,\"BufferedRanges\":[],\"PlayMethod\":\"\(pbitem.videoType == VideoType.direct ? "DirectStream" : "Transcode")\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem1\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(item.Id)\",\"PlaylistItemId\":\"playlistItem1\"}]}"; + } else { + progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":true,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":140000000,\"PositionTicks\":\(Int(vlcplayer.position * Float(item.RuntimeTicks))),\"PlaybackStartTimeTicks\":16209515560670000,\"AudioStreamIndex\":1,\"BufferedRanges\":[{\"start\":0,\"end\":100000}],\"PlayMethod\":\"Transcode\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem1\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(item.Id)\",\"PlaylistItemId\":\"playlistItem1\"}]}"; + } + + let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing/Stopped") + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + request.messageBody = progressBody.data(using: .ascii); + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + let body = response.body + print(body) + break + case .failure(let error): + debugPrint(error) + break + } + } + } + + func sendPlayReport() { + var progressBody: String = ""; + if(pbitem.videoType == VideoType.hls) { + progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":false,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":140000000,\"PositionTicks\":0,\"PlaybackStartTimeTicks\":16209515560670000,\"AudioStreamIndex\":1,\"BufferedRanges\":[],\"PlayMethod\":\"Transcode\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem1\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(item.Id)\",\"PlaylistItemId\":\"playlistItem1\"}]}"; + } else { + progressBody = "{\"VolumeLevel\":100,\"IsMuted\":false,\"IsPaused\":false,\"RepeatMode\":\"RepeatNone\",\"ShuffleMode\":\"Sorted\",\"MaxStreamingBitrate\":140000000,\"PositionTicks\":0,\"PlaybackStartTimeTicks\":16209515560670000,\"AudioStreamIndex\":1,\"BufferedRanges\":[],\"PlayMethod\":\"Transcode\",\"PlaySessionId\":\"\(playSessionId)\",\"PlaylistItemId\":\"playlistItem1\",\"MediaSourceId\":\"\(item.Id)\",\"CanSeek\":true,\"ItemId\":\"\(item.Id)\",\"NowPlayingQueue\":[{\"Id\":\"\(item.Id)\",\"PlaylistItemId\":\"playlistItem1\"}]}"; + } + + let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Sessions/Playing") + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + request.messageBody = progressBody.data(using: .ascii); + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + let body = response.body + print(body) + break + case .failure(let error): + debugPrint(error) + break + } + } + } + + func startStream() { + _streamLoading.wrappedValue = true; + //print((globalData.server?.baseURI ?? "") + "/Items/\(item)/PlaybackInfo?UserId=\(globalData.user?.user_id ?? "")&StartTimeTicks=0&IsPlayback=true&AutoOpenLiveStream=true&MaxStreamingBitrate=60000000") + let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Items/\(item.Id)/PlaybackInfo?UserId=\(globalData.user?.user_id ?? "")&StartTimeTicks=0&IsPlayback=true&AutoOpenLiveStream=true&MaxStreamingBitrate=60000000") + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + request.messageBody = "{\"DeviceProfile\":{\"MaxStreamingBitrate\":120000000,\"MaxStaticBitrate\":100000000,\"MusicStreamingTranscodingBitrate\":384000,\"DirectPlayProfiles\":[{\"Container\":\"webm\",\"Type\":\"Video\",\"VideoCodec\":\"vp8,vp9\",\"AudioCodec\":\"vorbis\"},{\"Container\":\"mp4,m4v,mkv\",\"Type\":\"Video\",\"VideoCodec\":\"hevc,h264,vp8,vp9\",\"AudioCodec\":\"aac,mp3,ac3,eac3,flac,alac,vorbis,dts\"},{\"Container\":\"mov\",\"Type\":\"Video\",\"VideoCodec\":\"h264\",\"AudioCodec\":\"aac,mp3,ac3,eac3,flac,alac,vorbis\"},{\"Container\":\"mp3\",\"Type\":\"Audio\"},{\"Container\":\"aac\",\"Type\":\"Audio\"},{\"Container\":\"m4a\",\"AudioCodec\":\"aac\",\"Type\":\"Audio\"},{\"Container\":\"m4b\",\"AudioCodec\":\"aac\",\"Type\":\"Audio\"},{\"Container\":\"flac\",\"Type\":\"Audio\"},{\"Container\":\"alac\",\"Type\":\"Audio\"},{\"Container\":\"m4a\",\"AudioCodec\":\"alac\",\"Type\":\"Audio\"},{\"Container\":\"m4b\",\"AudioCodec\":\"alac\",\"Type\":\"Audio\"},{\"Container\":\"webma\",\"Type\":\"Audio\"},{\"Container\":\"webm\",\"AudioCodec\":\"webma\",\"Type\":\"Audio\"},{\"Container\":\"wav\",\"Type\":\"Audio\"}],\"TranscodingProfiles\":[{\"Container\":\"aac\",\"Type\":\"Audio\",\"AudioCodec\":\"aac\",\"Context\":\"Streaming\",\"Protocol\":\"hls\",\"MaxAudioChannels\":\"6\",\"MinSegments\":\"2\",\"BreakOnNonKeyFrames\":true},{\"Container\":\"aac\",\"Type\":\"Audio\",\"AudioCodec\":\"aac\",\"Context\":\"Streaming\",\"Protocol\":\"http\",\"MaxAudioChannels\":\"6\"},{\"Container\":\"mp3\",\"Type\":\"Audio\",\"AudioCodec\":\"mp3\",\"Context\":\"Streaming\",\"Protocol\":\"http\",\"MaxAudioChannels\":\"6\"},{\"Container\":\"wav\",\"Type\":\"Audio\",\"AudioCodec\":\"wav\",\"Context\":\"Streaming\",\"Protocol\":\"http\",\"MaxAudioChannels\":\"6\"},{\"Container\":\"mp3\",\"Type\":\"Audio\",\"AudioCodec\":\"mp3\",\"Context\":\"Static\",\"Protocol\":\"http\",\"MaxAudioChannels\":\"6\"},{\"Container\":\"aac\",\"Type\":\"Audio\",\"AudioCodec\":\"aac\",\"Context\":\"Static\",\"Protocol\":\"http\",\"MaxAudioChannels\":\"6\"},{\"Container\":\"wav\",\"Type\":\"Audio\",\"AudioCodec\":\"wav\",\"Context\":\"Static\",\"Protocol\":\"http\",\"MaxAudioChannels\":\"6\"},{\"Container\":\"ts\",\"Type\":\"Video\",\"AudioCodec\":\"aac,mp3,ac3,eac3\",\"VideoCodec\":\"h264\",\"Context\":\"Streaming\",\"Protocol\":\"hls\",\"MaxAudioChannels\":\"6\",\"MinSegments\":\"2\",\"BreakOnNonKeyFrames\":true},{\"Container\":\"webm\",\"Type\":\"Video\",\"AudioCodec\":\"vorbis\",\"VideoCodec\":\"vpx\",\"Context\":\"Streaming\",\"Protocol\":\"http\",\"MaxAudioChannels\":\"6\"},{\"Container\":\"mp4\",\"Type\":\"Video\",\"AudioCodec\":\"aac,mp3,ac3,eac3,flac,alac,vorbis\",\"VideoCodec\":\"h264\",\"Context\":\"Static\",\"Protocol\":\"http\"}],\"ContainerProfiles\":[],\"CodecProfiles\":[{\"Type\":\"Video\",\"Codec\":\"h264\",\"Conditions\":[{\"Condition\":\"NotEquals\",\"Property\":\"IsAnamorphic\",\"Value\":\"true\",\"IsRequired\":false},{\"Condition\":\"EqualsAny\",\"Property\":\"VideoProfile\",\"Value\":\"high|main|baseline|constrained baseline\",\"IsRequired\":false},{\"Condition\":\"LessThanEqual\",\"Property\":\"VideoLevel\",\"Value\":\"80\",\"IsRequired\":false},{\"Condition\":\"NotEquals\",\"Property\":\"IsInterlaced\",\"Value\":\"true\",\"IsRequired\":false}]},{\"Type\":\"Video\",\"Codec\":\"hevc\",\"Conditions\":[{\"Condition\":\"NotEquals\",\"Property\":\"IsAnamorphic\",\"Value\":\"true\",\"IsRequired\":false},{\"Condition\":\"EqualsAny\",\"Property\":\"VideoProfile\",\"Value\":\"main|main 10\",\"IsRequired\":false},{\"Condition\":\"LessThanEqual\",\"Property\":\"VideoLevel\",\"Value\":\"190\",\"IsRequired\":false},{\"Condition\":\"NotEquals\",\"Property\":\"IsInterlaced\",\"Value\":\"true\",\"IsRequired\":false}]}],\"SubtitleProfiles\":[{\"Format\":\"vtt\",\"Method\":\"External\"},{\"Format\":\"ass\",\"Method\":\"External\"},{\"Format\":\"ssa\",\"Method\":\"External\"}],\"ResponseProfiles\":[{\"Type\":\"Video\",\"Container\":\"m4v\",\"MimeType\":\"video/mp4\"}]}}".data(using: .ascii); + + request.responseData() { (result: Result, RestError>) in + switch result { + case .success(let response): + let body = response.body + do { + let json = try JSON(data: body) + _playSessionId.wrappedValue = json["PlaySessionId"].string ?? ""; + if(json["MediaSources"][0]["TranscodingUrl"].string != nil) { + //Video is transcoded due to TranscodingReason - also may just be remuxed + for (_,stream):(String, JSON) in json["MediaSources"][0]["MediaStreams"] { + if(stream["Type"].string == "Subtitle") { + print("Found subtitle track: \(stream["DeliveryUrl"].string ?? "")") + } + } + let streamURL: URL = URL(string: "\(globalData.server?.baseURI ?? "")\((json["MediaSources"][0]["TranscodingUrl"].string ?? "").replacingOccurrences(of: "master.m3u8", with: "main.m3u8"))")! + print(streamURL); + let item = PlaybackItem(videoType: VideoType.hls, videoUrl: streamURL, subtitles: []) + var SubIndex: Int32 = 2; + let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: URL(string: "https://example.com")!) + _subtitles.wrappedValue.append(disableSubtitleTrack); + for (_,stream):(String, JSON) in json["MediaSources"][0]["MediaStreams"] { + if(stream["Type"].string == "Subtitle") { + let deliveryUrl = URL(string: "\(globalData.server?.baseURI ?? "")\(stream["DeliveryUrl"].string ?? "")")! + let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: SubIndex, url: deliveryUrl) + SubIndex+=1; + _subtitles.wrappedValue.append(subtitle); + } + } + sendPlayReport(); + pbitem = item; + pbitem.subtitles = subtitles; + _isPlaying.wrappedValue = true; + } else { + print("Direct play of item \(item.Name)") + let streamURL: URL = URL(string: "\(globalData.server?.baseURI ?? "")/Videos/\(item.Id)/stream.mp4?Static=true&mediaSourceId=\(item.Id)&deviceId=\(globalData.user?.device_uuid ?? "")&api_key=\(globalData.authToken)&Tag=\(json["MediaSources"][0]["ETag"])")!; + let item = PlaybackItem(videoType: VideoType.direct, videoUrl: streamURL, subtitles: []) + var SubIndex: Int32 = 2; + let disableSubtitleTrack = Subtitle(name: "Disabled", id: -1, url: URL(string: "https://example.com")!) + _subtitles.wrappedValue.append(disableSubtitleTrack); + for (_,stream):(String, JSON) in json["MediaSources"][0]["MediaStreams"] { + if(stream["Type"].string == "Subtitle") { + let deliveryUrl = URL(string: "\(globalData.server?.baseURI ?? "")\(stream["DeliveryUrl"].string ?? "")")! + let subtitle = Subtitle(name: stream["DisplayTitle"].string ?? "", id: SubIndex, url: deliveryUrl) + SubIndex+=1; + _subtitles.wrappedValue.append(subtitle); + } + } + pbitem = item; + pbitem.subtitles = subtitles; + _isPlaying.wrappedValue = true; + } + DispatchQueue.global(qos: .userInitiated).async { [self] in + self.keepUpWithPlayerState() + } + } catch { + + } + break + case .failure(let error): + debugPrint(error) + break + } + } + } + + func processScrubbingState() { + let videoDuration = Double(vlcplayer.time.intValue + abs(vlcplayer.remainingTime.intValue))/1000 + while(vlcplayer.state != VLCMediaPlayerState.paused) {} + while(vlcplayer.state == VLCMediaPlayerState.paused) { + let secondsScrubbedTo = round(_scrub.wrappedValue * videoDuration); + let scrubRemaining = videoDuration - secondsScrubbedTo; + usleep(10000) + + let remainingTime = scrubRemaining; + let hours = floor(remainingTime / 3600); + let minutes = (remainingTime.truncatingRemainder(dividingBy: 3600)) / 60; + let seconds = (remainingTime.truncatingRemainder(dividingBy: 3600)).truncatingRemainder(dividingBy: 60); + if(hours != 0) { + timeText = "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))"; + } else { + timeText = "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))"; + } + } + } + + func resetTimer() { + if(_inactivity.wrappedValue == false) { + _inactivity.wrappedValue = true; + return; + } + _lastActivityTime.wrappedValue = CACurrentMediaTime() + _inactivity.wrappedValue = false; + } + + var body: some View { + LoadingView(isShowing: ($streamLoading)) { + ZStack() { + VLCPlayer(url: $pbitem, player: $vlcplayer, startTime: Int(item.Progress)).onDisappear(perform: { + _isPlaying.wrappedValue = false; + vlcplayer.stop() + }) + VStack() { + HStack() { + HStack() { + Button() { + self.playing.wrappedValue = false; + } label: { + HStack() { + Image(systemName: "chevron.left").font(.system(size: 20)).foregroundColor(.white) + } + }.frame(width: 20) + Spacer() + Text(item.Name).font(.headline).fontWeight(.semibold).foregroundColor(.white).offset(x:-4) + Spacer() + Button() { + vlcplayer.pause() + self.captionConfiguration = true; + } label: { + HStack() { + Image(systemName: "captions.bubble").font(.system(size: 20)).foregroundColor(.white) + } + }.frame(width: 20) + } + Spacer() + }.padding(EdgeInsets(top: 55, leading: 40, bottom: 0, trailing: 40)) + Spacer() + HStack() { + Spacer() + Button() { + vlcplayer.jumpBackward(15) + } label: { + Image(systemName: "gobackward.15").font(.system(size: 40)).foregroundColor(.white) + }.padding(20) + Spacer() + Button() { + if(vlcplayer.state != VLCMediaPlayerState.paused) { + vlcplayer.pause() + playPauseButtonSystemName = "play" + sendProgressReport() + } else { + vlcplayer.play() + playPauseButtonSystemName = "pause" + sendProgressReport() + } + } label: { + Image(systemName: playPauseButtonSystemName).font(.system(size: 55)).foregroundColor(.white) + }.padding(20) + Spacer() + Button() { + vlcplayer.jumpForward(15) + } label: { + Image(systemName: "goforward.15").font(.system(size: 40)).foregroundColor(.white) + }.padding(20) + Spacer() + }.padding(.leading, -20) + Spacer() + HStack() { + Slider(value: $scrub, onEditingChanged: { bool in + let videoPosition = Double(vlcplayer.time.intValue) + let videoDuration = Double(vlcplayer.time.intValue + abs(vlcplayer.remainingTime.intValue)) + if(bool == true) { + vlcplayer.pause() + DispatchQueue.global(qos: .userInitiated).async { [self] in + self.processScrubbingState() + } + } else { + //Scrub is value from 0..1 - find position in video and add / or remove. + let secondsScrubbedTo = round(_scrub.wrappedValue * videoDuration); + let offset = secondsScrubbedTo - videoPosition; + sendProgressReport() + vlcplayer.play() + if(offset > 0) { + vlcplayer.jumpForward(Int32(offset)/1000); + } else { + vlcplayer.jumpBackward(Int32(abs(offset))/1000); + } + } + }) + .accentColor(Color(red: 172/255, green: 92/255, blue: 195/255)) + Text(timeText).fontWeight(.semibold).frame(width: 80).foregroundColor(.white) + }.padding(EdgeInsets(top: -20, leading: 44, bottom: 42, trailing: 40)) + }.transition(.fade) + .padding(EdgeInsets(top: 0, leading: -30, bottom: 0, trailing: -30)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(UIColor.black).opacity(0.4)) + .isHidden(inactivity) + }.padding(EdgeInsets(top: 0, leading: 34, bottom: 0, trailing: 34)) + }.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .onAppear(perform: startStream) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .statusBar(hidden: true) + .introspectTabBarController { (UITabBarController) in + UITabBarController.tabBar.isHidden = true + } + .prefersHomeIndicatorAutoHidden(true) + .supportedOrientations(.landscapeRight) + .edgesIgnoringSafeArea(.all) + .onTapGesture(perform: resetTimer) + .overrideViewPreference(.dark) + .popover( isPresented: self.$captionConfiguration, arrowEdge: .bottom) { + NavigationView() { + Form() { + Picker("Closed Captions", selection: $selectedCaptionTrack) { + ForEach(subtitles, id: \.id) { caption in + Text(caption.name).tag(caption.id) + } + }.onChange(of: selectedCaptionTrack) { track in + vlcplayer.currentVideoSubTitleIndex = track; + } + } + .navigationBarTitle("Audio & Captions", displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + captionConfiguration = false; + playPauseButtonSystemName = "pause"; + } label: { + HStack() { + Text("Back").font(.callout) + } + } + } + } + }.edgesIgnoringSafeArea(.bottom) + } + } +} diff --git a/JellyfinPlayer/Preview Content/Preview Assets.xcassets/Contents.json b/JellyfinPlayer/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/JellyfinPlayer/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/JellyfinPlayer/SettingsView.swift b/JellyfinPlayer/SettingsView.swift new file mode 100644 index 00000000..b417dd89 --- /dev/null +++ b/JellyfinPlayer/SettingsView.swift @@ -0,0 +1,20 @@ +// +// SettingsView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 4/29/21. +// + +import SwiftUI + +struct SettingsView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView() + } +} diff --git a/JellyfinPlayer/VLCPlayer.swift b/JellyfinPlayer/VLCPlayer.swift new file mode 100644 index 00000000..fedbd76e --- /dev/null +++ b/JellyfinPlayer/VLCPlayer.swift @@ -0,0 +1,86 @@ +// +// VideoPlayerView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 5/10/21. +// + +import SwiftUI +import MobileVLCKit + +extension NSNotification { + static let PlayerUpdate = NSNotification.Name.init("PlayerUpdate") +} + +enum VideoType { + case hls; + case direct; +} + +struct PlaybackItem { + var videoType: VideoType; + var videoUrl: URL; + var subtitles: [Subtitle]; +} + +struct VLCPlayer: UIViewRepresentable{ + var url: Binding; + var player: Binding; + var startTime: Int; + + func updateUIView(_ uiView: PlayerUIView, context: UIViewRepresentableContext) { + uiView.url = self.url + if(self.url.wrappedValue.videoUrl.absoluteString != "https://example.com") { + uiView.videoSetup() + } + } + + func makeUIView(context: Context) -> PlayerUIView { + return PlayerUIView(frame: .zero, url: url, player: self.player, startTime: self.startTime); + } +} + +class PlayerUIView: UIView, VLCMediaPlayerDelegate { + + private var mediaPlayer: Binding; + var url:Binding + var lastUrl: PlaybackItem? + var startTime: Int + + init(frame: CGRect, url: Binding, player: Binding, startTime: Int) { + self.mediaPlayer = player; + self.url = url; + self.startTime = startTime; + super.init(frame: frame) + mediaPlayer.wrappedValue.delegate = self + mediaPlayer.wrappedValue.drawable = self + } + + func videoSetup() { + if(lastUrl == nil || lastUrl?.videoUrl != url.wrappedValue.videoUrl) { + lastUrl = url.wrappedValue + print("update called") + print(self.url.wrappedValue.videoUrl) + mediaPlayer.wrappedValue.stop() + mediaPlayer.wrappedValue.media = VLCMedia(url: self.url.wrappedValue.videoUrl) + self.url.wrappedValue.subtitles.forEach() { sub in + if(sub.id != -1) { + mediaPlayer.wrappedValue.addPlaybackSlave(sub.url, type: .subtitle, enforce: false) + } + } + + mediaPlayer.wrappedValue.perform(Selector(("setTextRendererFontSize:")), with: 14) + mediaPlayer.wrappedValue.perform(Selector(("setTextRendererFont:")), with: "Copperplate") + mediaPlayer.wrappedValue.play() + mediaPlayer.wrappedValue.jumpForward(Int32(startTime/10000000)) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + } +}